Compare commits
30 Commits
v0.0.1
...
e506a343bc
| Author | SHA1 | Date | |
|---|---|---|---|
| e506a343bc | |||
| 8c5ccc94ec | |||
| fc5f41b9cc | |||
| 60f9fd52f0 | |||
| db87542096 | |||
| dd0a643d73 | |||
| 04b8a7006a | |||
| 27cf05aba6 | |||
| d5666fdcc3 | |||
| 792460b27a | |||
| 722e027f18 | |||
| 6bc697bd95 | |||
| 77191ef7da | |||
| f3db10f015 | |||
| a7fcd7dce5 | |||
| e1254c6b5f | |||
| bc531f81db | |||
| 83ac51fd11 | |||
| 18ca11f9bb | |||
| 49de0e277c | |||
| 3afef9d0d2 | |||
| c2b3e3a204 | |||
| 10bf6b422e | |||
| 0fd18bfb48 | |||
| 787747aa7f | |||
| 237eb2990f | |||
| 5ee6fcd54b | |||
| cbf5ed6c8c | |||
| a3acd9320a | |||
| 37f6c38caa |
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
dist
|
||||
coverage
|
||||
.git
|
||||
.env
|
||||
.env.development
|
||||
.env.production
|
||||
*.log
|
||||
22
.env.example
Normal file
22
.env.example
Normal file
@@ -0,0 +1,22 @@
|
||||
# Database
|
||||
DATABASE_URL=postgres://sori_studio:replace-with-random-password@sori-studio-db:5432/sori_studio
|
||||
DATABASE_NAME=sori_studio
|
||||
POSTGRES_DB=sori_studio
|
||||
POSTGRES_USER=sori_studio
|
||||
POSTGRES_PASSWORD=replace-with-random-password
|
||||
DB_PORT=43119
|
||||
|
||||
# Auth
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=replace-with-random-password
|
||||
|
||||
# Upload
|
||||
UPLOAD_DIR=/uploads
|
||||
MAX_FILE_SIZE=10485760
|
||||
|
||||
# Site
|
||||
NUXT_PUBLIC_SITE_URL=https://sori.studio
|
||||
NUXT_PUBLIC_SITE_TITLE=sori.studio
|
||||
|
||||
# Server
|
||||
APP_PORT=43118
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,6 +5,7 @@ node_modules/
|
||||
.output/
|
||||
.nuxt/
|
||||
dist/
|
||||
public/uploads/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
@@ -26,4 +27,4 @@ Thumbs.db
|
||||
npm-debug.log*
|
||||
|
||||
# Test
|
||||
coverage/
|
||||
coverage/
|
||||
|
||||
@@ -157,6 +157,7 @@
|
||||
- 기존 API 호출 패턴을 따른다.
|
||||
- 응답 구조 변경 시 docs/spec.md를 반드시 갱신한다.
|
||||
- 하드코딩된 값 사용을 금지한다.
|
||||
- 초기 백엔드는 별도 앱으로 분리하지 않고 Nuxt `server/api`를 사용한다.
|
||||
- 개발 환경과 운영 환경의 데이터베이스 연결 문자열을 혼용하지 않는다.
|
||||
- 운영 DB는 로컬 개발 서버에서 직접 사용하지 않는다.
|
||||
|
||||
|
||||
28
Dockerfile
Normal file
28
Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
||||
FROM node:22-alpine AS dependencies
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=dependencies /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=3000
|
||||
|
||||
COPY --from=builder /app/.output ./.output
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
5
app.vue
Normal file
5
app.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
138
assets/css/main.css
Normal file
138
assets/css/main.css
Normal file
@@ -0,0 +1,138 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--site-bg: #fbfbfa;
|
||||
--site-panel: #f6f6f5;
|
||||
--site-panel-strong: #ffffff;
|
||||
--site-text: #111111;
|
||||
--site-muted: #454545;
|
||||
--site-soft: #6f7480;
|
||||
--site-line: #e2e2e0;
|
||||
--site-input: #f2f2f1;
|
||||
--site-accent: #ff4f2e;
|
||||
--site-accent-text: #ffffff;
|
||||
--site-invert: #111111;
|
||||
--site-invert-text: #ffffff;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--site-bg: #050505;
|
||||
--site-panel: #080808;
|
||||
--site-panel-strong: #0d0d0d;
|
||||
--site-text: #f4f4f2;
|
||||
--site-muted: #c7c7c2;
|
||||
--site-soft: #8b8e96;
|
||||
--site-line: #252525;
|
||||
--site-input: #171717;
|
||||
--site-accent: #ff4f2e;
|
||||
--site-accent-text: #ffffff;
|
||||
--site-invert: #f4f4f2;
|
||||
--site-invert-text: #111111;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: Pretendard, ui-sans-serif, system-ui, sans-serif;
|
||||
color: var(--site-text);
|
||||
background: var(--site-bg);
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
margin: 0;
|
||||
background: var(--site-bg);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.site-shell {
|
||||
min-height: 100vh;
|
||||
color: var(--site-text);
|
||||
background: var(--site-bg);
|
||||
}
|
||||
|
||||
.site-content-grid {
|
||||
@apply mx-auto grid max-w-[1294px] grid-cols-1 px-4 lg:grid-cols-[287px_minmax(0,720px)_287px] lg:px-0;
|
||||
min-height: calc(100vh - 57px);
|
||||
background: var(--site-bg);
|
||||
}
|
||||
|
||||
.site-content-grid--menu-closed {
|
||||
@apply lg:grid-cols-[minmax(0,720px)_287px];
|
||||
max-width: 1007px;
|
||||
}
|
||||
|
||||
.site-section {
|
||||
border-bottom: 1px solid var(--site-line);
|
||||
background: var(--site-bg);
|
||||
}
|
||||
|
||||
.site-section-header {
|
||||
@apply px-6 py-8;
|
||||
}
|
||||
|
||||
.site-section-body {
|
||||
@apply px-6 py-4;
|
||||
}
|
||||
|
||||
.post-prose {
|
||||
@apply max-w-none text-[17px] leading-8;
|
||||
color: var(--site-text);
|
||||
}
|
||||
|
||||
.site-header {
|
||||
height: 57px;
|
||||
border-bottom: 1px solid var(--site-line);
|
||||
background: var(--site-panel);
|
||||
color: var(--site-text);
|
||||
}
|
||||
|
||||
.site-main {
|
||||
min-height: calc(100vh - 57px);
|
||||
border-left: 1px solid var(--site-line);
|
||||
border-right: 1px solid var(--site-line);
|
||||
background: var(--site-bg);
|
||||
}
|
||||
|
||||
.site-main--menu-closed {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.site-sidebar {
|
||||
min-height: calc(100vh - 57px);
|
||||
background: var(--site-panel);
|
||||
color: var(--site-text);
|
||||
}
|
||||
|
||||
.site-sidebar-section {
|
||||
border-bottom: 1px solid var(--site-line);
|
||||
}
|
||||
|
||||
.site-muted {
|
||||
color: var(--site-muted);
|
||||
}
|
||||
|
||||
.site-soft {
|
||||
color: var(--site-soft);
|
||||
}
|
||||
|
||||
.site-input {
|
||||
border: 1px solid var(--site-line);
|
||||
background: var(--site-input);
|
||||
color: var(--site-text);
|
||||
}
|
||||
|
||||
.site-button {
|
||||
background: var(--site-invert);
|
||||
color: var(--site-invert-text);
|
||||
}
|
||||
|
||||
.site-accent-button {
|
||||
background: var(--site-accent);
|
||||
color: var(--site-accent-text);
|
||||
}
|
||||
}
|
||||
1343
components/admin/AdminBlockEditor.vue
Normal file
1343
components/admin/AdminBlockEditor.vue
Normal file
File diff suppressed because it is too large
Load Diff
278
components/admin/AdminPageForm.vue
Normal file
278
components/admin/AdminPageForm.vue
Normal file
@@ -0,0 +1,278 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
initialPage: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
submitLabel: {
|
||||
type: String,
|
||||
default: '저장'
|
||||
},
|
||||
saving: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['submit'])
|
||||
|
||||
const slugTouched = ref(Boolean(props.initialPage.slug))
|
||||
const blockEditor = ref(null)
|
||||
const mediaItems = ref([])
|
||||
const isMediaPickerOpen = ref(false)
|
||||
const isLoadingMedia = ref(false)
|
||||
const isUploadingFeaturedImage = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
title: props.initialPage.title || '',
|
||||
slug: props.initialPage.slug || '',
|
||||
content: props.initialPage.content || '',
|
||||
featuredImage: props.initialPage.featuredImage || ''
|
||||
})
|
||||
|
||||
/**
|
||||
* 문자열을 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 라이브러리 목록 조회
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const fetchMediaItems = async () => {
|
||||
isLoadingMedia.value = true
|
||||
|
||||
try {
|
||||
mediaItems.value = await $fetch('/admin/api/media')
|
||||
} finally {
|
||||
isLoadingMedia.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지 선택 창 열기
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const openMediaPicker = async () => {
|
||||
isMediaPickerOpen.value = true
|
||||
await fetchMediaItems()
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지 선택 창 닫기
|
||||
* @returns {void}
|
||||
*/
|
||||
const closeMediaPicker = () => {
|
||||
isMediaPickerOpen.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지 선택
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {void}
|
||||
*/
|
||||
const selectFeaturedImage = (item) => {
|
||||
form.featuredImage = item.url
|
||||
closeMediaPicker()
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지 삭제
|
||||
* @returns {void}
|
||||
*/
|
||||
const removeFeaturedImage = () => {
|
||||
form.featuredImage = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지 파일 업로드
|
||||
* @param {Event} event - 파일 입력 이벤트
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const uploadFeaturedImage = async (event) => {
|
||||
const files = event.target.files
|
||||
|
||||
if (!files?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('files', files[0])
|
||||
isUploadingFeaturedImage.value = true
|
||||
|
||||
try {
|
||||
const result = await $fetch('/admin/api/uploads', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
form.featuredImage = result.files?.[0]?.url || ''
|
||||
} finally {
|
||||
event.target.value = ''
|
||||
isUploadingFeaturedImage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 제목 입력 후 본문 에디터로 이동
|
||||
* @returns {void}
|
||||
*/
|
||||
const focusContentEditor = () => {
|
||||
blockEditor.value?.focusFirstBlock()
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 입력값 제출
|
||||
* @returns {void}
|
||||
*/
|
||||
const submitPage = () => {
|
||||
emit('submit', {
|
||||
title: form.title.trim(),
|
||||
slug: toSlug(form.slug || form.title),
|
||||
content: form.content,
|
||||
featuredImage: form.featuredImage.trim() || null
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="admin-page-form grid gap-6" @submit.prevent="submitPage">
|
||||
<div class="admin-page-form__main grid gap-5 lg:grid-cols-[minmax(0,1fr)_18rem]">
|
||||
<section class="admin-page-form__content grid gap-4">
|
||||
<input
|
||||
v-model="form.title"
|
||||
class="admin-page-form__title-input border-0 bg-transparent px-0 py-2 text-5xl font-semibold leading-tight text-ink outline-none placeholder:text-soft"
|
||||
type="text"
|
||||
placeholder="페이지 제목"
|
||||
required
|
||||
@keydown.enter.prevent="focusContentEditor"
|
||||
>
|
||||
|
||||
<div class="admin-page-form__field grid gap-2 text-sm">
|
||||
<AdminBlockEditor ref="blockEditor" v-model="form.content" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="admin-page-form__settings grid content-start gap-4">
|
||||
<label class="admin-page-form__field grid gap-2 text-sm">
|
||||
<span class="admin-page-form__label font-medium">슬러그</span>
|
||||
<input
|
||||
v-model="form.slug"
|
||||
class="admin-page-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>
|
||||
|
||||
<div class="admin-page-form__field grid gap-2 text-sm">
|
||||
<span class="admin-page-form__label font-medium">대표 이미지</span>
|
||||
<figure v-if="form.featuredImage" class="admin-page-form__featured overflow-hidden rounded border border-line bg-white">
|
||||
<img class="admin-page-form__featured-image aspect-[4/3] w-full bg-surface object-cover" :src="form.featuredImage" alt="">
|
||||
<figcaption class="admin-page-form__featured-actions grid gap-2 p-3">
|
||||
<p class="admin-page-form__featured-url break-all text-xs text-muted">
|
||||
{{ form.featuredImage }}
|
||||
</p>
|
||||
<div class="admin-page-form__featured-buttons flex flex-wrap gap-2">
|
||||
<button class="admin-page-form__featured-change rounded border border-line px-3 py-1.5 text-xs font-semibold" type="button" @click="openMediaPicker">
|
||||
변경
|
||||
</button>
|
||||
<label class="admin-page-form__featured-reupload cursor-pointer rounded border border-line px-3 py-1.5 text-xs font-semibold">
|
||||
새 업로드
|
||||
<input class="sr-only" type="file" accept="image/*" @change="uploadFeaturedImage">
|
||||
</label>
|
||||
<button class="admin-page-form__featured-remove rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700" type="button" @click="removeFeaturedImage">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</figcaption>
|
||||
</figure>
|
||||
<div v-else class="admin-page-form__featured-empty grid gap-2 rounded border border-dashed border-line bg-white p-4">
|
||||
<button class="admin-page-form__featured-select rounded border border-line px-3 py-2 text-sm font-semibold" type="button" @click="openMediaPicker">
|
||||
미디어에서 선택
|
||||
</button>
|
||||
<label class="admin-page-form__featured-upload cursor-pointer rounded bg-[#15171a] px-3 py-2 text-center text-sm font-semibold text-white">
|
||||
{{ isUploadingFeaturedImage ? '업로드 중' : '새 이미지 업로드' }}
|
||||
<input class="sr-only" type="file" accept="image/*" @change="uploadFeaturedImage">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="admin-page-form__actions flex justify-end gap-3 border-t border-line pt-5">
|
||||
<NuxtLink class="admin-page-form__cancel rounded border border-line bg-white px-4 py-2 text-sm font-semibold" to="/admin/pages">
|
||||
취소
|
||||
</NuxtLink>
|
||||
<button
|
||||
class="admin-page-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>
|
||||
|
||||
<div
|
||||
v-if="isMediaPickerOpen"
|
||||
class="admin-page-form__media-picker fixed inset-0 z-50 grid place-items-center bg-black/40 px-5 py-8"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@click.self="closeMediaPicker"
|
||||
>
|
||||
<section class="admin-page-form__media-picker-panel max-h-[80vh] w-full max-w-4xl overflow-hidden bg-white text-ink shadow-xl">
|
||||
<div class="admin-page-form__media-picker-header flex items-center justify-between border-b border-line px-5 py-4">
|
||||
<h2 class="admin-page-form__media-picker-title text-lg font-semibold">
|
||||
대표 이미지 선택
|
||||
</h2>
|
||||
<button class="admin-page-form__media-picker-close rounded border border-line px-3 py-1.5 text-sm font-semibold" type="button" @click="closeMediaPicker">
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
<div class="admin-page-form__media-picker-body max-h-[62vh] overflow-y-auto p-5">
|
||||
<p v-if="isLoadingMedia" class="admin-page-form__media-picker-loading text-sm text-muted">
|
||||
미디어를 불러오는 중입니다.
|
||||
</p>
|
||||
<div v-else-if="mediaItems.length" class="admin-page-form__media-picker-grid grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6">
|
||||
<button
|
||||
v-for="item in mediaItems"
|
||||
:key="item.url"
|
||||
class="admin-page-form__media-picker-item overflow-hidden border border-line bg-white text-left"
|
||||
type="button"
|
||||
@click="selectFeaturedImage(item)"
|
||||
>
|
||||
<img class="admin-page-form__media-picker-image aspect-square w-full bg-surface object-cover" :src="item.url" :alt="item.title">
|
||||
<span class="admin-page-form__media-picker-name block truncate px-2 py-1.5 text-xs font-semibold text-ink">{{ item.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="admin-page-form__media-picker-empty border border-dashed border-line p-8 text-center text-sm text-muted">
|
||||
선택할 미디어가 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
735
components/admin/AdminPostForm.vue
Normal file
735
components/admin/AdminPostForm.vue
Normal file
@@ -0,0 +1,735 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
initialPost: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
submitLabel: {
|
||||
type: String,
|
||||
default: '저장'
|
||||
},
|
||||
saving: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['submit', 'preview'])
|
||||
|
||||
const autosaveStoragePrefix = 'SORI_ADMIN_POST_AUTOSAVE'
|
||||
const slugTouched = ref(Boolean(props.initialPost.slug))
|
||||
const blockEditor = ref(null)
|
||||
const mediaItems = ref([])
|
||||
const isMediaPickerOpen = ref(false)
|
||||
const mediaPickerTarget = ref('featuredImage')
|
||||
const isLoadingMedia = ref(false)
|
||||
const isUploadingFeaturedImage = ref(false)
|
||||
const isUploadingOgImage = ref(false)
|
||||
const autosaveTimer = ref(null)
|
||||
const autosaveNotice = ref(null)
|
||||
const autosaveStatus = ref('')
|
||||
const isRestoringAutosave = ref(false)
|
||||
|
||||
/**
|
||||
* ISO 날짜를 datetime-local 입력값으로 변환
|
||||
* @param {string} value - ISO 날짜 문자열
|
||||
* @returns {string} datetime-local 입력값
|
||||
*/
|
||||
function toDateTimeLocalValue(value) {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const offsetDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000)
|
||||
|
||||
return offsetDate.toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
/**
|
||||
* datetime-local 입력값을 ISO 문자열로 변환
|
||||
* @param {string} value - datetime-local 입력값
|
||||
* @returns {string | null} ISO 날짜 문자열
|
||||
*/
|
||||
function toIsoDateTime(value) {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null
|
||||
}
|
||||
|
||||
return date.toISOString()
|
||||
}
|
||||
|
||||
const form = reactive({
|
||||
title: props.initialPost.title || '',
|
||||
slug: props.initialPost.slug || '',
|
||||
excerpt: props.initialPost.excerpt || '',
|
||||
content: props.initialPost.content || '',
|
||||
featuredImage: props.initialPost.featuredImage || '',
|
||||
seoTitle: props.initialPost.seoTitle || '',
|
||||
seoDescription: props.initialPost.seoDescription || '',
|
||||
canonicalUrl: props.initialPost.canonicalUrl || '',
|
||||
noindex: Boolean(props.initialPost.noindex),
|
||||
ogImage: props.initialPost.ogImage || '',
|
||||
status: props.initialPost.status || 'draft',
|
||||
publishedAt: toDateTimeLocalValue(props.initialPost.publishedAt),
|
||||
tagsText: props.initialPost.tags?.join(', ') || ''
|
||||
})
|
||||
|
||||
const autosaveKey = computed(() => `${autosaveStoragePrefix}:${props.initialPost.id || 'new'}`)
|
||||
|
||||
/**
|
||||
* 문자열을 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 {boolean} 예약 발행 여부
|
||||
*/
|
||||
const isScheduledPost = () => {
|
||||
const publishedAt = toIsoDateTime(form.publishedAt)
|
||||
|
||||
return form.status === 'published' && Boolean(publishedAt) && new Date(publishedAt) > new Date()
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 입력값 생성
|
||||
* @returns {Object} 게시물 입력값
|
||||
*/
|
||||
const createPostPayload = () => {
|
||||
const publishedAt = form.status === 'published'
|
||||
? toIsoDateTime(form.publishedAt) || props.initialPost.publishedAt || new Date().toISOString()
|
||||
: null
|
||||
|
||||
return {
|
||||
title: form.title.trim(),
|
||||
slug: toSlug(form.slug || form.title),
|
||||
excerpt: form.excerpt.trim(),
|
||||
content: form.content,
|
||||
featuredImage: form.featuredImage.trim() || null,
|
||||
seoTitle: form.seoTitle.trim(),
|
||||
seoDescription: form.seoDescription.trim(),
|
||||
canonicalUrl: form.canonicalUrl.trim(),
|
||||
noindex: form.noindex,
|
||||
ogImage: form.ogImage.trim() || null,
|
||||
status: form.status,
|
||||
publishedAt,
|
||||
tags: parseTags(form.tagsText)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 자동 저장 데이터 생성
|
||||
* @returns {Object} 자동 저장 데이터
|
||||
*/
|
||||
const createAutosavePayload = () => ({
|
||||
title: form.title,
|
||||
slug: form.slug,
|
||||
excerpt: form.excerpt,
|
||||
content: form.content,
|
||||
featuredImage: form.featuredImage,
|
||||
seoTitle: form.seoTitle,
|
||||
seoDescription: form.seoDescription,
|
||||
canonicalUrl: form.canonicalUrl,
|
||||
noindex: form.noindex,
|
||||
ogImage: form.ogImage,
|
||||
status: form.status,
|
||||
publishedAt: form.publishedAt,
|
||||
tagsText: form.tagsText
|
||||
})
|
||||
|
||||
/**
|
||||
* 자동 저장 데이터가 비어 있는지 확인
|
||||
* @param {Object} payload - 자동 저장 데이터
|
||||
* @returns {boolean} 비어 있는지 여부
|
||||
*/
|
||||
const isEmptyAutosavePayload = (payload) => ![
|
||||
payload.title,
|
||||
payload.slug,
|
||||
payload.excerpt,
|
||||
payload.content,
|
||||
payload.featuredImage,
|
||||
payload.seoTitle,
|
||||
payload.seoDescription,
|
||||
payload.canonicalUrl,
|
||||
payload.ogImage,
|
||||
payload.tagsText
|
||||
].some((value) => String(value || '').trim())
|
||||
|
||||
/**
|
||||
* 자동 저장 시각 표시 문자열 반환
|
||||
* @param {number} savedAt - 저장 시각
|
||||
* @returns {string} 표시 문자열
|
||||
*/
|
||||
const formatAutosaveTime = (savedAt) => new Intl.DateTimeFormat('ko-KR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
}).format(new Date(savedAt))
|
||||
|
||||
/**
|
||||
* 자동 저장본을 로컬 저장소에 기록
|
||||
* @returns {void}
|
||||
*/
|
||||
const saveAutosave = () => {
|
||||
if (!import.meta.client || isRestoringAutosave.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const payload = createAutosavePayload()
|
||||
|
||||
if (isEmptyAutosavePayload(payload)) {
|
||||
localStorage.removeItem(autosaveKey.value)
|
||||
autosaveStatus.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const savedAt = Date.now()
|
||||
localStorage.setItem(autosaveKey.value, JSON.stringify({
|
||||
savedAt,
|
||||
payload
|
||||
}))
|
||||
autosaveStatus.value = `${formatAutosaveTime(savedAt)} 자동 저장됨`
|
||||
}
|
||||
|
||||
/**
|
||||
* 자동 저장 예약
|
||||
* @returns {void}
|
||||
*/
|
||||
const scheduleAutosave = () => {
|
||||
if (!import.meta.client || isRestoringAutosave.value) {
|
||||
return
|
||||
}
|
||||
|
||||
window.clearTimeout(autosaveTimer.value)
|
||||
autosaveTimer.value = window.setTimeout(saveAutosave, 900)
|
||||
}
|
||||
|
||||
/**
|
||||
* 자동 저장본을 입력 폼에 복원
|
||||
* @returns {void}
|
||||
*/
|
||||
const restoreAutosave = () => {
|
||||
if (!autosaveNotice.value?.payload) {
|
||||
return
|
||||
}
|
||||
|
||||
isRestoringAutosave.value = true
|
||||
Object.assign(form, autosaveNotice.value.payload)
|
||||
slugTouched.value = Boolean(form.slug)
|
||||
autosaveStatus.value = `${formatAutosaveTime(autosaveNotice.value.savedAt)} 자동 저장본 복원됨`
|
||||
autosaveNotice.value = null
|
||||
|
||||
nextTick(() => {
|
||||
isRestoringAutosave.value = false
|
||||
scheduleAutosave()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 자동 저장본을 삭제
|
||||
* @returns {void}
|
||||
*/
|
||||
const discardAutosave = () => {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
localStorage.removeItem(autosaveKey.value)
|
||||
autosaveNotice.value = null
|
||||
autosaveStatus.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 라이브러리 목록 조회
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const fetchMediaItems = async () => {
|
||||
isLoadingMedia.value = true
|
||||
|
||||
try {
|
||||
mediaItems.value = await $fetch('/admin/api/media')
|
||||
} finally {
|
||||
isLoadingMedia.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지 선택 창 열기
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const openMediaPicker = async (target = 'featuredImage') => {
|
||||
mediaPickerTarget.value = target
|
||||
isMediaPickerOpen.value = true
|
||||
await fetchMediaItems()
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지 선택 창 닫기
|
||||
* @returns {void}
|
||||
*/
|
||||
const closeMediaPicker = () => {
|
||||
isMediaPickerOpen.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지 선택
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {void}
|
||||
*/
|
||||
const selectPickedImage = (item) => {
|
||||
form[mediaPickerTarget.value] = item.url
|
||||
closeMediaPicker()
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지 삭제
|
||||
* @returns {void}
|
||||
*/
|
||||
const removeFeaturedImage = () => {
|
||||
form.featuredImage = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* OG 이미지 삭제
|
||||
* @returns {void}
|
||||
*/
|
||||
const removeOgImage = () => {
|
||||
form.ogImage = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지 파일 업로드
|
||||
* @param {Event} event - 파일 입력 이벤트
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const uploadFeaturedImage = async (event) => {
|
||||
const files = event.target.files
|
||||
|
||||
if (!files?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('files', files[0])
|
||||
isUploadingFeaturedImage.value = true
|
||||
|
||||
try {
|
||||
const result = await $fetch('/admin/api/uploads', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
form.featuredImage = result.files?.[0]?.url || ''
|
||||
} finally {
|
||||
event.target.value = ''
|
||||
isUploadingFeaturedImage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OG 이미지 파일 업로드
|
||||
* @param {Event} event - 파일 입력 이벤트
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const uploadOgImage = async (event) => {
|
||||
const files = event.target.files
|
||||
|
||||
if (!files?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('files', files[0])
|
||||
isUploadingOgImage.value = true
|
||||
|
||||
try {
|
||||
const result = await $fetch('/admin/api/uploads', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
form.ogImage = result.files?.[0]?.url || ''
|
||||
} finally {
|
||||
event.target.value = ''
|
||||
isUploadingOgImage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 제목 입력 후 본문 에디터로 이동
|
||||
* @returns {void}
|
||||
*/
|
||||
const focusContentEditor = () => {
|
||||
blockEditor.value?.focusFirstBlock()
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 입력값 제출
|
||||
* @returns {void}
|
||||
*/
|
||||
const submitPost = () => {
|
||||
emit('submit', createPostPayload())
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 미리보기 요청
|
||||
* @returns {void}
|
||||
*/
|
||||
const previewPost = () => {
|
||||
emit('preview', createPostPayload())
|
||||
}
|
||||
|
||||
watch(form, scheduleAutosave, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
const savedRaw = localStorage.getItem(autosaveKey.value)
|
||||
|
||||
if (!savedRaw) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const saved = JSON.parse(savedRaw)
|
||||
|
||||
if (saved?.payload && !isEmptyAutosavePayload(saved.payload)) {
|
||||
autosaveNotice.value = saved
|
||||
autosaveStatus.value = `${formatAutosaveTime(saved.savedAt)} 자동 저장본 있음`
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem(autosaveKey.value)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (import.meta.client) {
|
||||
window.clearTimeout(autosaveTimer.value)
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
clearAutosave: discardAutosave
|
||||
})
|
||||
</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">
|
||||
<input
|
||||
v-model="form.title"
|
||||
class="admin-post-form__title-input border-0 bg-transparent px-0 py-2 text-5xl font-semibold leading-tight text-ink outline-none placeholder:text-soft"
|
||||
type="text"
|
||||
placeholder="제목"
|
||||
required
|
||||
@keydown.enter.prevent="focusContentEditor"
|
||||
>
|
||||
|
||||
<div
|
||||
v-if="autosaveNotice"
|
||||
class="admin-post-form__autosave-notice flex flex-wrap items-center justify-between gap-3 rounded border border-line bg-surface px-4 py-3 text-sm"
|
||||
>
|
||||
<p class="admin-post-form__autosave-message text-muted">
|
||||
{{ formatAutosaveTime(autosaveNotice.savedAt) }}에 저장된 작성 중 내용이 있습니다.
|
||||
</p>
|
||||
<div class="admin-post-form__autosave-actions flex gap-2">
|
||||
<button class="admin-post-form__autosave-restore rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white" type="button" @click="restoreAutosave">
|
||||
복원
|
||||
</button>
|
||||
<button class="admin-post-form__autosave-discard rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold" type="button" @click="discardAutosave">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-post-form__field grid gap-2 text-sm">
|
||||
<AdminBlockEditor ref="blockEditor" v-model="form.content" />
|
||||
</div>
|
||||
</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 v-if="form.status === 'published'" class="admin-post-form__field grid gap-2 text-sm">
|
||||
<span class="admin-post-form__label font-medium">
|
||||
발행 시각
|
||||
</span>
|
||||
<input
|
||||
v-model="form.publishedAt"
|
||||
class="admin-post-form__input rounded border border-line bg-white px-3 py-2"
|
||||
type="datetime-local"
|
||||
>
|
||||
<span class="admin-post-form__hint text-xs text-muted">
|
||||
{{ isScheduledPost() ? '미래 시각이면 예약 발행으로 저장됩니다.' : '비워두면 저장 시점으로 발행됩니다.' }}
|
||||
</span>
|
||||
</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>
|
||||
|
||||
<div class="admin-post-form__seo grid gap-3 rounded border border-line bg-white p-4 text-sm">
|
||||
<div>
|
||||
<h2 class="admin-post-form__section-title text-sm font-semibold text-ink">
|
||||
SEO
|
||||
</h2>
|
||||
<p class="admin-post-form__section-description mt-1 text-xs text-muted">
|
||||
검색 결과와 공유 미리보기에 사용할 기본 메타 정보를 설정합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="admin-post-form__field grid gap-2">
|
||||
<span class="admin-post-form__label font-medium">SEO 제목</span>
|
||||
<input
|
||||
v-model="form.seoTitle"
|
||||
class="admin-post-form__input rounded border border-line bg-white px-3 py-2"
|
||||
type="text"
|
||||
maxlength="80"
|
||||
placeholder="비워두면 글 제목을 사용"
|
||||
>
|
||||
<span class="admin-post-form__hint text-xs text-muted">
|
||||
{{ form.seoTitle.length }}/80
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="admin-post-form__field grid gap-2">
|
||||
<span class="admin-post-form__label font-medium">SEO 설명</span>
|
||||
<textarea
|
||||
v-model="form.seoDescription"
|
||||
class="admin-post-form__textarea min-h-24 rounded border border-line bg-white px-3 py-2"
|
||||
maxlength="180"
|
||||
placeholder="비워두면 요약을 사용"
|
||||
/>
|
||||
<span class="admin-post-form__hint text-xs text-muted">
|
||||
{{ form.seoDescription.length }}/180
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="admin-post-form__field grid gap-2">
|
||||
<span class="admin-post-form__label font-medium">Canonical URL</span>
|
||||
<input
|
||||
v-model="form.canonicalUrl"
|
||||
class="admin-post-form__input rounded border border-line bg-white px-3 py-2"
|
||||
type="url"
|
||||
placeholder="비워두면 기본 글 주소를 사용"
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="admin-post-form__checkbox flex items-start gap-2 text-sm">
|
||||
<input
|
||||
v-model="form.noindex"
|
||||
class="admin-post-form__checkbox-input mt-1"
|
||||
type="checkbox"
|
||||
>
|
||||
<span>
|
||||
<span class="admin-post-form__label block font-medium">검색엔진 노출 제외</span>
|
||||
<span class="admin-post-form__hint mt-1 block text-xs text-muted">공개 글이어도 robots noindex 메타를 추가합니다.</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="admin-post-form__field grid gap-2 text-sm">
|
||||
<span class="admin-post-form__label font-medium">대표 이미지</span>
|
||||
<figure v-if="form.featuredImage" class="admin-post-form__featured overflow-hidden rounded border border-line bg-white">
|
||||
<img class="admin-post-form__featured-image aspect-[4/3] w-full bg-surface object-cover" :src="form.featuredImage" alt="">
|
||||
<figcaption class="admin-post-form__featured-actions grid gap-2 p-3">
|
||||
<p class="admin-post-form__featured-url break-all text-xs text-muted">
|
||||
{{ form.featuredImage }}
|
||||
</p>
|
||||
<div class="admin-post-form__featured-buttons flex flex-wrap gap-2">
|
||||
<button class="admin-post-form__featured-change rounded border border-line px-3 py-1.5 text-xs font-semibold" type="button" @click="openMediaPicker('featuredImage')">
|
||||
변경
|
||||
</button>
|
||||
<label class="admin-post-form__featured-reupload cursor-pointer rounded border border-line px-3 py-1.5 text-xs font-semibold">
|
||||
새 업로드
|
||||
<input class="sr-only" type="file" accept="image/*" @change="uploadFeaturedImage">
|
||||
</label>
|
||||
<button class="admin-post-form__featured-remove rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700" type="button" @click="removeFeaturedImage">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</figcaption>
|
||||
</figure>
|
||||
<div v-else class="admin-post-form__featured-empty grid gap-2 rounded border border-dashed border-line bg-white p-4">
|
||||
<button class="admin-post-form__featured-select rounded border border-line px-3 py-2 text-sm font-semibold" type="button" @click="openMediaPicker">
|
||||
미디어에서 선택
|
||||
</button>
|
||||
<label class="admin-post-form__featured-upload cursor-pointer rounded bg-[#15171a] px-3 py-2 text-center text-sm font-semibold text-white">
|
||||
{{ isUploadingFeaturedImage ? '업로드 중' : '새 이미지 업로드' }}
|
||||
<input class="sr-only" type="file" accept="image/*" @change="uploadFeaturedImage">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-post-form__field grid gap-2 text-sm">
|
||||
<span class="admin-post-form__label font-medium">OG 이미지</span>
|
||||
<figure v-if="form.ogImage" class="admin-post-form__og-image overflow-hidden rounded border border-line bg-white">
|
||||
<img class="admin-post-form__og-preview aspect-[1.91/1] w-full bg-surface object-cover" :src="form.ogImage" alt="">
|
||||
<figcaption class="admin-post-form__og-actions grid gap-2 p-3">
|
||||
<p class="admin-post-form__og-url break-all text-xs text-muted">
|
||||
{{ form.ogImage }}
|
||||
</p>
|
||||
<div class="admin-post-form__og-buttons flex flex-wrap gap-2">
|
||||
<button class="admin-post-form__og-change rounded border border-line px-3 py-1.5 text-xs font-semibold" type="button" @click="openMediaPicker('ogImage')">
|
||||
변경
|
||||
</button>
|
||||
<label class="admin-post-form__og-reupload cursor-pointer rounded border border-line px-3 py-1.5 text-xs font-semibold">
|
||||
새 업로드
|
||||
<input class="sr-only" type="file" accept="image/*" @change="uploadOgImage">
|
||||
</label>
|
||||
<button class="admin-post-form__og-remove rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700" type="button" @click="removeOgImage">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</figcaption>
|
||||
</figure>
|
||||
<div v-else class="admin-post-form__og-empty grid gap-2 rounded border border-dashed border-line bg-white p-4">
|
||||
<button class="admin-post-form__og-select rounded border border-line px-3 py-2 text-sm font-semibold" type="button" @click="openMediaPicker('ogImage')">
|
||||
미디어에서 선택
|
||||
</button>
|
||||
<label class="admin-post-form__og-upload cursor-pointer rounded bg-[#15171a] px-3 py-2 text-center text-sm font-semibold text-white">
|
||||
{{ isUploadingOgImage ? '업로드 중' : '새 이미지 업로드' }}
|
||||
<input class="sr-only" type="file" accept="image/*" @change="uploadOgImage">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="admin-post-form__actions flex justify-end gap-3 border-t border-line pt-5">
|
||||
<p v-if="autosaveStatus" class="admin-post-form__autosave-status mr-auto self-center text-xs text-muted">
|
||||
{{ autosaveStatus }}
|
||||
</p>
|
||||
<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__preview rounded border border-line bg-white px-4 py-2 text-sm font-semibold"
|
||||
type="button"
|
||||
@click="previewPost"
|
||||
>
|
||||
미리보기
|
||||
</button>
|
||||
<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>
|
||||
|
||||
<div
|
||||
v-if="isMediaPickerOpen"
|
||||
class="admin-post-form__media-picker fixed inset-0 z-50 grid place-items-center bg-black/40 px-5 py-8"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@click.self="closeMediaPicker"
|
||||
>
|
||||
<section class="admin-post-form__media-picker-panel max-h-[80vh] w-full max-w-4xl overflow-hidden bg-white text-ink shadow-xl">
|
||||
<div class="admin-post-form__media-picker-header flex items-center justify-between border-b border-line px-5 py-4">
|
||||
<h2 class="admin-post-form__media-picker-title text-lg font-semibold">
|
||||
{{ mediaPickerTarget === 'ogImage' ? 'OG 이미지 선택' : '대표 이미지 선택' }}
|
||||
</h2>
|
||||
<button class="admin-post-form__media-picker-close rounded border border-line px-3 py-1.5 text-sm font-semibold" type="button" @click="closeMediaPicker">
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
<div class="admin-post-form__media-picker-body max-h-[62vh] overflow-y-auto p-5">
|
||||
<p v-if="isLoadingMedia" class="admin-post-form__media-picker-loading text-sm text-muted">
|
||||
미디어를 불러오는 중입니다.
|
||||
</p>
|
||||
<div v-else-if="mediaItems.length" class="admin-post-form__media-picker-grid grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6">
|
||||
<button
|
||||
v-for="item in mediaItems"
|
||||
:key="item.url"
|
||||
class="admin-post-form__media-picker-item overflow-hidden border border-line bg-white text-left"
|
||||
type="button"
|
||||
@click="selectPickedImage(item)"
|
||||
>
|
||||
<img class="admin-post-form__media-picker-image aspect-square w-full bg-surface object-cover" :src="item.url" :alt="item.title">
|
||||
<span class="admin-post-form__media-picker-name block truncate px-2 py-1.5 text-xs font-semibold text-ink">{{ item.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="admin-post-form__media-picker-empty border border-dashed border-line p-8 text-center text-sm text-muted">
|
||||
선택할 미디어가 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</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>
|
||||
307
components/content/ContentMarkdownRenderer.vue
Normal file
307
components/content/ContentMarkdownRenderer.vue
Normal file
@@ -0,0 +1,307 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const activeLightboxImages = ref([])
|
||||
const activeLightboxIndex = ref(0)
|
||||
|
||||
/**
|
||||
* 마크다운 블록을 생성
|
||||
* @param {string} type - 블록 타입
|
||||
* @param {string|Array<string>} text - 블록 텍스트
|
||||
* @param {number|null} level - 제목 레벨
|
||||
* @param {string} id - 블록 ID
|
||||
* @param {Object} options - 추가 블록 옵션
|
||||
* @returns {Object} 블록
|
||||
*/
|
||||
const createBlock = (type = 'paragraph', text = '', level = null, id = '', options = {}) => ({
|
||||
id,
|
||||
type,
|
||||
text,
|
||||
level,
|
||||
url: options.url || '',
|
||||
alt: options.alt || '',
|
||||
title: options.title || '',
|
||||
width: options.width || 'regular',
|
||||
images: options.images || []
|
||||
})
|
||||
|
||||
/**
|
||||
* 이미지 마크다운 행을 이미지 데이터로 변환
|
||||
* @param {string} line - 마크다운 행
|
||||
* @returns {Object|null} 이미지 데이터
|
||||
*/
|
||||
const parseImageLine = (line) => {
|
||||
const match = line.trim().match(/^!\[([^\]]*)\]\(([^)]+)\)(?:\{width=(regular|wide|full)\})?$/)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
alt: match[1],
|
||||
url: match[2],
|
||||
width: match[3] || 'regular'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 닫힘 표식까지의 행 목록을 반환
|
||||
* @param {Array<string>} lines - 전체 마크다운 행
|
||||
* @param {number} startIndex - 본문 시작 인덱스
|
||||
* @returns {{contentLines: Array<string>, nextIndex: number}} 블록 본문과 다음 인덱스
|
||||
*/
|
||||
const collectFencedLines = (lines, startIndex) => {
|
||||
const contentLines = []
|
||||
let index = startIndex
|
||||
|
||||
while (index < lines.length && lines[index].trim() !== ':::') {
|
||||
contentLines.push(lines[index])
|
||||
index += 1
|
||||
}
|
||||
|
||||
return {
|
||||
contentLines,
|
||||
nextIndex: index + 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 마크다운 문자열을 렌더링용 블록 목록으로 변환
|
||||
* @param {string} markdown - 마크다운 문자열
|
||||
* @returns {Array<Object>} 블록 목록
|
||||
*/
|
||||
const parseMarkdownBlocks = (markdown) => {
|
||||
const lines = markdown.split('\n')
|
||||
const blocks = []
|
||||
let index = 0
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index]
|
||||
const trimmedLine = line.trim()
|
||||
|
||||
if (!trimmedLine) {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine === ':::gallery') {
|
||||
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||
const images = []
|
||||
|
||||
contentLines.forEach((contentLine) => {
|
||||
const image = parseImageLine(contentLine)
|
||||
if (image) {
|
||||
images.push(image)
|
||||
}
|
||||
})
|
||||
|
||||
blocks.push(createBlock('gallery', '', null, `block-${blocks.length}`, { images }))
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine === ':::callout') {
|
||||
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||
blocks.push(createBlock('callout', contentLines.join('\n'), null, `block-${blocks.length}`))
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith(':::toggle')) {
|
||||
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||
const title = trimmedLine.replace(/^:::toggle\s*/, '').trim()
|
||||
blocks.push(createBlock('toggle', contentLines.join('\n'), null, `block-${blocks.length}`, { title }))
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine === ':::embed') {
|
||||
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||
blocks.push(createBlock('embed', '', null, `block-${blocks.length}`, { url: contentLines.join('\n').trim() }))
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
const image = parseImageLine(trimmedLine)
|
||||
|
||||
if (image) {
|
||||
blocks.push(createBlock('image', '', null, `block-${blocks.length}`, image))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith('```')) {
|
||||
const codeLines = []
|
||||
index += 1
|
||||
|
||||
while (index < lines.length && !lines[index].trim().startsWith('```')) {
|
||||
codeLines.push(lines[index])
|
||||
index += 1
|
||||
}
|
||||
|
||||
blocks.push(createBlock('code', codeLines.join('\n'), null, `block-${blocks.length}`))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine === '---') {
|
||||
blocks.push(createBlock('divider', '', null, `block-${blocks.length}`))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const headingMatch = trimmedLine.match(/^(#{1,3})\s+(.+)$/)
|
||||
|
||||
if (headingMatch) {
|
||||
blocks.push(createBlock('heading', headingMatch[2], headingMatch[1].length, `block-${blocks.length}`))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith('> ')) {
|
||||
blocks.push(createBlock('quote', trimmedLine.replace(/^>\s?/, ''), null, `block-${blocks.length}`))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^- /.test(trimmedLine)) {
|
||||
const items = []
|
||||
|
||||
while (index < lines.length && /^- /.test(lines[index].trim())) {
|
||||
items.push(lines[index].trim().replace(/^- /, ''))
|
||||
index += 1
|
||||
}
|
||||
|
||||
blocks.push(createBlock('list', items, null, `block-${blocks.length}`))
|
||||
continue
|
||||
}
|
||||
|
||||
blocks.push(createBlock('paragraph', trimmedLine, null, `block-${blocks.length}`))
|
||||
index += 1
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
const blocks = computed(() => parseMarkdownBlocks(props.content))
|
||||
const activeLightboxImage = computed(() => activeLightboxImages.value[activeLightboxIndex.value])
|
||||
|
||||
/**
|
||||
* 라이트박스를 연다
|
||||
* @param {Array<Object>} images - 이미지 목록
|
||||
* @param {number} index - 시작 인덱스
|
||||
* @returns {void}
|
||||
*/
|
||||
const openLightbox = (images, index) => {
|
||||
activeLightboxImages.value = images
|
||||
activeLightboxIndex.value = index
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이트박스를 닫는다
|
||||
* @returns {void}
|
||||
*/
|
||||
const closeLightbox = () => {
|
||||
activeLightboxImages.value = []
|
||||
activeLightboxIndex.value = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이트박스 이전 이미지로 이동
|
||||
* @returns {void}
|
||||
*/
|
||||
const showPreviousImage = () => {
|
||||
activeLightboxIndex.value = activeLightboxIndex.value === 0
|
||||
? activeLightboxImages.value.length - 1
|
||||
: activeLightboxIndex.value - 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이트박스 다음 이미지로 이동
|
||||
* @returns {void}
|
||||
*/
|
||||
const showNextImage = () => {
|
||||
activeLightboxIndex.value = (activeLightboxIndex.value + 1) % activeLightboxImages.value.length
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="content-markdown-renderer">
|
||||
<template v-for="block in blocks" :key="block.id">
|
||||
<ProseHeading v-if="block.type === 'heading'" :level="block.level">
|
||||
{{ block.text }}
|
||||
</ProseHeading>
|
||||
<ProseBlockquote v-else-if="block.type === 'quote'">
|
||||
{{ block.text }}
|
||||
</ProseBlockquote>
|
||||
<ProseList v-else-if="block.type === 'list'">
|
||||
<li v-for="(item, itemIndex) in block.text" :key="`${block.id}-${itemIndex}`">
|
||||
{{ item }}
|
||||
</li>
|
||||
</ProseList>
|
||||
<ProseImage v-else-if="block.type === 'image'" :src="block.url" :alt="block.alt" :variant="block.width">
|
||||
{{ block.alt }}
|
||||
</ProseImage>
|
||||
<ProseCallout v-else-if="block.type === 'callout'">
|
||||
{{ block.text }}
|
||||
</ProseCallout>
|
||||
<ProseToggle v-else-if="block.type === 'toggle'" :title="block.title || '더 보기'">
|
||||
{{ block.text }}
|
||||
</ProseToggle>
|
||||
<ProseEmbed v-else-if="block.type === 'embed'" :url="block.url" />
|
||||
<div v-else-if="block.type === 'gallery'" class="content-markdown-renderer__gallery my-8 grid grid-cols-2 gap-2 md:grid-cols-3">
|
||||
<button
|
||||
v-for="(image, imageIndex) in block.images"
|
||||
:key="`${block.id}-${image.url}`"
|
||||
class="content-markdown-renderer__gallery-button overflow-hidden rounded bg-surface"
|
||||
type="button"
|
||||
@click="openLightbox(block.images, imageIndex)"
|
||||
>
|
||||
<img class="content-markdown-renderer__gallery-image aspect-[4/3] w-full object-cover transition-transform hover:scale-[1.02]" :src="image.url" :alt="image.alt">
|
||||
</button>
|
||||
</div>
|
||||
<pre
|
||||
v-else-if="block.type === 'code'"
|
||||
class="content-markdown-renderer__code my-6 overflow-x-auto rounded bg-[#15171a] px-4 py-3 text-sm leading-6 text-white"
|
||||
><code>{{ block.text }}</code></pre>
|
||||
<hr v-else-if="block.type === 'divider'" class="content-markdown-renderer__divider my-10 border-line">
|
||||
<p v-else class="content-markdown-renderer__paragraph my-5 leading-8">
|
||||
{{ block.text }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="activeLightboxImage"
|
||||
class="content-markdown-renderer__lightbox fixed inset-0 z-50 grid place-items-center bg-black/90 px-5 py-8"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@click.self="closeLightbox"
|
||||
>
|
||||
<button class="content-markdown-renderer__lightbox-close absolute right-5 top-5 rounded bg-white px-3 py-2 text-sm font-semibold text-ink" type="button" @click="closeLightbox">
|
||||
닫기
|
||||
</button>
|
||||
<button
|
||||
v-if="activeLightboxImages.length > 1"
|
||||
class="content-markdown-renderer__lightbox-prev absolute left-5 top-1/2 rounded bg-white px-3 py-2 text-sm font-semibold text-ink"
|
||||
type="button"
|
||||
@click="showPreviousImage"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
<img class="content-markdown-renderer__lightbox-image max-h-[84vh] max-w-[92vw] object-contain" :src="activeLightboxImage.url" :alt="activeLightboxImage.alt">
|
||||
<button
|
||||
v-if="activeLightboxImages.length > 1"
|
||||
class="content-markdown-renderer__lightbox-next absolute right-5 top-1/2 rounded bg-white px-3 py-2 text-sm font-semibold text-ink"
|
||||
type="button"
|
||||
@click="showNextImage"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
5
components/content/ContentRenderer.vue
Normal file
5
components/content/ContentRenderer.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<article class="content-renderer post-prose">
|
||||
<slot />
|
||||
</article>
|
||||
</template>
|
||||
5
components/content/ProseAudio.vue
Normal file
5
components/content/ProseAudio.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="prose-audio my-8 border border-line bg-surface p-5">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
5
components/content/ProseBlockquote.vue
Normal file
5
components/content/ProseBlockquote.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<blockquote class="prose-blockquote my-8 border-l-4 border-ink bg-surface px-5 py-4 text-xl font-medium leading-8">
|
||||
<slot />
|
||||
</blockquote>
|
||||
</template>
|
||||
20
components/content/ProseButton.vue
Normal file
20
components/content/ProseButton.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
href: {
|
||||
type: String,
|
||||
default: '#'
|
||||
},
|
||||
align: {
|
||||
type: String,
|
||||
default: 'left'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p class="prose-button my-8" :class="{ 'text-center': align === 'center' }">
|
||||
<NuxtLink class="prose-button__link inline-flex rounded bg-ink px-5 py-3 text-sm font-semibold text-white hover:bg-muted" :to="href">
|
||||
<slot />
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</template>
|
||||
5
components/content/ProseCallout.vue
Normal file
5
components/content/ProseCallout.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<aside class="prose-callout my-8 border border-line bg-surface p-5">
|
||||
<slot />
|
||||
</aside>
|
||||
</template>
|
||||
57
components/content/ProseEmbed.vue
Normal file
57
components/content/ProseEmbed.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
url: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* YouTube 영상 ID를 추출
|
||||
* @param {string} value - 임베드 URL
|
||||
* @returns {string} YouTube 영상 ID
|
||||
*/
|
||||
const getYouTubeId = (value) => {
|
||||
try {
|
||||
const parsedUrl = new URL(value)
|
||||
|
||||
if (parsedUrl.hostname.includes('youtu.be')) {
|
||||
return parsedUrl.pathname.replace('/', '')
|
||||
}
|
||||
|
||||
if (parsedUrl.hostname.includes('youtube.com')) {
|
||||
return parsedUrl.searchParams.get('v') || parsedUrl.pathname.split('/').pop() || ''
|
||||
}
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const youtubeId = computed(() => getYouTubeId(props.url))
|
||||
const youtubeEmbedUrl = computed(() => youtubeId.value ? `https://www.youtube.com/embed/${youtubeId.value}` : '')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prose-embed my-8 overflow-hidden border border-line bg-paper">
|
||||
<iframe
|
||||
v-if="youtubeEmbedUrl"
|
||||
class="prose-embed__frame aspect-video w-full"
|
||||
:src="youtubeEmbedUrl"
|
||||
title="Embedded video"
|
||||
loading="lazy"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen
|
||||
/>
|
||||
<a
|
||||
v-else
|
||||
class="prose-embed__link block p-5 text-sm font-semibold text-ink hover:opacity-70"
|
||||
:href="url"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{{ url }}
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
5
components/content/ProseFile.vue
Normal file
5
components/content/ProseFile.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="prose-file my-8 border border-line bg-paper p-5">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
14
components/content/ProseHeaderCard.vue
Normal file
14
components/content/ProseHeaderCard.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'simple'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="prose-header-card my-8 bg-ink p-8 text-white" :class="`prose-header-card--${variant}`">
|
||||
<slot />
|
||||
</header>
|
||||
</template>
|
||||
27
components/content/ProseHeading.vue
Normal file
27
components/content/ProseHeading.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
level: {
|
||||
type: Number,
|
||||
default: 2
|
||||
}
|
||||
})
|
||||
|
||||
const tagName = computed(() => `h${Math.min(Math.max(props.level, 1), 6)}`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="tagName"
|
||||
class="prose-heading mt-10 font-semibold leading-tight tracking-normal first:mt-0"
|
||||
:class="{
|
||||
'text-5xl': level === 1,
|
||||
'text-4xl': level === 2,
|
||||
'text-3xl': level === 3,
|
||||
'text-2xl': level === 4,
|
||||
'text-xl': level === 5,
|
||||
'text-lg': level === 6
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
31
components/content/ProseImage.vue
Normal file
31
components/content/ProseImage.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
alt: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'regular'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<figure
|
||||
class="prose-image my-8"
|
||||
:class="{
|
||||
'prose-image--wide lg:-mx-10': variant === 'wide',
|
||||
'prose-image--full lg:-mx-20': variant === 'full'
|
||||
}"
|
||||
>
|
||||
<img class="prose-image__media w-full bg-surface object-cover" :src="src" :alt="alt">
|
||||
<figcaption v-if="$slots.default" class="prose-image__caption mt-3 text-center text-sm text-muted">
|
||||
<slot />
|
||||
</figcaption>
|
||||
</figure>
|
||||
</template>
|
||||
18
components/content/ProseList.vue
Normal file
18
components/content/ProseList.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
ordered: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="ordered ? 'ol' : 'ul'"
|
||||
class="prose-list my-6 space-y-2 pl-6"
|
||||
:class="ordered ? 'list-decimal' : 'list-disc'"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
5
components/content/ProseProduct.vue
Normal file
5
components/content/ProseProduct.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="prose-product my-8 border border-line bg-surface p-5">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
19
components/content/ProseToggle.vue
Normal file
19
components/content/ProseToggle.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<details class="prose-toggle my-6 border border-line bg-paper p-5">
|
||||
<summary class="prose-toggle__summary cursor-pointer font-semibold">
|
||||
{{ title }}
|
||||
</summary>
|
||||
<div class="prose-toggle__body mt-4 text-muted">
|
||||
<slot />
|
||||
</div>
|
||||
</details>
|
||||
</template>
|
||||
5
components/content/ProseVideo.vue
Normal file
5
components/content/ProseVideo.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="prose-video my-8 aspect-video bg-ink text-white">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
79
components/site/LeftSidebar.vue
Normal file
79
components/site/LeftSidebar.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<script setup>
|
||||
const { data: tags } = await useFetch('/api/tags', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
const { data: navigation } = await useFetch('/api/navigation', {
|
||||
default: () => ({
|
||||
primary: [],
|
||||
footer: []
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="left-sidebar site-sidebar hidden w-[287px] lg:flex lg:flex-col">
|
||||
<div class="left-sidebar__scroll min-h-0 flex-1 overflow-y-auto">
|
||||
<div class="left-sidebar__block site-sidebar-section py-3 pl-0 pr-3">
|
||||
<nav class="left-sidebar__nav grid gap-1 text-[15px]">
|
||||
<NuxtLink
|
||||
v-for="item in navigation.primary"
|
||||
:key="item.id"
|
||||
class="left-sidebar__nav-link py-2 pl-3"
|
||||
:to="item.url"
|
||||
>
|
||||
{{ item.label }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="left-sidebar__block site-sidebar-section py-5 pl-0 pr-3">
|
||||
<div class="left-sidebar__section-title flex items-center justify-between text-xs font-semibold uppercase site-muted">
|
||||
<span>Categories</span>
|
||||
<span>⌃</span>
|
||||
</div>
|
||||
<div class="left-sidebar__category-grid mt-4 grid grid-cols-2 gap-x-6 gap-y-4 text-sm">
|
||||
<NuxtLink
|
||||
v-for="tag in tags"
|
||||
:key="tag.id"
|
||||
class="left-sidebar__category flex items-center gap-3"
|
||||
:to="`/tag/${tag.slug}`"
|
||||
>
|
||||
<span class="left-sidebar__category-color h-4 w-1 rounded-full" :style="{ backgroundColor: tag.color }" />
|
||||
<span class="left-sidebar__category-name">{{ tag.name }}</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="left-sidebar__block site-sidebar-section py-5 pl-0 pr-3">
|
||||
<div class="left-sidebar__section-title flex items-center justify-between text-xs font-semibold uppercase site-muted">
|
||||
<span>Authors</span>
|
||||
<span>⌃</span>
|
||||
</div>
|
||||
<div class="left-sidebar__authors mt-4 grid gap-4 text-sm">
|
||||
<div class="left-sidebar__author flex items-center gap-3">
|
||||
<span class="h-8 w-8 rounded-full bg-[#e7c49d]" />
|
||||
<span><strong class="block">sori</strong><span class="site-soft">Editor</span></span>
|
||||
</div>
|
||||
<div class="left-sidebar__author flex items-center gap-3">
|
||||
<span class="h-8 w-8 rounded-full bg-[#98b7d5]" />
|
||||
<span><strong class="block">zenn</strong><span class="site-soft">Writer</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="left-sidebar__footer flex items-center justify-between px-1 py-4 text-xs">
|
||||
<nav class="left-sidebar__footer-nav flex gap-4">
|
||||
<NuxtLink
|
||||
v-for="item in navigation.footer"
|
||||
:key="item.id"
|
||||
:to="item.url"
|
||||
>
|
||||
{{ item.label }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
<span class="left-sidebar__theme-dot">☾</span>
|
||||
</footer>
|
||||
</aside>
|
||||
</template>
|
||||
5
components/site/MainColumn.vue
Normal file
5
components/site/MainColumn.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="main-column w-full lg:w-[720px]">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
36
components/site/PostCard.vue
Normal file
36
components/site/PostCard.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
post: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="post-card site-section">
|
||||
<div class="post-card__body site-section-body flex gap-4">
|
||||
<img
|
||||
v-if="post.featuredImage"
|
||||
class="post-card__thumb h-20 w-36 shrink-0 rounded-lg bg-surface object-cover"
|
||||
:src="post.featuredImage"
|
||||
:alt="post.title"
|
||||
loading="lazy"
|
||||
>
|
||||
<div v-else class="post-card__thumb h-20 w-36 shrink-0 rounded-lg bg-[linear-gradient(135deg,#06333a,#f4a261)]" />
|
||||
<div class="post-card__content min-w-0">
|
||||
<h2 class="post-card__title text-base font-semibold leading-tight">
|
||||
<NuxtLink class="post-card__title-link hover:opacity-70" :to="post.to">
|
||||
{{ post.title }}
|
||||
</NuxtLink>
|
||||
</h2>
|
||||
<p class="post-card__excerpt mt-2 text-sm leading-6 site-muted">
|
||||
{{ post.excerpt }}
|
||||
</p>
|
||||
<p class="post-card__meta mt-2 text-xs site-muted">
|
||||
{{ post.publishedAt }} / {{ post.tag }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
84
components/site/RightSidebar.vue
Normal file
84
components/site/RightSidebar.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<script setup>
|
||||
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
default: () => ({
|
||||
title: 'sori.studio',
|
||||
description: 'sori.studio 개인 블로그',
|
||||
logoText: '井',
|
||||
copyrightText: '©2026 sori.studio'
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="right-sidebar site-sidebar hidden w-[287px] lg:flex lg:flex-col">
|
||||
<div class="right-sidebar__scroll min-h-0 flex-1 overflow-y-auto">
|
||||
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
|
||||
<div class="right-sidebar__profile flex items-center gap-3">
|
||||
<div class="right-sidebar__logo grid h-12 w-12 place-items-center rounded-2xl bg-[var(--site-invert)] text-2xl font-bold text-[var(--site-invert-text)]">
|
||||
{{ siteSettings.logoText }}
|
||||
</div>
|
||||
<div>
|
||||
<p class="right-sidebar__title font-semibold">
|
||||
{{ siteSettings.title }}
|
||||
</p>
|
||||
<p class="right-sidebar__description text-sm site-muted">
|
||||
{{ siteSettings.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<form class="right-sidebar__subscribe mt-4 flex gap-2">
|
||||
<input class="right-sidebar__input min-w-0 flex-1 rounded-lg px-3 py-2 text-sm site-input" placeholder="Your email">
|
||||
<button class="right-sidebar__button rounded-lg px-4 py-2 text-sm font-semibold site-button" type="button">
|
||||
Subscribe
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
|
||||
<div class="right-sidebar__row flex items-center justify-between">
|
||||
<p class="right-sidebar__eyebrow text-xs font-semibold uppercase site-muted">
|
||||
Follow
|
||||
</p>
|
||||
<div class="right-sidebar__social flex gap-3 text-sm">
|
||||
<span>f</span>
|
||||
<span>𝕏</span>
|
||||
<span>rss</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
|
||||
<div class="right-sidebar__row flex items-center justify-between">
|
||||
<p class="right-sidebar__eyebrow text-xs font-semibold uppercase site-muted">
|
||||
Recommended
|
||||
</p>
|
||||
<span>↗</span>
|
||||
</div>
|
||||
<div class="right-sidebar__links mt-4 grid gap-3 text-sm">
|
||||
<NuxtLink class="right-sidebar__link font-semibold" to="/post/hello-sori-studio">
|
||||
sori.studio 첫 글과 방향
|
||||
</NuxtLink>
|
||||
<NuxtLink class="right-sidebar__link font-semibold" to="/pages/projects">
|
||||
Projects and services
|
||||
</NuxtLink>
|
||||
<NuxtLink class="right-sidebar__link font-semibold" to="/pages/links">
|
||||
Links and portal
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
|
||||
<p class="right-sidebar__about text-sm leading-6 site-muted">
|
||||
{{ siteSettings.description }}
|
||||
</p>
|
||||
<NuxtLink class="right-sidebar__about-button mt-4 inline-flex rounded-lg px-4 py-2 text-sm font-semibold site-accent-button" to="/pages/about">
|
||||
About {{ siteSettings.title }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="right-sidebar__footer py-4 pl-5 pr-0 text-xs site-muted">
|
||||
{{ siteSettings.copyrightText }}
|
||||
</footer>
|
||||
</aside>
|
||||
</template>
|
||||
62
components/site/SiteHeader.vue
Normal file
62
components/site/SiteHeader.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup>
|
||||
const { menuOpen, toggleMenu } = useMenuState()
|
||||
|
||||
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
default: () => ({
|
||||
title: 'sori.studio'
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="site-header sticky top-0 z-20 backdrop-blur">
|
||||
<div class="site-header__inner mx-auto flex h-full max-w-[1294px] items-center justify-between px-4 lg:px-0">
|
||||
<NuxtLink class="site-header__brand flex items-center gap-2 text-[18px] font-semibold tracking-normal" to="/">
|
||||
<button
|
||||
class="site-header__menu-toggle group flex h-7 w-7 items-center justify-center rounded-full transition-transform"
|
||||
type="button"
|
||||
data-menu-toggle
|
||||
aria-label="Menu toggle"
|
||||
aria-haspopup="true"
|
||||
aria-controls="menu"
|
||||
:aria-expanded="menuOpen.toString()"
|
||||
@click.prevent="toggleMenu"
|
||||
>
|
||||
<span v-if="menuOpen" class="site-header__menu-icon pointer-events-none">
|
||||
<svg class="block h-6 w-6 group-hover:hidden" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M6 21a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6Zm12-16h-8v14h8a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1Z" />
|
||||
</svg>
|
||||
<svg class="hidden h-6 w-6 group-hover:block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M18 3a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h12Zm0 2h-9v14h9a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1Zm-2.3 4.3a1 1 0 0 1 0 1.4L14.4 12l1.3 1.3a1 1 0 0 1-1.4 1.4l-2-2a1 1 0 0 1 0-1.4l2-2a1 1 0 0 1 1.4 0Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span v-else class="site-header__menu-icon pointer-events-none">
|
||||
<svg class="block h-6 w-6 group-hover:hidden" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4 6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6Z" />
|
||||
<path d="M9 4v16" />
|
||||
</svg>
|
||||
<svg class="hidden h-6 w-6 group-hover:block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4 6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6Z" />
|
||||
<path d="M9 4v16" />
|
||||
<path d="m14 10 2 2-2 2" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
{{ siteSettings.title }}
|
||||
</NuxtLink>
|
||||
<div class="site-header__search hidden h-9 w-[470px] items-center rounded-lg px-3 text-sm md:flex site-input">
|
||||
<span class="site-header__search-icon mr-2 text-lg leading-none">⌕</span>
|
||||
<span class="site-header__search-text site-soft">Search</span>
|
||||
<span class="site-header__search-key ml-auto rounded-md px-2 text-xs site-soft site-input">/</span>
|
||||
</div>
|
||||
<nav class="site-header__nav flex items-center gap-3 text-sm">
|
||||
<NuxtLink class="site-header__buy site-accent-button rounded-lg px-4 py-2 font-semibold" to="/pages/about">
|
||||
Subscribe
|
||||
</NuxtLink>
|
||||
<NuxtLink class="site-header__nav-link hover:text-ink" to="/pages/about">
|
||||
Account
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
28
components/site/TagHeader.vue
Normal file
28
components/site/TagHeader.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="tag-header site-section">
|
||||
<div class="tag-header__inner site-section-header">
|
||||
<p class="tag-header__eyebrow text-xs font-semibold uppercase text-muted">
|
||||
Tag
|
||||
</p>
|
||||
<h1 class="tag-header__title mt-3 text-4xl font-semibold leading-tight">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p v-if="description" class="tag-header__description mt-3 text-sm leading-6 text-muted">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
31
composables/useMenuState.js
Normal file
31
composables/useMenuState.js
Normal file
@@ -0,0 +1,31 @@
|
||||
const menuStorageKey = 'MENU_STATE'
|
||||
|
||||
/**
|
||||
* 좌측 메뉴 열림 상태 관리
|
||||
* @returns {{menuOpen: import('vue').Ref<boolean>, toggleMenu: Function}} 메뉴 상태와 토글 함수
|
||||
*/
|
||||
export const useMenuState = () => {
|
||||
const menuOpen = useState('site-menu-open', () => true)
|
||||
|
||||
onMounted(() => {
|
||||
const savedState = localStorage.getItem(menuStorageKey)
|
||||
|
||||
if (savedState) {
|
||||
menuOpen.value = savedState === 'open'
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 좌측 메뉴 열림 상태 토글
|
||||
* @returns {void}
|
||||
*/
|
||||
const toggleMenu = () => {
|
||||
menuOpen.value = !menuOpen.value
|
||||
localStorage.setItem(menuStorageKey, menuOpen.value ? 'open' : 'closed')
|
||||
}
|
||||
|
||||
return {
|
||||
menuOpen,
|
||||
toggleMenu
|
||||
}
|
||||
}
|
||||
58
db/migrations/001_initial_schema.sql
Normal file
58
db/migrations/001_initial_schema.sql
Normal file
@@ -0,0 +1,58 @@
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
excerpt TEXT NOT NULL DEFAULT '',
|
||||
featured_image TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
published_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT posts_status_check CHECK (status IN ('published', 'draft', 'private'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS posts_status_published_at_idx
|
||||
ON posts (status, published_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
featured_image TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
color TEXT NOT NULL DEFAULT '#15171a',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE tags
|
||||
ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE tags
|
||||
ADD COLUMN IF NOT EXISTS color TEXT NOT NULL DEFAULT '#15171a';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS tags_sort_order_name_idx
|
||||
ON tags (sort_order ASC, name ASC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS post_tags (
|
||||
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (post_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS post_tags_tag_id_idx
|
||||
ON post_tags (tag_id);
|
||||
65
db/migrations/002_seed_development.sql
Normal file
65
db/migrations/002_seed_development.sql
Normal file
@@ -0,0 +1,65 @@
|
||||
INSERT INTO tags (id, name, slug, description, sort_order, color)
|
||||
VALUES
|
||||
('44444444-4444-4444-8444-444444444444', 'NOTE', 'note', '생각과 기록을 모아두는 태그입니다.', 10, '#f97316'),
|
||||
('55555555-5555-4555-8555-555555555555', 'DEV', 'dev', '개발과 제작 과정을 기록하는 태그입니다.', 20, '#06b6d4')
|
||||
ON CONFLICT (slug) DO NOTHING;
|
||||
|
||||
INSERT INTO posts (
|
||||
id,
|
||||
title,
|
||||
slug,
|
||||
content,
|
||||
excerpt,
|
||||
status,
|
||||
published_at,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'11111111-1111-4111-8111-111111111111',
|
||||
'sori.studio를 직접 만들기 시작하며',
|
||||
'hello-sori-studio',
|
||||
'개인 블로그와 포털 역할을 한 공간에 담기 위한 첫 글입니다.',
|
||||
'블로그와 포털의 경계에 있는 개인 공간을 직접 구축하기 위한 첫 기록입니다.',
|
||||
'published',
|
||||
'2026-04-29T00:00:00.000Z',
|
||||
'2026-04-29T00:00:00.000Z',
|
||||
'2026-04-29T00:00:00.000Z'
|
||||
),
|
||||
(
|
||||
'22222222-2222-4222-8222-222222222222',
|
||||
'글쓰기 도구는 왜 직접 만들게 되는가',
|
||||
'custom-writing-tool',
|
||||
'기존 도구를 거치며 남은 취향의 빈칸을 직접 채우는 과정입니다.',
|
||||
'네이버 블로그, 티스토리, 워드프레스, Ghost를 거쳐 남은 취향의 빈칸을 정리합니다.',
|
||||
'published',
|
||||
'2026-04-29T00:00:00.000Z',
|
||||
'2026-04-29T00:00:00.000Z',
|
||||
'2026-04-29T00:00:00.000Z'
|
||||
)
|
||||
ON CONFLICT (slug) DO NOTHING;
|
||||
|
||||
INSERT INTO post_tags (post_id, tag_id)
|
||||
VALUES
|
||||
('11111111-1111-4111-8111-111111111111', '44444444-4444-4444-8444-444444444444'),
|
||||
('22222222-2222-4222-8222-222222222222', '55555555-5555-4555-8555-555555555555')
|
||||
ON CONFLICT (post_id, tag_id) DO NOTHING;
|
||||
|
||||
INSERT INTO pages (
|
||||
id,
|
||||
title,
|
||||
slug,
|
||||
content,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
'33333333-3333-4333-8333-333333333333',
|
||||
'About',
|
||||
'about',
|
||||
'sori.studio 소개 페이지입니다.',
|
||||
'2026-04-29T00:00:00.000Z',
|
||||
'2026-04-29T00:00:00.000Z'
|
||||
)
|
||||
ON CONFLICT (slug) DO NOTHING;
|
||||
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);
|
||||
28
db/migrations/004_add_site_settings.sql
Normal file
28
db/migrations/004_add_site_settings.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
CREATE TABLE IF NOT EXISTS site_settings (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||
title TEXT NOT NULL DEFAULT 'sori.studio',
|
||||
description TEXT NOT NULL DEFAULT 'sori.studio 개인 블로그',
|
||||
site_url TEXT NOT NULL DEFAULT 'https://sori.studio',
|
||||
logo_text TEXT NOT NULL DEFAULT '井',
|
||||
copyright_text TEXT NOT NULL DEFAULT '©2026 sori.studio',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT site_settings_singleton_check CHECK (id = 1)
|
||||
);
|
||||
|
||||
INSERT INTO site_settings (
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
site_url,
|
||||
logo_text,
|
||||
copyright_text
|
||||
)
|
||||
VALUES (
|
||||
1,
|
||||
'sori.studio',
|
||||
'sori.studio 개인 블로그',
|
||||
'https://sori.studio',
|
||||
'井',
|
||||
'©2026 sori.studio'
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
29
db/migrations/005_add_navigation_items.sql
Normal file
29
db/migrations/005_add_navigation_items.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
CREATE TABLE IF NOT EXISTS navigation_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
label TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
location TEXT NOT NULL DEFAULT 'primary',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
is_visible BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (location, label, url),
|
||||
CONSTRAINT navigation_items_location_check CHECK (location IN ('primary', 'footer'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS navigation_items_location_sort_order_idx
|
||||
ON navigation_items (location, sort_order ASC, label ASC);
|
||||
|
||||
INSERT INTO navigation_items (label, url, location, sort_order, is_visible)
|
||||
VALUES
|
||||
('Home pages', '/', 'primary', 10, true),
|
||||
('Tags', '/tags', 'primary', 20, true),
|
||||
('Authors', '/pages/about', 'primary', 30, true),
|
||||
('Style', '/post/hello-sori-studio', 'primary', 40, true),
|
||||
('Post types', '/post/custom-writing-tool', 'primary', 50, true),
|
||||
('Members', '/pages/contact', 'primary', 60, true),
|
||||
('Landing pages', '/pages/projects', 'primary', 70, true),
|
||||
('Portal', '/pages/links', 'footer', 10, true),
|
||||
('Docs', '/pages/about', 'footer', 20, true),
|
||||
('Projects', '/pages/projects', 'footer', 30, true)
|
||||
ON CONFLICT DO NOTHING;
|
||||
9
db/migrations/006_add_media_metadata.sql
Normal file
9
db/migrations/006_add_media_metadata.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS media_metadata (
|
||||
url TEXT PRIMARY KEY,
|
||||
category TEXT NOT NULL DEFAULT '미분류',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS media_metadata_category_idx
|
||||
ON media_metadata (category ASC);
|
||||
9
db/migrations/007_add_media_folders.sql
Normal file
9
db/migrations/007_add_media_folders.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS media_folders (
|
||||
path TEXT PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
INSERT INTO media_folders (path)
|
||||
VALUES ('미분류')
|
||||
ON CONFLICT (path) DO NOTHING;
|
||||
11
db/migrations/008_add_post_seo_fields.sql
Normal file
11
db/migrations/008_add_post_seo_fields.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE posts
|
||||
ADD COLUMN IF NOT EXISTS seo_title TEXT NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE posts
|
||||
ADD COLUMN IF NOT EXISTS seo_description TEXT NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE posts
|
||||
ADD COLUMN IF NOT EXISTS canonical_url TEXT NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE posts
|
||||
ADD COLUMN IF NOT EXISTS noindex BOOLEAN NOT NULL DEFAULT false;
|
||||
2
db/migrations/009_add_post_og_image.sql
Normal file
2
db/migrations/009_add_post_og_image.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE posts
|
||||
ADD COLUMN IF NOT EXISTS og_image TEXT;
|
||||
34
docker-compose.yml
Normal file
34
docker-compose.yml
Normal file
@@ -0,0 +1,34 @@
|
||||
services:
|
||||
sori-studio:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: sori-studio
|
||||
env_file:
|
||||
- ${ENV_FILE:-.env.production}
|
||||
ports:
|
||||
- "${APP_PORT:-43118}:3000"
|
||||
volumes:
|
||||
- ./public/uploads:/app/public/uploads
|
||||
depends_on:
|
||||
- sori-studio-db
|
||||
restart: unless-stopped
|
||||
|
||||
sori-studio-db:
|
||||
image: postgres:16-alpine
|
||||
container_name: sori-studio-db
|
||||
env_file:
|
||||
- ${ENV_FILE:-.env.production}
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
ports:
|
||||
- "${DB_PORT:-43119}:5432"
|
||||
volumes:
|
||||
- sori-studio-postgres:/var/lib/postgresql/data
|
||||
- ./db/migrations:/docker-entrypoint-initdb.d:ro
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
sori-studio-postgres:
|
||||
@@ -1,5 +1,39 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v0.0.6
|
||||
|
||||
- `.env.example`을 실제 비밀값이 없는 공유 템플릿으로 정리.
|
||||
- 로컬 개발 전용 `.env.development`를 생성하고 개발 DB/관리자 비밀번호를 랜덤 값으로 분리.
|
||||
- 개발/운영 환경 변수 파일 관리 기준을 문서화.
|
||||
- 패키지 버전을 0.0.6으로 갱신.
|
||||
|
||||
## v0.0.5
|
||||
|
||||
- PostgreSQL 초기 스키마와 개발용 시드 데이터를 추가.
|
||||
- Nuxt 서버 API에 DB 저장소 계층을 추가.
|
||||
- DB 연결이 없을 때는 샘플 데이터로 동작하도록 fallback 구조를 추가.
|
||||
- Docker Compose에 PostgreSQL 서비스를 추가.
|
||||
|
||||
## v0.0.4
|
||||
|
||||
- 헤더 좌측 아이콘을 사이드바 메뉴 토글 버튼으로 수정.
|
||||
- 좌측 사이드바 열림 상태를 저장하고 복원하는 기능 추가.
|
||||
- Nuxt/Vue 방식으로 원본 테마의 Alpine식 메뉴 토글 동작을 구현.
|
||||
|
||||
## v0.0.3
|
||||
|
||||
- 공개 화면의 라이트/다크 색상 토큰을 추가.
|
||||
- 좌우 사이드바가 헤더 아래 전체 높이를 차지하도록 레이아웃 보정.
|
||||
- Thred 참고 화면에 가깝게 헤더, 히어로, 사이드바 임시 콘텐츠를 보강.
|
||||
|
||||
## v0.0.2
|
||||
|
||||
- Nuxt 3 기반 프로젝트 실행 구조를 추가.
|
||||
- Tailwind CSS, Zod, Nuxt 서버 API 초기 골격을 추가.
|
||||
- 공개 화면, 관리자 화면, 콘텐츠 컴포넌트의 기본 파일 구조를 생성.
|
||||
- Docker 기반 NAS 배포 초안을 추가.
|
||||
- 프로젝트 전용 개발/운영 포트 기준을 추가.
|
||||
|
||||
## v0.0.1
|
||||
|
||||
- sori.studio 개인 블로그/CMS 초기 방향 정리.
|
||||
|
||||
@@ -19,11 +19,13 @@
|
||||
- Vue 컴포넌트 파일: PascalCase
|
||||
- CSS 클래스: kebab-case
|
||||
- 고유 클래스명 필수 (Tailwind 외)
|
||||
- Nuxt 컴포넌트 자동 import는 경로 prefix 없이 파일명 기준으로 사용
|
||||
|
||||
## 스타일
|
||||
|
||||
- TailwindCSS 기본 사용
|
||||
- 주요 요소: Tailwind + 고유 className 동시 적용
|
||||
- 관리자 글 에디터는 블록 단위 UI로 작성하되 저장 값은 기존 마크다운 문자열을 유지
|
||||
|
||||
```html
|
||||
<main class="site-main w-[720px]">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 배포 가이드
|
||||
|
||||
> 현재 프로젝트는 코드 스캐폴딩 전 상태다. 아래 내용은 Nuxt 앱 생성 후 적용할 기본 배포 방향이다.
|
||||
> 현재 프로젝트는 Nuxt 3 초기 스캐폴딩 상태다. Docker 설정은 파일 기준 초안이 있으며 운영 DB 확정 후 NAS에서 검증한다.
|
||||
|
||||
## 빌드 유형
|
||||
|
||||
@@ -9,13 +9,15 @@
|
||||
| 개발 | `npm run dev` | 로컬 테스트, 개발 서버 |
|
||||
| 프로덕션 | `npm run build` | NAS 배포, 운영 서버 |
|
||||
|
||||
> `npm run dev`는 프로젝트 전용 실행 스크립트를 통해 Localhost, Local IP, Admin, Tailwind Viewer 링크만 요약 출력한다.
|
||||
|
||||
---
|
||||
|
||||
## 로컬 개발
|
||||
|
||||
### 필수 조건
|
||||
|
||||
- Node.js 20+ 권장
|
||||
- Node.js 22 LTS 권장
|
||||
- npm 9+
|
||||
- 개발 DB
|
||||
|
||||
@@ -32,23 +34,48 @@ cd sori.studio
|
||||
npm install
|
||||
|
||||
# 개발 환경 변수 설정
|
||||
# .env.development는 Git에 올리지 않는 로컬 전용 파일
|
||||
# 새로 만들 때는 .env.example을 복사한 뒤 비밀번호를 랜덤 값으로 교체
|
||||
cp .env.example .env.development
|
||||
# .env.development 파일에 개발 DB 연결 정보 입력
|
||||
openssl rand -hex 32
|
||||
# 로컬 DB 컨테이너를 호스트에서 접근할 때는 127.0.0.1:43119 사용
|
||||
|
||||
# 개발 서버 실행
|
||||
# 개발 서버 실행 (127.0.0.1:43117)
|
||||
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://localhost:3000
|
||||
- 관리자: http://localhost:3000/admin
|
||||
- 개발 서버: http://127.0.0.1:43117
|
||||
- 관리자: http://127.0.0.1:43117/admin
|
||||
- Tailwind Viewer: http://127.0.0.1:43117/_tailwind/
|
||||
|
||||
---
|
||||
|
||||
## UGREEN NAS Docker 배포
|
||||
|
||||
> Dockerfile과 docker-compose 설정은 아직 작성 전이다.
|
||||
> Dockerfile과 docker-compose 설정은 초안이며 NAS 운영 환경에서는 아직 검증 전이다.
|
||||
|
||||
### SSH 접속
|
||||
|
||||
@@ -69,11 +96,13 @@ git clone https://git.sori.studio/zenn/sori.studio.git
|
||||
cd sori.studio
|
||||
|
||||
# 운영 환경 변수 설정
|
||||
# .env.production은 Git에 올리지 않는 운영 전용 파일
|
||||
cp .env.example .env.production
|
||||
# .env.production 파일에 운영 DB 연결 정보 입력
|
||||
# .env.production 파일에 운영 DB 연결 정보, 운영 전용 랜덤 비밀번호, APP_PORT=43118 입력
|
||||
# Docker 내부 앱에서 PostgreSQL에 접근할 때는 sori-studio-db:5432 사용
|
||||
|
||||
# Docker 빌드 및 실행
|
||||
docker-compose up -d
|
||||
docker compose --env-file .env.production up -d
|
||||
```
|
||||
|
||||
### 프로덕션 빌드 (NAS에서)
|
||||
@@ -89,7 +118,10 @@ docker run -d -p 3000:3000 sori.studio:latest
|
||||
|
||||
### 포트
|
||||
|
||||
- HTTP: 3000
|
||||
- 로컬 개발: 43117
|
||||
- NAS Docker 외부: 43118
|
||||
- 컨테이너 내부: 3000
|
||||
- PostgreSQL 외부: 43119
|
||||
- HTTPS: 3001 (SSL 설정 시)
|
||||
|
||||
---
|
||||
@@ -98,13 +130,30 @@ docker run -d -p 3000:3000 sori.studio:latest
|
||||
|
||||
- 로컬 개발: `.env.development`의 `DATABASE_URL`
|
||||
- NAS 운영: `.env.production`의 `DATABASE_URL`
|
||||
- 로컬 개발 예시: `postgres://sori_studio:비밀번호@127.0.0.1:43119/sori_studio`
|
||||
- NAS Docker 예시: `postgres://sori_studio:비밀번호@sori-studio-db:5432/sori_studio`
|
||||
- `.env.example`에는 실제 비밀번호나 개인 이메일을 기록하지 않음
|
||||
- 개발/운영 DB 비밀번호와 관리자 비밀번호는 서로 다른 랜덤 값을 사용
|
||||
- 개발 DB와 운영 DB는 반드시 별도 인스턴스 또는 별도 데이터베이스로 분리
|
||||
- 운영 DB는 로컬 개발 서버에서 직접 연결하지 않음
|
||||
- 관리 도구: CloudBeaver 등으로 추후 연결 가능하게 설계
|
||||
- NAS Docker 배포 시 PostgreSQL 초기 스키마는 `db/migrations/`의 SQL로 생성
|
||||
- 로컬 개발 Docker Compose 실행 시 `ENV_FILE=.env.development`와 `--env-file .env.development`를 함께 사용
|
||||
- 로컬 개발 DB 마이그레이션은 `npm run db:migrate:dev`로 실행
|
||||
|
||||
## 업로드 파일
|
||||
|
||||
- 관리자 글쓰기에서 업로드한 이미지는 `/uploads/posts/YYYY/MM/` URL로 제공한다.
|
||||
- 로컬 개발에서는 실제 파일이 `public/uploads/posts/YYYY/MM/` 아래 저장된다.
|
||||
- `public/uploads/`는 Git에 포함하지 않는다.
|
||||
- NAS 운영에서는 업로드 파일이 컨테이너 재생성으로 사라지지 않도록 별도 볼륨 연결을 확정해야 한다.
|
||||
- `MAX_FILE_SIZE` 환경 변수로 관리자 이미지 업로드 최대 크기를 제한한다.
|
||||
- 관리자 미디어 화면은 현재 업로드 파일 시스템을 기준으로 목록, 파일명 변경, 삭제를 처리한다.
|
||||
|
||||
## 사용자 액션 필요 항목
|
||||
|
||||
- NAS SSH 접속 주소 확인.
|
||||
- NAS 프로젝트 루트 경로 확정.
|
||||
- 운영 DB 이름, 계정, 권한 확정.
|
||||
- 운영 업로드 볼륨 경로 확정.
|
||||
- 도메인 `sori.studio`의 NAS 연결 방식 확정.
|
||||
|
||||
274
docs/history.md
274
docs/history.md
@@ -1,5 +1,279 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-03 v0.0.31
|
||||
|
||||
### 글 미리보기 저장 방식 결정
|
||||
|
||||
글 미리보기는 데이터베이스에 임시 초안 레코드를 만들지 않고 브라우저 저장소를 통해 현재 작성 폼 값을 전달한다. 저장 전 내용 확인이 목적이므로 DB에 미리보기용 글이 쌓이거나 슬러그 충돌을 만드는 일을 피하기 위해서다.
|
||||
|
||||
미리보기 화면은 `/admin/posts/preview` 관리자 경로에 두고, 공개 게시물 상세와 같은 `ContentRenderer`, `ProseHeaderCard`, `ContentMarkdownRenderer` 조합으로 본문을 렌더링한다. 이렇게 하면 저장 전에도 공개 화면에 가까운 결과를 빠르게 확인할 수 있다.
|
||||
|
||||
## 2026-05-03 v0.0.30
|
||||
|
||||
### OG 이미지 저장 방식 결정
|
||||
|
||||
게시물 OG 이미지는 대표 이미지와 별도 필드인 `og_image`로 저장한다. 대표 이미지는 화면 카드와 본문 진입 시각 요소에 쓰이고, OG 이미지는 외부 공유 미리보기 비율과 목적이 다를 수 있기 때문이다.
|
||||
|
||||
관리자 입력은 대표 이미지와 같은 미디어 선택/업로드 흐름을 재사용한다. OG 이미지가 비어 있으면 공개 상세 화면에서는 대표 이미지를 fallback으로 사용해 기존 글도 기본 공유 이미지를 가질 수 있게 한다.
|
||||
|
||||
## 2026-05-03 v0.0.29
|
||||
|
||||
### 게시물 SEO 설정 범위 결정
|
||||
|
||||
게시물 SEO 설정은 우선 검색 결과에 직접 영향을 주는 SEO 제목, SEO 설명, canonical URL, robots noindex 값만 다룬다. OG 이미지는 대표 이미지 재사용 여부와 별도 이미지 선택 흐름이 더 필요하므로 이번 단계에서는 기본 OG 제목/설명/URL만 공개 상세 화면에 연결하고, 전용 OG 이미지는 다음 작업으로 남긴다.
|
||||
|
||||
SEO 제목과 설명이 비어 있으면 기존 글 제목과 요약을 fallback으로 사용한다. 이렇게 하면 모든 글에 값을 강제로 입력하지 않아도 공개 화면의 기본 메타 품질을 유지할 수 있다.
|
||||
|
||||
## 2026-05-03 v0.0.28
|
||||
|
||||
### 예약 발행 저장 방식 결정
|
||||
|
||||
예약 발행은 별도 `scheduled` 상태를 추가하지 않고 기존 `published` 상태와 미래 `published_at` 값을 조합해 처리한다. 현재 데이터베이스의 게시물 상태 제약은 `published`, `draft`, `private`만 허용하고 있으므로 상태값을 늘리기보다 공개 API의 조회 조건으로 발행 시각을 확인하는 편이 변경 범위가 작다.
|
||||
|
||||
관리자 목록에서는 미래 발행 시각을 가진 `published` 게시물을 예약 상태로 표시한다. 공개 목록과 상세 API는 `published_at`이 비어 있거나 현재 시각 이전인 발행 글만 노출한다.
|
||||
|
||||
## 2026-05-02 v0.0.27
|
||||
|
||||
### 미디어 폴더 트리 관리 방식 결정
|
||||
|
||||
미디어 폴더는 워드프레스 플러그인형 폴더 UX처럼 왼쪽 트리에서 만들고 선택하지만, 실제 업로드 파일 경로는 이동하지 않는다. 이미 게시물과 페이지에 저장된 이미지 URL이 깨지는 일을 막기 위해 폴더 이동은 `media_metadata.category` 값을 경로 문자열로 갱신하는 방식으로 처리한다.
|
||||
|
||||
빈 폴더도 남길 수 있어야 하므로 `media_folders` 테이블을 별도로 둔다. 다만 미디어 사용 여부와 공개 렌더링은 계속 URL 기준으로 판단하며, Ctrl/Command 및 Shift 복수 선택과 드래그 이동은 선택된 URL 목록의 메타데이터만 일괄 변경한다.
|
||||
|
||||
## 2026-05-02 v0.0.26
|
||||
|
||||
### 미디어 카테고리 저장 방식 결정
|
||||
|
||||
미디어 카테고리는 실제 파일 경로나 URL을 변경하지 않고 `media_metadata` 테이블에 URL별 메타데이터로 저장한다. 업로드 파일을 폴더별로 이동하면 이미 게시물이나 페이지에 저장된 이미지 URL이 깨질 수 있기 때문이다.
|
||||
|
||||
파일명 변경은 사용 중인 미디어에서 차단되어 있지만, 미사용 파일명을 변경할 때는 기존 URL의 메타데이터도 새 URL로 옮긴다. 삭제 시에는 남은 메타데이터가 쌓이지 않도록 함께 정리한다.
|
||||
|
||||
## 2026-05-02 v0.0.25
|
||||
|
||||
### 빈 문단 placeholder 표시와 네비게이션 관리 범위 결정
|
||||
|
||||
블록 에디터의 `/` 안내 문구는 첫 빈 화면이거나 마지막 보조 입력 블록일 때만 표시한다. 사용자가 중간에 의도적으로 만든 빈 문단에도 같은 안내가 반복되면 작성 중인 여백이 오류처럼 보이고, 실제 내용보다 placeholder가 더 강하게 눈에 들어오기 때문이다.
|
||||
|
||||
네비게이션 관리는 1차로 공개 왼쪽 사이드바의 상단 메뉴와 하단 링크를 대상으로 한다. 기존 화면에서 이미 해당 영역이 메뉴 역할을 하고 있으므로 새 UI 영역을 만들기보다 하드코딩된 항목을 `navigation_items` 테이블로 옮겨 관리자에서 라벨, URL, 위치, 순서, 표시 여부를 조정할 수 있게 한다.
|
||||
|
||||
## 2026-05-02 v0.0.24
|
||||
|
||||
### 빈 줄 입력 보존과 사이트 설정 범위 결정
|
||||
|
||||
관리자 블록 에디터는 마지막에 클릭 가능한 빈 문단을 유지하지만, 사용자가 Enter로 만든 연속 빈 문단은 자동 삭제하지 않는다. 글 작성 중 여러 줄을 띄워 생각의 구간을 나누는 행동이 자연스럽고, 보조 입력 블록 정리 로직이 사용자의 입력 의도를 지우면 안 되기 때문이다.
|
||||
|
||||
사이트 설정은 우선 단일 `site_settings` 레코드로 관리한다. 개인 블로그 초기 단계에서는 여러 사이트나 다국어 설정보다 사이트 이름, 설명, 기본 URL, 텍스트 로고, 저작권 문구를 안정적으로 저장하고 공개 화면에 반영하는 흐름이 더 중요하다. 이미지 기반 로고와 프로필 이미지는 미디어 사용처 추적과 연결해야 하므로 이후 미디어 설정 확장 단계에서 다룬다.
|
||||
|
||||
## 2026-05-02 v0.0.23
|
||||
|
||||
### 고정 페이지 관리 구조 결정
|
||||
|
||||
고정 페이지 작성과 수정은 게시물과 같은 블록형 에디터를 공유하되, 별도 `AdminPageForm`으로 분리한다. 페이지는 상태, 요약, 태그, 발행일이 없는 정적 콘텐츠이므로 게시물 폼을 그대로 재사용하면 불필요한 필드와 저장 조건이 섞이기 때문이다.
|
||||
|
||||
관리자 경로는 내부 리소스 컬렉션 기준으로 `/admin/pages/:id`를 사용하고, 공개 보기 경로는 기존 고정 페이지 공개 구조인 `/pages/:slug`를 유지한다. 페이지는 목록과 태그 흐름에 노출되지 않는 독립 콘텐츠로 다루기 위해서다.
|
||||
|
||||
## 2026-05-02 v0.0.22
|
||||
|
||||
### 글쓰기 하단 빈 블록과 저장 피드백 보정
|
||||
|
||||
이미지, 갤러리, 임베드 같은 비텍스트 블록이 글의 마지막에 오더라도 작성자가 이어서 글을 쓸 수 있도록 에디터 마지막에는 항상 빈 문단 블록을 유지한다. 이 빈 문단은 작성 편의를 위한 입력 지점이므로 내용이 없으면 저장 마크다운에는 포함하지 않는다.
|
||||
|
||||
한글 조합 입력 직후 Enter는 IME 확정 동작으로 들어오는 경우가 있으므로 즉시 새 블록 생성으로 처리하지 않는다. 조합 확정 Enter와 문단 이동 Enter를 분리해 마지막 글자가 다음 블록에 중복 입력되는 문제를 줄이기 위해서다.
|
||||
|
||||
저장 버튼을 눌렀을 때 동작 여부가 보이지 않으면 작성자가 같은 동작을 반복할 수 있으므로, 저장/수정/삭제 진행과 결과는 우측 상단 토스트로 표시한다. 새 글 저장 후 수정 화면으로 이동하는 경우에도 성공 토스트를 이어서 표시한다.
|
||||
|
||||
## 2026-05-02 v0.0.21
|
||||
|
||||
### 글 작성 중 자동 저장 범위 결정
|
||||
|
||||
글 작성 중 자동 저장은 1차로 브라우저 `localStorage`에 보존한다. 저장 버튼을 누르기 전까지 서버에 게시물을 생성하지 않으면, 의도하지 않은 초안이 DB에 쌓이거나 슬러그 충돌이 발생하는 일을 피할 수 있기 때문이다.
|
||||
|
||||
자동 저장본은 새 글과 기존 글을 서로 다른 키로 분리한다. 작성 화면에 다시 들어왔을 때는 자동으로 덮어쓰지 않고 복원/삭제 선택지를 보여준다. 명시적인 저장이 성공하면 해당 자동 저장본을 삭제해 저장 완료 후 오래된 내용이 다시 나타나지 않도록 한다.
|
||||
|
||||
## 2026-05-02 v0.0.20
|
||||
|
||||
### 콜아웃, 토글, 임베드 블록 저장 방식 결정
|
||||
|
||||
콜아웃, 토글, 임베드는 기존 `content` 마크다운 문자열 안에 `:::callout`, `:::toggle`, `:::embed` fenced block으로 저장한다. 이미지 갤러리와 같은 확장 문법을 사용하면 DB 스키마를 바꾸지 않고도 관리자 작성 화면과 공개 렌더러를 함께 확장할 수 있기 때문이다.
|
||||
|
||||
임베드는 1차로 YouTube URL만 iframe으로 렌더링하고, 그 외 URL은 외부 링크로 표시한다. Twitter 등 외부 서비스별 스크립트 임베드는 SSR 안정성과 개인정보/스크립트 로딩 정책을 검토한 뒤 별도 단계에서 확장한다.
|
||||
|
||||
## 2026-05-02 v0.0.19
|
||||
|
||||
### 블록 에디터 조합 입력과 이미지 캡션 표시 보정
|
||||
|
||||
관리자 블록 에디터는 한글처럼 조합 과정이 있는 입력 중에는 마크다운 단축 변환과 슬래시 메뉴 상태 확정을 미룬다. 조합 중인 DOM 텍스트를 Vue 상태로 다시 덮어쓰면 마지막 글자가 중복되거나 입력 순서가 어긋날 수 있기 때문이다.
|
||||
|
||||
이미지 삽입 시 파일명이나 미디어 제목을 자동으로 alt/caption에 채우지 않는다. 파일명은 작성자가 공개 본문에서 보려는 설명이 아니므로 기본 화면에서는 숨기고, 필요한 경우 이미지 hover 또는 focus 상태에서만 alt 입력을 열어 직접 작성하도록 한다.
|
||||
|
||||
## 2026-05-02 v0.0.18
|
||||
|
||||
### 공개 URL 복수형/단수형 기준 결정
|
||||
|
||||
게시물과 태그의 전체 목록은 컬렉션이므로 `/posts`, `/tags` 복수형을 사용한다. 개별 게시물과 특정 태그 상세는 하나의 리소스를 가리키므로 `/post/:slug`, `/tag/:slug` 단수형을 기준 경로로 정한다.
|
||||
|
||||
기존에 사용하던 `/posts/:slug`, `/tags/:slug`는 외부 링크나 기존 이동 흐름이 깨지지 않도록 새 단수형 경로로 리다이렉트한다. 관리자 API와 관리자 화면 경로는 내부 관리 리소스 컬렉션이므로 기존 `/admin/posts/:id`, `/admin/tags/:id`를 유지한다.
|
||||
|
||||
## 2026-05-02 v0.0.17
|
||||
|
||||
### 대표 이미지와 미디어 화면 밀도 개선 결정
|
||||
|
||||
대표 이미지는 URL을 직접 입력하지 않고 미디어 라이브러리에서 선택하거나 새로 업로드하는 흐름으로 통일한다. 게시물 작성자가 파일 URL을 다루지 않아도 되고, 이미 업로드된 이미지를 재사용할 수 있어야 하기 때문이다. 대표 이미지가 설정되면 썸네일과 삭제/변경 액션을 바로 보여준다.
|
||||
|
||||
미디어 화면은 수백 개 이상의 파일이 쌓이는 전제를 기준으로 카드형 목록에서 고밀도 썸네일 갤러리로 바꾼다. 파일 경로, 용량, 사용 현황, 파일명 변경, 삭제 같은 상세 정보는 워드프레스처럼 선택한 이미지의 상세 모달에서 확인하고 처리한다.
|
||||
|
||||
## 2026-05-01 v0.0.16
|
||||
|
||||
### 미디어 사용처 표시와 삭제 보호 결정
|
||||
|
||||
미디어 라이브러리에서 파일명 변경과 삭제를 제공하면, 해당 이미지가 글 본문이나 대표 이미지에 사용 중인지 먼저 보여줘야 한다. 현재 콘텐츠는 이미지 URL을 게시물/페이지의 `content`와 `featuredImage`에 직접 저장하므로, 사용 중인 파일을 변경하거나 삭제하면 공개 화면의 이미지가 깨진다.
|
||||
|
||||
따라서 1차 사용처 추적은 게시물과 페이지를 대상으로 본문, 대표 이미지 위치를 표시한다. 사용 중인 미디어의 파일명 변경과 삭제는 차단하고, 미사용 파일만 정리할 수 있도록 한다. 프로필이나 사이트 설정 이미지는 아직 해당 데이터 모델이 없으므로 설정 기능 구현 시 사용처 추적에 추가한다.
|
||||
|
||||
## 2026-05-01 v0.0.15
|
||||
|
||||
### 미디어 라이브러리 1차 범위 결정
|
||||
|
||||
글쓰기 화면에서 이미지를 매번 로컬 업로드만 하는 흐름은 장기적으로 불편하므로, 먼저 업로드된 파일을 다시 선택할 수 있는 미디어 선택 창을 붙인다. 관리자 사이드바에는 미디어 메뉴를 추가하고, 업로드된 이미지 목록, 파일명 변경, 삭제를 1차 기능으로 제공한다.
|
||||
|
||||
미디어 데이터는 아직 별도 DB 테이블을 만들지 않고 `public/uploads` 아래 실제 파일 시스템을 기준으로 읽는다. 카테고리 분류와 이미지 사용처 추적은 파일만으로 안정적으로 관리하기 어렵기 때문에 이후 미디어 메타데이터 테이블을 만들 때 함께 확장한다.
|
||||
|
||||
## 2026-05-01 v0.0.14
|
||||
|
||||
### 이미지와 갤러리 블록 구현 범위 결정
|
||||
|
||||
관리자 글쓰기의 이미지 기능은 기존 `content` 필드를 유지하면서 마크다운 확장 문법으로 저장한다. 단일 이미지는 기본 마크다운 이미지 문법에 `{width=wide}` 같은 너비 옵션만 붙이고, 갤러리는 `:::gallery` fenced block 안에 여러 이미지 행을 넣는다. 이렇게 하면 DB 구조를 바꾸지 않고 공개 렌더러와 관리자 에디터를 함께 확장할 수 있다.
|
||||
|
||||
이번 단계에서는 게시물 작성 중 새 이미지를 업로드하고 글에 삽입하는 흐름을 먼저 구현한다. 워드프레스처럼 이미 업로드된 미디어를 선택하거나 파일명 변경, 개별 삭제, 카테고리 분류를 관리하는 기능은 별도 미디어 라이브러리 메뉴에서 다룬다. 글쓰기 화면이 이후 미디어 라이브러리를 호출할 수 있도록 업로드 API와 저장 URL 기준을 먼저 고정한다.
|
||||
|
||||
## 2026-05-01 v0.0.13
|
||||
|
||||
### 개발 서버 로그 요약 방식 결정
|
||||
|
||||
Nuxt 개발 서버의 기본 로그는 프레임워크 상태와 빌드 이벤트를 자세히 보여주지만, 일상적인 로컬 개발에서는 접속 링크만 빠르게 확인하는 편이 더 효율적이다. 따라서 `npm run dev`는 프로젝트 전용 래퍼 스크립트로 Nuxt dev 서버를 실행하고, 터미널에는 Localhost, Local IP, Admin, Tailwind Viewer 링크를 요약 출력한다.
|
||||
|
||||
오류나 경고에 가까운 로그는 계속 터미널에 남긴다. 개발 서버 실행 자체는 Nuxt CLI를 그대로 사용하되 출력만 정리해, 프레임워크 동작 방식은 바꾸지 않고 로컬 사용성만 개선한다.
|
||||
|
||||
## 2026-05-01 v0.0.12
|
||||
|
||||
### 제목과 본문 입력 흐름 보정
|
||||
|
||||
관리자 글쓰기 화면에서 제목은 별도 데이터 필드로 유지하되, 키보드 흐름은 본문 에디터와 이어지도록 한다. 제목 입력 중 Enter를 누르면 폼 제출이 아니라 본문 첫 블록으로 포커스를 이동해 Ghost류 작성 화면처럼 하나의 글쓰기 흐름으로 느껴지게 한다.
|
||||
|
||||
관리자 에디터 본문 색상은 공개 화면용 `post-prose` 전역 색상 변수를 그대로 따르지 않고 관리자 화면의 `ink` 색상으로 고정한다. 시스템 다크모드 변수와 관리자 흰 배경이 섞이면 실제 입력 텍스트가 placeholder보다 흐리거나 읽기 어려워질 수 있기 때문이다.
|
||||
|
||||
## 2026-05-01 v0.0.11
|
||||
|
||||
### 블록 에디터 키보드 흐름 보정
|
||||
|
||||
빈 문단에서 Enter를 누를 때도 다음 빈 문단 블록을 생성하도록 유지한다. 작성 중 의도적으로 여백을 두거나 다음 입력 위치로 이동하는 행동이 자연스러운 글쓰기 흐름이기 때문이다. 저장 시에는 기존처럼 비어 있는 블록을 마크다운 문자열에 포함하지 않는다.
|
||||
|
||||
슬래시 메뉴는 입력 포커스를 본문 블록에 둔 채 키보드로 선택한다. `/제목 3`처럼 필터링한 뒤 Enter를 누르면 현재 강조된 항목을 적용하고, 위/아래 방향키로 강조 항목을 이동한다. 이렇게 하면 메뉴 항목으로 실제 DOM 포커스를 옮기지 않아도 Ghost류 에디터처럼 연속 입력 흐름을 유지할 수 있다.
|
||||
|
||||
## 2026-05-01 v0.0.10
|
||||
|
||||
### 블록 에디터 입력 안정화 결정
|
||||
|
||||
관리자 블록 에디터는 `contenteditable` 요소 안의 텍스트를 Vue 템플릿 보간으로 직접 렌더링하지 않고 DOM 참조를 통해 동기화한다. Vue의 렌더 패치와 브라우저의 조합 입력이 동시에 같은 텍스트 노드를 수정하면 `/` 입력이나 한글 필터 입력이 중복되는 문제가 생기기 때문이다.
|
||||
|
||||
슬래시 메뉴는 고정적으로 아래에 열지 않고 활성 블록 위치와 화면 높이를 기준으로 위 또는 아래에 표시한다. 글 하단에서 블록을 추가할 때 메뉴가 화면 밖으로 밀리는 문제를 줄이기 위해서다.
|
||||
|
||||
제목은 별도 폼 영역이 아니라 에디터 상단의 큰 제목 입력으로 유지한다. Ghost 작성 화면처럼 제목과 본문이 하나의 흐름으로 보이는 편이 글쓰기 집중도와 결과 화면 예측에 더 가깝기 때문이다.
|
||||
|
||||
## 2026-05-01 v0.0.9
|
||||
|
||||
### 관리자 블록형 글쓰기 방식 결정
|
||||
|
||||
관리자 글 작성은 순수 마크다운 textarea가 아니라 Ghost 스타일에 가까운 블록형 에디터를 기준으로 전환한다. 사용자가 `/` 명령으로 블록을 선택하고, `##` 같은 마크다운 단축 입력을 즉시 제목 블록으로 변환해 작성 화면과 결과 화면의 차이를 줄이기 위해서다.
|
||||
|
||||
다만 현재 데이터베이스와 API의 `content` 필드는 그대로 유지한다. 블록 에디터 내부에서는 문단, 제목, 인용, 목록, 코드, 구분선을 블록으로 다루고 저장 시 마크다운 문자열로 직렬화한다. 이렇게 하면 기존 게시물 저장 구조를 깨지 않으면서도 이후 이미지, 임베드, 콜아웃 같은 Ghost 카드형 블록을 단계적으로 확장할 수 있다.
|
||||
|
||||
공개 게시물과 고정 페이지 본문도 같은 마크다운 렌더러를 사용하도록 연결한다. 작성 화면과 보는 화면을 완전히 동일하게 만드는 것은 이미지 업로드와 전체 콘텐츠 컴포넌트 구현 이후 다시 보정하되, 이번 단계에서는 제목, 목록, 인용, 코드 등 기본 블록의 시각 차이를 먼저 줄인다.
|
||||
|
||||
## 2026-05-01 v0.0.8
|
||||
|
||||
### 관리자 마크다운 미리보기 방식 결정
|
||||
|
||||
관리자 글 편집의 미리보기는 저장 형식을 바꾸지 않고 textarea 입력 위에 작성/미리보기 탭을 추가하는 방식으로 시작한다. 현재 공개 게시물 렌더링이 아직 완전한 마크다운 파서를 사용하지 않기 때문에, 관리자 미리보기는 기본 문법 확인용으로 제한하고 원본 마크다운 문자열을 그대로 저장한다.
|
||||
|
||||
편집 편의 기능은 제목, 굵게, 목록, 인용, 코드 블록 삽입 버튼으로 시작한다. 별도 에디터 패키지는 이미지 업로드와 공개 렌더링 방향이 확정된 뒤 필요성을 다시 판단한다.
|
||||
|
||||
## 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
|
||||
|
||||
### 환경 변수 파일 보안 기준 정리
|
||||
|
||||
`.env.example`은 Git에 포함되는 공유 템플릿이므로 실제 개인 이메일, 관리자 비밀번호, DB 비밀번호를 기록하지 않는다. 공유 파일에는 placeholder만 두고, 실제 값은 Git에서 제외되는 `.env.development`와 `.env.production`에서만 관리한다.
|
||||
|
||||
로컬 개발 환경은 `127.0.0.1:43119`로 개발 DB에 연결하고, NAS Docker 운영 환경은 `sori-studio-db:5432`로 운영 DB에 연결한다. 개발 DB와 운영 DB는 비밀번호도 분리해 한쪽 값이 노출되더라도 다른 환경으로 전파되지 않게 한다.
|
||||
|
||||
이미 원격 저장소에 올라간 비밀번호가 실제 사용 값이었다면 Git 이력에서 지워도 안전하다고 볼 수 없으므로, 해당 값은 폐기하고 새 랜덤 값으로 교체하는 것을 전제로 한다.
|
||||
|
||||
## 2026-04-29 v0.0.5
|
||||
|
||||
### PostgreSQL 기반 데이터 계층 결정
|
||||
|
||||
DB 관리 도구로 CloudBeaver를 고려하고 NAS Docker 배포를 전제로 하기 때문에 초기 데이터베이스는 PostgreSQL로 잡는다. SQLite보다 운영/개발 분리, 외부 관리 도구 연결, 향후 확장에 유리하다.
|
||||
|
||||
Nuxt 서버 API는 바로 DB에 강결합하지 않고 `server/repositories`를 통해 콘텐츠를 조회한다. `DATABASE_URL`이 설정된 환경에서는 PostgreSQL을 사용하고, 설정되지 않은 환경에서는 샘플 데이터를 사용해 화면과 API 개발을 계속할 수 있게 했다.
|
||||
|
||||
Docker Compose에는 앱과 PostgreSQL 서비스를 함께 두되, 실제 운영 비밀번호와 연결 문자열은 `.env.production`에서 관리한다. DB 외부 포트는 기존 사용 포트와 겹치지 않도록 `43119`를 사용한다.
|
||||
|
||||
## 2026-04-29 v0.0.4
|
||||
|
||||
### 메뉴 토글 구현 방식 결정
|
||||
|
||||
원본 Ghost 테마는 Alpine 스타일의 `@click`, `:class`, `:aria-expanded` 바인딩으로 좌측 메뉴 상태를 제어한다. 이 프로젝트는 Nuxt/Vue 기반이므로 Alpine을 추가하지 않고 Vue 상태와 composable로 같은 기능을 구현한다.
|
||||
|
||||
메뉴 상태는 `useMenuState`에서 공유하고, 브라우저 `localStorage`의 `MENU_STATE`에 저장한다. 이렇게 하면 헤더 버튼, 공개 레이아웃, 게시물 레이아웃이 같은 상태를 사용하면서도 별도 프론트엔드 상태 라이브러리나 Alpine 의존성을 추가하지 않아도 된다.
|
||||
|
||||
## 2026-04-29 v0.0.3
|
||||
|
||||
### 공개 화면 테마와 패널 구조 보정
|
||||
|
||||
참고 화면 기준으로 공개 화면은 단순한 흰색 페이지가 아니라 헤더 아래 좌측 사이드바, 중앙 본문, 우측 사이드바가 각각 전체 화면 높이를 차지하는 패널 구조로 정했다. 사이드바 콘텐츠가 적어도 패널 자체는 `calc(100vh - 57px)` 높이를 유지한다.
|
||||
|
||||
색상은 초반부터 라이트/다크 모드 기준을 잡기 위해 CSS 변수로 관리한다. 기본 배경, 패널, 라인, 텍스트, 보조 텍스트, 입력, 강조 버튼 색상을 분리해 이후 디자인 조정 시 Tailwind 클래스 전체를 갈아엎지 않도록 했다.
|
||||
|
||||
## 2026-04-29 v0.0.2
|
||||
|
||||
### Nuxt 통합 백엔드 구조 결정
|
||||
|
||||
초기 세팅은 별도 백엔드 앱을 만들지 않고 Nuxt/Nitro의 `server/api`를 사용한다. 개인 블로그와 CMS를 한 서버에서 배포하면 로컬 개발, NAS 운영, 환경 변수 관리가 단순해진다. DB 연결과 API 라우팅은 Nuxt 서버 영역에서 시작하고, 추후 독립 배포나 워커가 필요해질 때 백엔드 분리를 재검토한다.
|
||||
|
||||
Nuxt 3, Tailwind CSS, Zod를 실제 의존성으로 추가하고 공개 화면, 관리자 화면, 콘텐츠 컴포넌트의 초기 골격을 만들었다. 현재 API는 샘플 데이터 기반이며 다음 단계에서 개발 DB로 교체한다.
|
||||
|
||||
기본 포트와 사용 중인 포트 충돌을 피하기 위해 로컬 개발 서버는 `43117`, NAS Docker 외부 포트는 `43118`을 사용한다. 컨테이너 내부 포트는 Nuxt 기본 실행 흐름에 맞춰 `3000`으로 유지한다.
|
||||
|
||||
## 2026-04-29 v0.0.1
|
||||
|
||||
### 초기 제품 방향 결정
|
||||
|
||||
121
docs/map.md
121
docs/map.md
@@ -16,17 +16,27 @@
|
||||
| 파일 | 화면 위치 |
|
||||
|------|-----------|
|
||||
| components/site/SiteHeader.vue | 모든 공개 페이지 상단 |
|
||||
| components/site/LeftSidebar.vue | 메인 화면 왼쪽 |
|
||||
| components/site/RightSidebar.vue | 메인 화면 오른쪽 |
|
||||
| components/site/LeftSidebar.vue | 메인 화면 왼쪽, 네비게이션과 태그 목록 |
|
||||
| components/site/RightSidebar.vue | 메인 화면 오른쪽, 사이트 설정 표시 |
|
||||
| components/site/MainColumn.vue | 메인 화면 중앙 |
|
||||
| components/site/PostCard.vue | 목록의 게시물 카드 |
|
||||
| components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일 |
|
||||
| components/site/TagHeader.vue | 태그 페이지 헤더 |
|
||||
|
||||
## 관리자 컴포넌트
|
||||
|
||||
| 파일 | 화면 위치 |
|
||||
|------|-----------|
|
||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, 대표 이미지/OG 이미지 선택, 로컬 자동 저장, 예약 발행 시각 입력, SEO 설정, 미리보기 요청 |
|
||||
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
|
||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, 하단 빈 입력 블록 유지 |
|
||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼 |
|
||||
|
||||
## 콘텐츠 컴포넌트
|
||||
|
||||
| 파일 | 화면 위치 |
|
||||
|------|-----------|
|
||||
| components/content/ContentRenderer.vue | 게시물/페이지 본문 |
|
||||
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 확장 블록 파싱 |
|
||||
| components/content/ProseHeading.vue | h1~h6 제목 |
|
||||
| components/content/ProseImage.vue | 본문 내 이미지 |
|
||||
| components/content/ProseList.vue | 목록 |
|
||||
@@ -46,11 +56,19 @@
|
||||
| 파일 | 화면 |
|
||||
|------|------|
|
||||
| pages/admin/index.vue | 대시보드 |
|
||||
| pages/admin/posts/index.vue | 글 목록 |
|
||||
| pages/admin/posts/new.vue | 글 작성 |
|
||||
| pages/admin/posts/[id].vue | 글 수정 |
|
||||
| pages/admin/login.vue | 관리자 로그인 |
|
||||
| pages/admin/posts/index.vue | 글 목록, 예약 발행 상태 표시 |
|
||||
| pages/admin/posts/new.vue | 글 작성, 저장 토스트 |
|
||||
| pages/admin/posts/[id].vue | 글 수정, 저장/삭제 토스트 |
|
||||
| pages/admin/posts/preview.vue | 글 미리보기 |
|
||||
| pages/admin/pages/index.vue | 페이지 목록 |
|
||||
| pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 |
|
||||
| pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 |
|
||||
| pages/admin/media/index.vue | 미디어 관리, 폴더 트리, 복수 선택, 드래그 이동 |
|
||||
| pages/admin/navigation/index.vue | 메뉴/네비게이션 관리 |
|
||||
| pages/admin/tags/index.vue | 태그 관리 |
|
||||
| pages/admin/tags/new.vue | 태그 생성 |
|
||||
| pages/admin/tags/[id].vue | 태그 수정 |
|
||||
| pages/admin/settings/index.vue | 사이트 설정 |
|
||||
|
||||
## 공개 페이지
|
||||
@@ -58,6 +76,93 @@
|
||||
| 파일 | 화면 |
|
||||
|------|------|
|
||||
| pages/index.vue | 홈 |
|
||||
| pages/posts/[slug].vue | 블로그 글 상세 |
|
||||
| pages/tags/[slug].vue | 태그별 글 목록 |
|
||||
| pages/posts/index.vue | 게시물 전체 목록 |
|
||||
| pages/posts/[slug].vue | `/post/:slug` 리다이렉트 |
|
||||
| pages/post/[slug].vue | 블로그 글 상세, 게시물 SEO/OG 메타 출력 |
|
||||
| pages/tags/index.vue | 태그 전체 목록 |
|
||||
| pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 |
|
||||
| pages/tag/[slug].vue | 태그별 글 목록 |
|
||||
| pages/pages/[slug].vue | 고정 페이지 상세 |
|
||||
|
||||
## 서버 API
|
||||
|
||||
| 파일 | 기능 |
|
||||
|------|------|
|
||||
| server/api/posts.get.js | 게시물 목록 샘플 API |
|
||||
| server/api/posts/[slug].get.js | 게시물 상세 샘플 API |
|
||||
| server/api/pages.get.js | 고정 페이지 목록 샘플 API |
|
||||
| server/api/pages/[slug].get.js | 고정 페이지 상세 샘플 API |
|
||||
| server/api/tags.get.js | 태그 목록 샘플 API |
|
||||
| server/api/site-settings.get.js | 공개 사이트 설정 API |
|
||||
| server/api/navigation.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/pages.get.js | 관리자 고정 페이지 목록 API |
|
||||
| server/routes/admin/api/pages.post.js | 관리자 고정 페이지 생성 API |
|
||||
| server/routes/admin/api/pages/[id].get.js | 관리자 고정 페이지 상세 API |
|
||||
| server/routes/admin/api/pages/[id].put.js | 관리자 고정 페이지 수정 API |
|
||||
| server/routes/admin/api/pages/[id].delete.js | 관리자 고정 페이지 삭제 API |
|
||||
| server/routes/admin/api/media.get.js | 관리자 미디어 목록 API |
|
||||
| server/routes/admin/api/media.put.js | 관리자 미디어 파일명 변경 및 단일/복수 폴더 변경 API |
|
||||
| server/routes/admin/api/media.delete.js | 관리자 미디어 삭제 API |
|
||||
| server/routes/admin/api/media-folders.get.js | 관리자 미디어 폴더 목록 API |
|
||||
| server/routes/admin/api/media-folders.post.js | 관리자 미디어 폴더 생성 API |
|
||||
| server/routes/admin/api/uploads.post.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/routes/admin/api/settings.get.js | 관리자 사이트 설정 조회 API |
|
||||
| server/routes/admin/api/settings.put.js | 관리자 사이트 설정 수정 API |
|
||||
| server/routes/admin/api/navigation.get.js | 관리자 네비게이션 목록 API |
|
||||
| server/routes/admin/api/navigation.put.js | 관리자 네비게이션 일괄 저장 API |
|
||||
| server/utils/content-schema.js | Zod 콘텐츠 스키마 |
|
||||
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
|
||||
| server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 |
|
||||
| server/utils/admin-post-input.js | 관리자 게시물 입력값 검증 스키마 |
|
||||
| server/utils/admin-page-input.js | 관리자 고정 페이지 입력값 검증 스키마 |
|
||||
| server/utils/admin-site-settings-input.js | 관리자 사이트 설정 입력값 검증 스키마 |
|
||||
| server/utils/admin-navigation-input.js | 관리자 네비게이션 입력값 검증 스키마 |
|
||||
| server/utils/admin-tag-input.js | 관리자 태그 입력값 검증 스키마 |
|
||||
| server/utils/site-settings.js | 사이트 설정 기본값 유틸리티 |
|
||||
| server/utils/navigation-items.js | 네비게이션 기본값과 그룹 유틸리티 |
|
||||
| server/utils/media-library.js | 업로드 미디어 파일과 폴더 메타데이터 관리 유틸리티 |
|
||||
| server/repositories/postgres-client.js | PostgreSQL 클라이언트 |
|
||||
| server/repositories/content-repository.js | 콘텐츠 조회 저장소 |
|
||||
|
||||
## 데이터베이스
|
||||
|
||||
| 파일 | 기능 |
|
||||
|------|------|
|
||||
| db/migrations/001_initial_schema.sql | PostgreSQL 초기 테이블 스키마 |
|
||||
| db/migrations/002_seed_development.sql | 개발용 샘플 데이터 |
|
||||
| db/migrations/003_add_tag_display_fields.sql | 태그 표시 순서와 색상 필드 추가 |
|
||||
| db/migrations/004_add_site_settings.sql | 사이트 설정 테이블 추가 |
|
||||
| db/migrations/005_add_navigation_items.sql | 네비게이션 항목 테이블 추가 |
|
||||
| db/migrations/006_add_media_metadata.sql | 미디어 메타데이터 테이블 추가 |
|
||||
| db/migrations/007_add_media_folders.sql | 미디어 폴더 테이블 추가 |
|
||||
| db/migrations/008_add_post_seo_fields.sql | 게시물 SEO 필드 추가 |
|
||||
| db/migrations/009_add_post_og_image.sql | 게시물 OG 이미지 필드 추가 |
|
||||
|
||||
## 설정/배포
|
||||
|
||||
| 파일 | 기능 |
|
||||
|------|------|
|
||||
| package.json | Nuxt 실행 스크립트와 의존성 |
|
||||
| nuxt.config.js | Nuxt 앱 설정 |
|
||||
| tailwind.config.js | Tailwind 테마 설정 |
|
||||
| assets/css/main.css | 전역 스타일 |
|
||||
| composables/useMenuState.js | 좌측 메뉴 열림 상태 관리 |
|
||||
| middleware/admin-auth.global.js | 관리자 페이지 접근 인증 |
|
||||
| scripts/dev-server.js | 로컬 개발 서버 링크 요약 출력 |
|
||||
| scripts/migrate-development-db.js | 로컬 개발 DB 마이그레이션 실행 |
|
||||
| .env.example | 환경 변수 예시 |
|
||||
| Dockerfile | NAS 운영 이미지 빌드 |
|
||||
| docker-compose.yml | NAS 컨테이너 실행 초안 |
|
||||
|
||||
257
docs/spec.md
257
docs/spec.md
@@ -6,7 +6,7 @@
|
||||
- **유형**: 커스텀 블로그/CMS
|
||||
- **목표**: 개인 블로그 중심 운영, 기존 포털성 링크와 서비스 진입점은 블로그 내부 구조에 통합
|
||||
- **참조**: Ghost(관리자 UX/글쓰기), Thred 테마(사용자 화면)
|
||||
- **현재 상태**: 코드 스캐폴딩 전 문서 기준점
|
||||
- **현재 상태**: Nuxt 3 초기 스캐폴딩과 PostgreSQL 저장소 계층 구성 완료
|
||||
- **원격 저장소**: https://git.sori.studio/zenn/sori.studio.git
|
||||
|
||||
---
|
||||
@@ -18,9 +18,23 @@
|
||||
| 요소 | 크기/속성 |
|
||||
|------|-----------|
|
||||
| Header | 높이 57px |
|
||||
| Left Aside | 너비 287px, 패딩 12px 12px 12px 0 |
|
||||
| Left Aside | 너비 287px, 최소 높이 calc(100vh - 57px), 패딩 12px 12px 12px 0 |
|
||||
| Main | 너비 720px, 패딩 32px 24px (헤더), 16px 24px (섹션) |
|
||||
| Right Aside | 너비 287px, 패딩 20px 0 20px 20px |
|
||||
| Right Aside | 너비 287px, 최소 높이 calc(100vh - 57px), 패딩 20px 0 20px 20px |
|
||||
|
||||
### 메뉴 토글
|
||||
|
||||
- 헤더 좌측 아이콘은 브랜드 마크가 아니라 왼쪽 사이드바 열기/닫기 버튼
|
||||
- 메뉴 상태는 Nuxt/Vue 상태로 관리
|
||||
- 브라우저에서는 `localStorage.MENU_STATE`에 `open` 또는 `closed` 저장
|
||||
- 닫힘 상태에서는 왼쪽 사이드바를 숨기고 중앙/오른쪽 컬럼만 표시
|
||||
|
||||
### 공개 화면 색상
|
||||
|
||||
- 라이트/다크 모드는 CSS 변수로 관리
|
||||
- 기본 배경, 패널, 라인, 텍스트, 보조 텍스트, 입력, 강조 버튼 색상을 분리
|
||||
- 시스템 다크 모드는 `prefers-color-scheme: dark` 기준으로 우선 대응
|
||||
- Thred 참고 화면처럼 사이드바와 본문은 같은 화면 안에서 구분되는 패널과 라인으로 표현
|
||||
|
||||
### Post 페이지
|
||||
|
||||
@@ -34,6 +48,14 @@
|
||||
- 헤더와 사이드바를 사용하지 않고 본문 중심 전체 화면으로 표시
|
||||
- 진입 경로는 추후 메뉴/링크 설정을 통해 연결
|
||||
|
||||
### 공개 URL 구조
|
||||
|
||||
- `/posts` - 게시물 전체 목록
|
||||
- `/post/:slug` - 개별 게시물 상세
|
||||
- `/tags` - 태그 전체 목록
|
||||
- `/tag/:slug` - 태그별 게시물 목록
|
||||
- 기존 `/posts/:slug`, `/tags/:slug` 상세 경로는 새 단수형 상세 경로로 리다이렉트한다.
|
||||
|
||||
### 레이아웃 파일
|
||||
|
||||
```
|
||||
@@ -85,6 +107,7 @@ components/content/
|
||||
|
||||
### 환경 분리 원칙
|
||||
|
||||
- 데이터베이스는 PostgreSQL을 기준으로 한다.
|
||||
- 로컬 개발 환경과 NAS 운영 환경은 서로 다른 DB를 사용
|
||||
- 로컬 개발 서버는 개발 DB만 연결
|
||||
- NAS 배포 환경은 운영 DB만 연결
|
||||
@@ -100,7 +123,12 @@ components/content/
|
||||
| slug | String | URL 슬러그 |
|
||||
| content | Text | 마크다운 콘텐츠 |
|
||||
| excerpt | String | 요약 |
|
||||
| featured_image | String | 대표 이미지 |
|
||||
| featured_image | String nullable | 대표 이미지 |
|
||||
| seo_title | String | SEO 제목 |
|
||||
| seo_description | String | SEO 설명 |
|
||||
| canonical_url | String | canonical URL |
|
||||
| noindex | Boolean | 검색엔진 노출 제외 여부 |
|
||||
| og_image | String nullable | OG 이미지 |
|
||||
| status | Enum | published/draft/private |
|
||||
| published_at | DateTime | 발행일 |
|
||||
| created_at | DateTime | 생성일 |
|
||||
@@ -114,7 +142,7 @@ components/content/
|
||||
| title | String | 제목 |
|
||||
| slug | String | URL 슬러그 |
|
||||
| content | Text | 마크다운 콘텐츠 |
|
||||
| featured_image | String | 대표 이미지 |
|
||||
| featured_image | String nullable | 대표 이미지 |
|
||||
| created_at | DateTime | 생성일 |
|
||||
| updated_at | DateTime | 수정일 |
|
||||
|
||||
@@ -126,6 +154,52 @@ components/content/
|
||||
| name | String | 태그명 |
|
||||
| slug | String | URL 슬러그 |
|
||||
| description | String | 설명 |
|
||||
| sort_order | Integer | 사용자 화면 표시 순서 |
|
||||
| color | String | 태그 색상 코드 |
|
||||
| created_at | DateTime | 생성일 |
|
||||
| updated_at | DateTime | 수정일 |
|
||||
|
||||
### SiteSettings
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | Integer | 단일 설정 레코드 ID, 항상 1 |
|
||||
| title | String | 사이트 이름 |
|
||||
| description | String | 사이트 설명 |
|
||||
| site_url | String | 사이트 기본 URL |
|
||||
| logo_text | String | 텍스트 로고 |
|
||||
| copyright_text | String | 저작권 문구 |
|
||||
| updated_at | DateTime | 수정일 |
|
||||
|
||||
### NavigationItems
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | UUID | Primary Key |
|
||||
| label | String | 메뉴 표시 이름 |
|
||||
| url | String | 내부 경로 또는 외부 URL |
|
||||
| location | Enum | primary/footer |
|
||||
| sort_order | Integer | 표시 순서 |
|
||||
| is_visible | Boolean | 공개 화면 표시 여부 |
|
||||
| created_at | DateTime | 생성일 |
|
||||
| updated_at | DateTime | 수정일 |
|
||||
|
||||
### MediaMetadata
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| url | String | 업로드 미디어 URL |
|
||||
| category | String | 관리자 미디어 폴더 경로 |
|
||||
| created_at | DateTime | 생성일 |
|
||||
| updated_at | DateTime | 수정일 |
|
||||
|
||||
### MediaFolders
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| path | String | 미디어 폴더 경로 |
|
||||
| created_at | DateTime | 생성일 |
|
||||
| updated_at | DateTime | 수정일 |
|
||||
|
||||
### PostTags (다대다)
|
||||
|
||||
@@ -133,12 +207,23 @@ components/content/
|
||||
|------|------|------|
|
||||
| post_id | UUID | FK → Posts |
|
||||
| tag_id | UUID | FK → Tags |
|
||||
| created_at | DateTime | 생성일 |
|
||||
|
||||
---
|
||||
|
||||
## API 구조
|
||||
|
||||
> 아직 구현 전 설계안이다. 실제 구현 시 응답 구조와 엔드포인트가 바뀌면 이 문서를 먼저 갱신한다.
|
||||
> 현재 API는 Nuxt `server/api` 내부에 샘플 데이터 기반으로 구현되어 있다. DB 연결 후 같은 응답 구조를 유지하되 저장소만 교체한다.
|
||||
|
||||
### 백엔드 구성
|
||||
|
||||
- 별도 `backend/` 앱을 두지 않고 Nuxt/Nitro 서버 기능을 사용
|
||||
- 공개 API는 `server/api`에 작성
|
||||
- 서버 공통 스키마와 샘플 데이터는 `server/utils`에 작성
|
||||
- PostgreSQL 연결과 조회 로직은 `server/repositories`에 작성
|
||||
- `DATABASE_URL`이 없으면 샘플 데이터 저장소를 사용
|
||||
- 초기 단계에서는 같은 앱 배포로 관리 비용을 낮춤
|
||||
- 독립 API 서버가 필요해지는 시점에만 백엔드 분리를 재검토
|
||||
|
||||
### 공개 API (`/api/`)
|
||||
|
||||
@@ -147,18 +232,121 @@ components/content/
|
||||
- `GET /api/pages` - 고정 페이지 목록
|
||||
- `GET /api/pages/:slug` - 고정 페이지 상세
|
||||
- `GET /api/tags` - 태그 목록
|
||||
- `GET /api/site-settings` - 공개 사이트 설정
|
||||
- `GET /api/navigation` - 공개 네비게이션
|
||||
|
||||
### 관리자 API (`/admin/api/`)
|
||||
|
||||
- `POST /admin/api/auth/login` - 로그인
|
||||
- `POST /admin/api/auth/logout` - 로그아웃
|
||||
- `GET /admin/api/auth/me` - 현재 관리자 세션 조회
|
||||
- `GET /admin/api/posts` - 글 목록
|
||||
- `POST /admin/api/posts` - 글 작성
|
||||
- `GET /admin/api/posts/:id` - 글 상세
|
||||
- `PUT /admin/api/posts/:id` - 글 수정
|
||||
- `DELETE /admin/api/posts/:id` - 글 삭제
|
||||
- `POST /admin/api/posts/:id/publish` - 글 발행
|
||||
- `GET /admin/api/pages` - 고정 페이지 목록
|
||||
- `POST /admin/api/pages` - 고정 페이지 작성
|
||||
- `GET /admin/api/pages/:id` - 고정 페이지 상세
|
||||
- `PUT /admin/api/pages/:id` - 고정 페이지 수정
|
||||
- `DELETE /admin/api/pages/:id` - 고정 페이지 삭제
|
||||
- `GET /admin/api/media` - 업로드 미디어 목록
|
||||
- `PUT /admin/api/media` - 업로드 미디어 파일명 변경 또는 단일/복수 미디어 폴더 변경
|
||||
- `DELETE /admin/api/media` - 업로드 미디어 삭제
|
||||
- `GET /admin/api/media-folders` - 미디어 폴더 목록
|
||||
- `POST /admin/api/media-folders` - 미디어 폴더 생성
|
||||
- `POST /admin/api/uploads` - 관리자 이미지 업로드
|
||||
- `GET /admin/api/tags` - 태그 목록
|
||||
- `POST /admin/api/tags` - 태그 생성
|
||||
- `GET /admin/api/tags/:id` - 태그 상세
|
||||
- `PUT /admin/api/tags/:id` - 태그 수정
|
||||
- `DELETE /admin/api/tags/:id` - 태그 삭제
|
||||
- `GET /admin/api/settings` - 사이트 설정 조회
|
||||
- `PUT /admin/api/settings` - 사이트 설정 수정
|
||||
- `GET /admin/api/navigation` - 네비게이션 항목 목록
|
||||
- `PUT /admin/api/navigation` - 네비게이션 항목 일괄 저장
|
||||
|
||||
> 글 발행/초안/비공개 전환은 현재 `PUT /admin/api/posts/:id`의 `status` 값으로 처리한다.
|
||||
> 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다.
|
||||
> 태그 목록은 `sort_order ASC, name ASC` 기준으로 정렬한다.
|
||||
> 태그 `color`는 `#RRGGBB` 형식이며 사용자 화면 태그 색상 표시와 배지 배경색에 사용한다.
|
||||
|
||||
### 관리자 글 편집
|
||||
|
||||
- 글 작성/수정 화면은 Ghost 스타일을 참고한 블록형 에디터를 사용한다.
|
||||
- 저장 데이터는 기존 `content` 필드의 마크다운 문자열을 유지한다.
|
||||
- `/` 입력 시 블록 선택 메뉴를 표시한다.
|
||||
- `/` 명령 메뉴는 화면 하단 공간이 부족하면 현재 블록 위쪽으로 표시한다.
|
||||
- `/` 명령 메뉴가 열린 상태에서 Enter를 누르면 현재 강조된 메뉴 항목을 적용한다.
|
||||
- `/` 명령 메뉴가 열린 상태에서 위/아래 방향키로 강조 항목을 이동한다.
|
||||
- 블록 메뉴는 문단, 제목 2, 제목 3, 이미지, 갤러리, 콜아웃, 토글, 임베드, 인용, 목록, 코드, 구분선을 제공한다.
|
||||
- `#`, `##`, `###`, `>`, `-` 입력 후 공백을 누르면 현재 블록 타입을 즉시 변환한다.
|
||||
- 한글 등 조합형 입력 중에는 단축 문법과 슬래시 메뉴 상태를 확정하지 않고 조합 종료 뒤 반영한다.
|
||||
- 한글 등 조합형 입력 직후 Enter는 새 블록 생성으로 처리하지 않는다.
|
||||
- 빈 문단에서 Enter를 누르면 다음 빈 문단 블록을 생성한다.
|
||||
- 빈 블록 placeholder는 현재 활성 블록 또는 첫 빈 블록에만 표시한다.
|
||||
- 에디터 마지막에는 클릭 가능한 빈 문단 블록을 항상 유지하며, 해당 블록이 비어 있으면 저장 콘텐츠에는 포함하지 않는다.
|
||||
- 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다.
|
||||
- 제목 입력에서 Enter를 누르면 본문 첫 블록으로 포커스를 이동한다.
|
||||
- 새 글 작성처럼 본문이 비어 있는 경우에도 빈 문단 블록을 먼저 생성한다.
|
||||
- 글 작성/수정 중인 입력값은 브라우저 `localStorage`에 자동 저장한다.
|
||||
- 자동 저장 키는 `SORI_ADMIN_POST_AUTOSAVE:new` 또는 `SORI_ADMIN_POST_AUTOSAVE:{postId}` 형식을 사용한다.
|
||||
- 자동 저장본이 있으면 작성 화면에서 복원 또는 삭제를 선택할 수 있다.
|
||||
- 글 저장 성공 시 해당 자동 저장본은 삭제한다.
|
||||
- 미리보기 버튼은 현재 작성 폼 값을 `SORI_ADMIN_POST_PREVIEW` 브라우저 저장소에 기록한 뒤 `/admin/posts/preview` 새 탭에서 렌더링한다.
|
||||
- 미리보기 화면은 공개 게시물 상세와 같은 본문 렌더링 컴포넌트를 사용하되, 데이터베이스에는 임시 글을 저장하지 않는다.
|
||||
- 글 저장/수정/삭제 진행과 성공/실패 상태는 화면 우측 상단 토스트로 표시한다.
|
||||
- 발행 상태에서 발행 시각을 미래로 지정하면 예약 발행으로 저장한다.
|
||||
- 예약 발행 글은 관리자 목록에서 예약 상태로 표시하되 공개 게시물 목록과 상세 API에는 발행 시각 이후부터 노출한다.
|
||||
- 관리자 글 에디터의 실제 입력 텍스트 색상은 placeholder보다 진하게 표시한다.
|
||||
- 관리자 작성 화면과 공개 본문은 같은 마크다운 렌더링 기준을 사용한다.
|
||||
- 대표 이미지는 URL 직접 입력이 아니라 미디어 선택 또는 새 이미지 업로드로 설정한다.
|
||||
- 대표 이미지가 설정되면 관리자 글 폼에 썸네일과 삭제/변경 액션을 표시한다.
|
||||
- 글 SEO 설정은 SEO 제목, SEO 설명, canonical URL, 검색엔진 노출 제외 여부를 저장한다.
|
||||
- 공개 게시물 상세 화면은 SEO 제목이 없으면 글 제목, SEO 설명이 없으면 요약을 메타 태그 기본값으로 사용한다.
|
||||
- 검색엔진 노출 제외가 켜진 글은 robots 메타를 `noindex, nofollow`로 출력한다.
|
||||
- 글 OG 이미지는 미디어 선택 또는 새 이미지 업로드로 설정하며, 공개 상세 화면의 `og:image`와 Twitter large image 카드에 사용한다.
|
||||
- OG 이미지가 없으면 대표 이미지를 `og:image` fallback으로 사용한다.
|
||||
- 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `{width=wide}` 형식으로 저장한다.
|
||||
- 이미지/갤러리 삽입 시 파일명은 alt 값으로 자동 입력하지 않는다.
|
||||
- 이미지 블록 alt 입력은 이미지 hover 또는 focus 상태에서만 표시한다.
|
||||
- 이미지 블록 표시 옵션은 `regular`, `wide`, `full` 값을 사용하며 `regular`는 width 옵션을 생략한다.
|
||||
- 갤러리 블록은 `:::gallery` fenced block 안에 이미지 마크다운 행을 여러 개 저장한다.
|
||||
- 공개 갤러리는 한 줄에 2~3개 이미지 그리드로 표시하고 클릭 시 라이트박스로 크게 확인한다.
|
||||
- 이미지와 갤러리 블록은 기존 업로드 미디어 선택 또는 새 이미지 업로드를 제공한다.
|
||||
- 콜아웃 블록은 `:::callout` fenced block 안에 본문을 저장한다.
|
||||
- 토글 블록은 `:::toggle 제목` fenced block 안에 펼침 본문을 저장한다.
|
||||
- 임베드 블록은 `:::embed` fenced block 안에 URL을 저장한다.
|
||||
- YouTube 임베드 URL은 공개 화면에서 iframe으로 렌더링하고, 그 외 URL은 외부 링크로 표시한다.
|
||||
|
||||
### 관리자 페이지 편집
|
||||
|
||||
- 고정 페이지 작성/수정 화면은 게시물과 같은 블록형 에디터를 사용한다.
|
||||
- 고정 페이지는 제목, 슬러그, 본문, 대표 이미지만 저장한다.
|
||||
- 고정 페이지는 게시물 목록과 태그 목록에 노출하지 않는다.
|
||||
- 고정 페이지 공개 보기 경로는 `/pages/:slug`를 사용한다.
|
||||
|
||||
### 사이트 설정
|
||||
|
||||
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
|
||||
- 관리자는 사이트 이름, 설명, 사이트 URL, 텍스트 로고, 저작권 문구를 수정할 수 있다.
|
||||
- 공개 헤더와 오른쪽 사이드바는 공개 사이트 설정 API 값을 사용한다.
|
||||
- DB 연결이 없는 환경에서는 환경 변수와 기본값 기반 설정을 사용한다.
|
||||
|
||||
### 메뉴/네비게이션
|
||||
|
||||
- 네비게이션은 `navigation_items` 테이블로 관리한다.
|
||||
- 관리자는 메뉴 라벨, URL, 위치, 순서, 표시 여부를 수정할 수 있다.
|
||||
- 공개 왼쪽 사이드바의 상단 메뉴는 `primary` 위치 항목을 사용한다.
|
||||
- 공개 왼쪽 사이드바 하단 메뉴는 `footer` 위치 항목을 사용한다.
|
||||
- URL은 `/`로 시작하는 내부 경로 또는 `http://`, `https://` 외부 URL을 허용한다.
|
||||
|
||||
### 관리자 인증
|
||||
|
||||
- 초기 관리자 인증은 `ADMIN_EMAIL`, `ADMIN_PASSWORD` 환경 변수를 사용
|
||||
- 로그인 성공 시 httpOnly 세션 쿠키를 `/admin` 경로에 설정
|
||||
- 관리자 페이지 접근은 `/admin/api/auth/me` 확인 후 허용
|
||||
- 세션 토큰은 `ADMIN_PASSWORD` 기반 HMAC 서명으로 검증
|
||||
|
||||
---
|
||||
|
||||
@@ -173,6 +361,20 @@ components/content/
|
||||
/uploads/system/favicon.png
|
||||
```
|
||||
|
||||
- 관리자 이미지 업로드 API는 `image/jpeg`, `image/png`, `image/webp`, `image/gif`만 허용한다.
|
||||
- 업로드 파일 크기 제한은 `MAX_FILE_SIZE` 환경 변수를 따른다.
|
||||
- 로컬 개발 업로드 파일은 `public/uploads/posts/YYYY/MM/` 아래 저장하고 `/uploads/posts/YYYY/MM/filename` URL로 제공한다.
|
||||
- 관리자 미디어 화면은 왼쪽 폴더 트리와 오른쪽 고밀도 썸네일 갤러리, 검색, 파일명 변경, 개별 삭제를 제공한다.
|
||||
- 관리자는 폴더 트리에서 새 폴더와 하위 폴더를 만들 수 있다.
|
||||
- 미디어는 Ctrl/Command 클릭으로 복수 선택하고 Shift 클릭으로 범위 선택할 수 있다.
|
||||
- 선택한 미디어를 폴더로 드래그하면 해당 미디어들의 폴더 경로가 일괄 변경된다.
|
||||
- 미디어 파일 경로, 사용 현황, 용량 등 세부 정보는 썸네일 더블 클릭 시 열리는 상세 모달에서 표시한다.
|
||||
- 미디어 폴더는 실제 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별 경로 메타데이터로 저장한다.
|
||||
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
|
||||
- 미디어 사용 현황은 게시물/페이지의 대표 이미지, 게시물 OG 이미지, 본문 내 URL을 기준으로 표시한다.
|
||||
- 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다.
|
||||
- 향후 미디어 라이브러리는 프로필/사이트 설정 이미지 사용처 추적을 제공한다.
|
||||
|
||||
---
|
||||
|
||||
## 환경 변수 (.env)
|
||||
@@ -181,34 +383,55 @@ components/content/
|
||||
|
||||
```env
|
||||
# Database
|
||||
DATABASE_URL=
|
||||
DATABASE_NAME=
|
||||
DATABASE_URL=postgres://sori_studio:replace-with-random-password@sori-studio-db:5432/sori_studio
|
||||
DATABASE_NAME=sori_studio
|
||||
POSTGRES_DB=sori_studio
|
||||
POSTGRES_USER=sori_studio
|
||||
POSTGRES_PASSWORD=replace-with-random-password
|
||||
DB_PORT=43119
|
||||
|
||||
# Auth
|
||||
ADMIN_EMAIL=
|
||||
ADMIN_PASSWORD=
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=replace-with-random-password
|
||||
|
||||
# Upload
|
||||
UPLOAD_DIR=/uploads
|
||||
MAX_FILE_SIZE=10485760
|
||||
|
||||
# Site
|
||||
SITE_URL=https://sori.studio
|
||||
SITE_TITLE=sori.studio
|
||||
NUXT_PUBLIC_SITE_URL=https://sori.studio
|
||||
NUXT_PUBLIC_SITE_TITLE=sori.studio
|
||||
|
||||
# Server
|
||||
APP_PORT=43118
|
||||
```
|
||||
|
||||
### 환경 파일 기준
|
||||
|
||||
| 파일 | 용도 | DB |
|
||||
|------|------|----|
|
||||
| `.env.development` | 로컬 개발 | 개발 DB |
|
||||
| `.env.production` | NAS 운영 | 운영 DB |
|
||||
| `.env.example` | 공유 예시 | 실제 접속 정보 없음 |
|
||||
| `.env.development` | 로컬 개발, Git 제외 | 개발 DB |
|
||||
| `.env.production` | NAS 운영, Git 제외 | 운영 DB |
|
||||
| `.env.example` | 공유 예시, Git 포함 | 실제 접속 정보 없음 |
|
||||
|
||||
- `.env.example`에는 실제 이메일, 비밀번호, 토큰, 운영 서버 주소를 기록하지 않음
|
||||
- `.env.development`와 `.env.production`의 DB 비밀번호와 관리자 비밀번호는 서로 다른 랜덤 값을 사용
|
||||
- 로컬 개발 `DATABASE_URL`은 호스트 기준 `127.0.0.1:43119`를 사용
|
||||
- NAS Docker 내부 `DATABASE_URL`은 서비스명 기준 `sori-studio-db:5432`를 사용
|
||||
|
||||
### 포트 기준
|
||||
|
||||
| 용도 | 포트 |
|
||||
|------|------|
|
||||
| 로컬 개발 서버 | 43117 |
|
||||
| NAS Docker 외부 포트 | 43118 |
|
||||
| 컨테이너 내부 포트 | 3000 |
|
||||
| PostgreSQL 외부 포트 | 43119 |
|
||||
|
||||
---
|
||||
|
||||
## 버전 관리
|
||||
|
||||
- 현재 버전: v0.0.1
|
||||
- 현재 버전: v0.0.13
|
||||
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
|
||||
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정
|
||||
|
||||
85
docs/todo.md
85
docs/todo.md
@@ -2,68 +2,57 @@
|
||||
|
||||
## 1차 관리자 개발
|
||||
|
||||
- [ ] 로그인 기능 구현
|
||||
- [ ] 글 목록 조회
|
||||
- [ ] 글 작성/수정 (마크다운 에디터)
|
||||
- [ ] 글 발행/비공개 전환
|
||||
- [ ] 태그 관리 (생성/수정/삭제)
|
||||
- [ ] 이미지 업로드
|
||||
- [ ] 블록 에디터 브라우저 수동 QA: 빈 줄 Enter, `/` 메뉴 필터, 방향키, Enter 선택, 한글 조합 입력 확인
|
||||
- [ ] 공개 URL 수동 QA: `/posts`, `/post/:slug`, `/tags`, `/tag/:slug`, 기존 복수형 상세 리다이렉트 확인
|
||||
- [ ] 블록 에디터 저장/수정 왕복 QA: 기존 글 수정 시 블록 파싱, 저장 후 다시 열기 확인
|
||||
- [ ] 이미지/갤러리 블록 브라우저 수동 QA: 업로드, 너비 옵션, 저장 후 공개 렌더링, 갤러리 라이트박스 확인
|
||||
- [ ] 미디어 선택 창 브라우저 수동 QA: 기존 이미지 선택, 갤러리 추가, 빈 미디어 상태 확인
|
||||
- [ ] 대표 이미지 브라우저 수동 QA: 기존 미디어 선택, 새 업로드, 썸네일 표시, 삭제/변경 확인
|
||||
- [ ] 미디어 라이브러리 상세 모달 수동 QA: 검색, 사용 현황, 파일명 변경, 삭제 잠금 확인
|
||||
- [ ] 콜아웃, 토글, 임베드 블록 브라우저 수동 QA: `/` 메뉴 선택, 저장/수정 왕복, 공개 렌더링 확인
|
||||
- [ ] 게시물 미리보기 브라우저 수동 QA: 새 글/수정 글에서 저장 전 미리보기 탭 열림, 제목/본문 렌더링 확인
|
||||
|
||||
## 2차 관리자 개발
|
||||
|
||||
- [ ] 페이지 관리 (CRUD)
|
||||
- [ ] 사이트 설정
|
||||
- [ ] 메뉴/네비게이션 관리
|
||||
- [ ] 미디어 라이브러리
|
||||
|
||||
## 3차 관리자 개발
|
||||
|
||||
- [ ] 예약 발행
|
||||
- [ ] 초안 자동 저장
|
||||
- [ ] SEO 설정
|
||||
- [ ] OG 이미지 설정
|
||||
- [ ] 글 미리보기
|
||||
- [ ] 페이지 관리 브라우저 수동 QA: 생성, 수정, 삭제, 공개 보기 확인
|
||||
- [ ] 사이트 설정 브라우저 수동 QA: 저장, 공개 헤더/사이드바 반영, 잘못된 URL 실패 확인
|
||||
- [ ] 메뉴/네비게이션 브라우저 수동 QA: 항목 추가, 삭제, 숨김, 정렬, 공개 사이드바 반영 확인
|
||||
- [ ] 미디어 라이브러리 폴더 브라우저 수동 QA: 폴더 생성, 하위 폴더 표시, Ctrl/Command 복수 선택, Shift 범위 선택, 드래그 일괄 이동, 상세 모달 폴더 변경 확인
|
||||
- [ ] 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적
|
||||
|
||||
## 프론트엔드 개발
|
||||
|
||||
- [ ] SiteHeader.vue 구현 (57px 높이)
|
||||
- [ ] LeftSidebar.vue 구현 (287px, 패딩 12px 12px 12px 0)
|
||||
- [ ] RightSidebar.vue 구현 (287px, 패딩 20px 0 20px 20px)
|
||||
- [ ] MainColumn.vue 구현 (720px)
|
||||
- [ ] PostCard.vue 구현
|
||||
- [ ] TagHeader.vue 구현
|
||||
- [ ] 공개 화면 모바일 사이드바/네비게이션 방식 결정
|
||||
- [ ] Thred 참고 화면 기준 시각 QA
|
||||
- [ ] 사이드바 토글 애니메이션 세부 조정
|
||||
|
||||
## 콘텐츠 스타일 구현
|
||||
|
||||
- [ ] ProseHeading (h1~h6)
|
||||
- [ ] ProseList (Ordered/Unordered)
|
||||
- [ ] ProseBlockquote
|
||||
- [ ] ProseImage (Regular/Wide/Full-width)
|
||||
- [ ] ProseButton (Left-aligned/Centered)
|
||||
- [ ] ProseCallout
|
||||
- [ ] ProseToggle
|
||||
- [ ] ProseVideo
|
||||
- [ ] ProseAudio
|
||||
- [ ] ProseFile
|
||||
- [ ] ProseProduct
|
||||
- [ ] ProseHeaderCard (Simple/Wide/Full-width/Split)
|
||||
- [ ] ProseEmbed (YouTube, Twitter)
|
||||
- [ ] ProseHeading 실제 스타일 세부 조정
|
||||
- [ ] ProseList 실제 스타일 세부 조정
|
||||
- [ ] ProseBlockquote 실제 스타일 세부 조정
|
||||
- [ ] ProseImage Regular/Wide/Full-width 동작 검증
|
||||
- [ ] ProseImage Wide/Full-width 화면 이탈과 스킨별 최대 폭 기준 재정리
|
||||
- [ ] ProseButton Left/Center 정렬 검증
|
||||
- [ ] ProseCallout 실제 스타일 세부 조정
|
||||
- [ ] ProseToggle 실제 스타일 세부 조정
|
||||
- [ ] ProseVideo 실제 임베드 렌더링 연결
|
||||
- [ ] ProseAudio 실제 오디오 렌더링 연결
|
||||
- [ ] ProseFile 실제 파일 데이터 연결
|
||||
- [ ] ProseProduct 실제 상품 카드 데이터 연결
|
||||
- [ ] ProseHeaderCard Simple/Wide/Full-width/Split 변형 구현
|
||||
- [ ] ProseEmbed Twitter 실제 렌더링 연결
|
||||
|
||||
## 데이터베이스
|
||||
|
||||
- [ ] Posts 테이블 설계
|
||||
- [ ] Pages 테이블 설계
|
||||
- [ ] Tags 테이블 설계
|
||||
- [ ] PostTags 테이블 설계
|
||||
- [ ] 로컬 개발 DB 연결 설정 작성
|
||||
- [ ] NAS 운영 DB 연결 설정 작성
|
||||
- [ ] NAS 운영 DB 연결 설정 실제 값 작성
|
||||
- [ ] 개발 DB와 운영 DB 분리 검증 절차 작성
|
||||
- [ ] CloudBeaver 등 DB 관리 도구 연결 방식 결정
|
||||
- [ ] CloudBeaver PostgreSQL 연결 방식 확정
|
||||
- [ ] 이전에 원격에 올라간 관리자 비밀번호가 실제 사용 값이면 즉시 폐기 및 변경
|
||||
|
||||
## 배포
|
||||
|
||||
- [ ] UGREEN NAS Docker 배포 가이드 작성
|
||||
- [ ] 로컬 개발 환경 가이드 작성
|
||||
- [ ] Dockerfile 작성
|
||||
- [ ] docker-compose.yml 작성
|
||||
- [ ] .env.example 작성
|
||||
- [ ] Docker 빌드 검증
|
||||
- [ ] `.env.production` 작성 후 `docker compose --env-file .env.production config` 검증
|
||||
- [ ] NAS 운영 환경 변수 작성
|
||||
|
||||
297
docs/update.md
297
docs/update.md
@@ -1,5 +1,302 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v0.0.31
|
||||
|
||||
- 관리자 글 작성/수정 폼에 미리보기 버튼 추가.
|
||||
- 저장 전 게시물 입력값을 브라우저 저장소에 담아 새 미리보기 탭으로 전달하도록 추가.
|
||||
- 관리자 게시물 미리보기 화면 추가.
|
||||
- 미리보기 화면이 공개 게시물 본문 렌더러와 같은 컴포넌트를 사용하도록 연결.
|
||||
- 패키지 버전을 0.0.31로 갱신.
|
||||
|
||||
## v0.0.30
|
||||
|
||||
- 게시물 OG 이미지 필드 추가.
|
||||
- 관리자 글 작성/수정 폼에 OG 이미지 선택, 업로드, 변경, 삭제 기능 추가.
|
||||
- 공개 게시물 상세 화면에 OG 이미지와 Twitter large image 카드 메타 연결.
|
||||
- 미디어 라이브러리 사용 현황에 게시물 OG 이미지 사용처 표시 추가.
|
||||
- 패키지 버전을 0.0.30으로 갱신.
|
||||
|
||||
## v0.0.29
|
||||
|
||||
- 게시물 SEO 메타데이터 컬럼 추가.
|
||||
- 관리자 글 작성/수정 폼에 SEO 제목, SEO 설명, canonical URL, 검색엔진 노출 제외 설정 추가.
|
||||
- 관리자 게시물 생성/수정 API가 SEO 값을 저장하도록 수정.
|
||||
- 공개 게시물 상세 화면에 SEO 제목, description, canonical, robots, 기본 OG 메타 연결.
|
||||
- 패키지 버전을 0.0.29로 갱신.
|
||||
|
||||
## v0.0.28
|
||||
|
||||
- 관리자 글 작성/수정 폼에 발행 시각 입력 기능 추가.
|
||||
- 발행 상태에서 미래 발행 시각을 저장하면 예약 발행으로 처리하도록 추가.
|
||||
- 공개 게시물 목록과 상세 API가 미래 발행 글을 노출하지 않도록 수정.
|
||||
- 관리자 글 목록에서 발행/예약/초안/비공개 상태 표시를 구분하도록 수정.
|
||||
- 예약 발행 글은 공개 보기 버튼을 숨기도록 수정.
|
||||
- 패키지 버전을 0.0.28로 갱신.
|
||||
|
||||
## v0.0.27
|
||||
|
||||
- 미디어 폴더 테이블 추가.
|
||||
- 관리자 미디어 폴더 목록/생성 API 추가.
|
||||
- 관리자 미디어 화면을 왼쪽 폴더 트리와 오른쪽 썸네일 갤러리 구조로 수정.
|
||||
- 미디어 Ctrl/Command 클릭 및 Shift 클릭 복수 선택 기능 추가.
|
||||
- 선택 미디어를 폴더로 드래그해 일괄 이동하는 기능 추가.
|
||||
- 미디어 폴더 이동은 실제 파일 경로가 아닌 메타데이터 경로를 갱신하도록 유지.
|
||||
- 패키지 버전을 0.0.27로 갱신.
|
||||
|
||||
## v0.0.26
|
||||
|
||||
- 미디어 메타데이터 테이블 추가.
|
||||
- 미디어 URL별 카테고리 저장 기능 추가.
|
||||
- 관리자 미디어 목록에 카테고리 필터 추가.
|
||||
- 관리자 미디어 상세 모달에 카테고리 수정 기능 추가.
|
||||
- 미디어 파일명 변경/삭제 시 메타데이터도 함께 갱신하도록 수정.
|
||||
- 패키지 버전을 0.0.26으로 갱신.
|
||||
|
||||
## v0.0.25
|
||||
|
||||
- 관리자 블록 에디터에서 빈 문단 placeholder를 마지막 보조 입력 블록에만 표시하도록 수정.
|
||||
- 네비게이션 항목 데이터베이스 테이블 추가.
|
||||
- 공개 네비게이션 조회 API 추가.
|
||||
- 관리자 네비게이션 조회/일괄 저장 API 추가.
|
||||
- 관리자 메뉴 관리 화면 추가.
|
||||
- 공개 왼쪽 사이드바 상단/하단 메뉴를 네비게이션 API와 연결.
|
||||
- 패키지 버전을 0.0.25로 갱신.
|
||||
|
||||
## v0.0.24
|
||||
|
||||
- 관리자 블록 에디터에서 마지막 빈 문단 Enter 입력 시 연속 빈 줄이 유지되도록 수정.
|
||||
- 사이트 설정 데이터베이스 테이블 추가.
|
||||
- 공개 사이트 설정 조회 API 추가.
|
||||
- 관리자 사이트 설정 조회/수정 API 추가.
|
||||
- 관리자 사이트 설정 화면을 실제 저장 API와 연결.
|
||||
- 공개 헤더와 오른쪽 사이드바에 사이트 설정 값을 연결.
|
||||
- 패키지 버전을 0.0.24로 갱신.
|
||||
|
||||
## v0.0.23
|
||||
|
||||
- 관리자 고정 페이지 목록 화면을 실제 API와 연결.
|
||||
- 관리자 고정 페이지 생성 화면 추가.
|
||||
- 관리자 고정 페이지 수정 화면 추가.
|
||||
- 관리자 고정 페이지 생성/수정/삭제 API 추가.
|
||||
- 고정 페이지 작성 폼에 블록 에디터와 대표 이미지 선택 기능 연결.
|
||||
- 패키지 버전을 0.0.23으로 갱신.
|
||||
|
||||
## v0.0.22
|
||||
|
||||
- 관리자 블록 에디터 마지막에 클릭 가능한 빈 문단 블록을 항상 유지하도록 수정.
|
||||
- 빈 마지막 문단 블록은 저장 콘텐츠에 포함하지 않도록 유지.
|
||||
- 한글 조합 입력 직후 Enter가 새 블록 입력으로 중복 처리되는 문제 보정.
|
||||
- 관리자 글 저장/수정/삭제 진행 상태를 토스트로 표시하도록 추가.
|
||||
- 새 글 저장 후 수정 화면으로 이동해도 저장 성공 토스트를 표시하도록 추가.
|
||||
- 패키지 버전을 0.0.22로 갱신.
|
||||
|
||||
## v0.0.21
|
||||
|
||||
- 관리자 글 작성/수정 폼에 로컬 자동 저장 기능 추가.
|
||||
- 자동 저장본이 있으면 복원 또는 삭제 안내를 표시하도록 추가.
|
||||
- 글 저장 성공 시 해당 자동 저장본을 삭제하도록 연결.
|
||||
- 자동 저장 상태 시간을 글 작성 화면 하단에 표시하도록 추가.
|
||||
- 패키지 버전을 0.0.21로 갱신.
|
||||
|
||||
## v0.0.20
|
||||
|
||||
- 관리자 블록 에디터에 콜아웃 블록 추가.
|
||||
- 관리자 블록 에디터에 토글 블록 추가.
|
||||
- 관리자 블록 에디터에 임베드 블록 추가.
|
||||
- 공개 본문 렌더러에 콜아웃, 토글, 임베드 마크다운 확장 파싱 추가.
|
||||
- YouTube 임베드 URL을 공개 화면에서 iframe으로 렌더링하도록 수정.
|
||||
- 패키지 버전을 0.0.20으로 갱신.
|
||||
|
||||
## v0.0.19
|
||||
|
||||
- 관리자 블록 에디터의 한글 조합 입력 중복 방지 처리 추가.
|
||||
- 이미지/갤러리 삽입 시 파일명을 alt/caption으로 자동 표시하지 않도록 수정.
|
||||
- 이미지 블록 alt 입력을 이미지 hover 또는 focus 상태에서만 표시하도록 수정.
|
||||
- 공개 게시물 카드에 대표 이미지 썸네일 표시 추가.
|
||||
- ProseImage Wide/Full-width 화면 이탈 보정 작업을 할 일에 기록.
|
||||
- 패키지 버전을 0.0.19로 갱신.
|
||||
|
||||
## v0.0.18
|
||||
|
||||
- 새 글 작성 화면에서 빈 본문 블록이 생성되지 않던 문제 수정.
|
||||
- 공개 게시물 목록 경로 `/posts` 추가.
|
||||
- 공개 게시물 상세 경로를 `/post/:slug` 기준으로 추가.
|
||||
- 기존 `/posts/:slug` 상세 경로를 `/post/:slug`로 리다이렉트하도록 수정.
|
||||
- 공개 태그 목록 경로 `/tags` 추가.
|
||||
- 공개 태그 상세 경로를 `/tag/:slug` 기준으로 추가.
|
||||
- 기존 `/tags/:slug` 상세 경로를 `/tag/:slug`로 리다이렉트하도록 수정.
|
||||
- 공개 화면과 관리자 미리보기 링크를 단수형 상세 경로 기준으로 정리.
|
||||
- 패키지 버전을 0.0.18로 갱신.
|
||||
|
||||
## v0.0.17
|
||||
|
||||
- 관리자 글 작성/수정 폼의 대표 이미지 URL 직접 입력을 이미지 선택 UI로 변경.
|
||||
- 대표 이미지 썸네일, 삭제, 변경, 새 업로드 기능 추가.
|
||||
- 대표 이미지를 기존 미디어 라이브러리에서 선택할 수 있도록 추가.
|
||||
- 관리자 미디어 화면을 고밀도 썸네일 갤러리 구조로 변경.
|
||||
- 미디어 경로, 사용 현황, 용량, 파일명 변경, 삭제 정보를 상세 모달로 이동.
|
||||
- 패키지 버전을 0.0.17로 갱신.
|
||||
|
||||
## v0.0.16
|
||||
|
||||
- 관리자 미디어 목록에 게시물/페이지 사용 현황 표시 추가.
|
||||
- 미디어 사용처를 대표 이미지와 본문 위치로 구분해 표시.
|
||||
- 사용 중인 미디어의 파일명 변경과 삭제를 차단하도록 수정.
|
||||
- 미디어 검색 대상에 사용 중인 게시물/페이지 제목 추가.
|
||||
- 패키지 버전을 0.0.16으로 갱신.
|
||||
|
||||
## v0.0.15
|
||||
|
||||
- 관리자 블록 에디터 `/` 메뉴 항목 제목 색상을 진한 본문색으로 수정.
|
||||
- 관리자 미디어 목록 API 추가.
|
||||
- 관리자 미디어 파일명 변경 API 추가.
|
||||
- 관리자 미디어 삭제 API 추가.
|
||||
- 관리자 미디어 관리 화면 추가.
|
||||
- 관리자 사이드바에 미디어 메뉴 추가.
|
||||
- 글쓰기 이미지/갤러리 블록에서 기존 업로드 미디어 선택 기능 추가.
|
||||
- 패키지 버전을 0.0.15로 갱신.
|
||||
|
||||
## v0.0.14
|
||||
|
||||
- 관리자 블록 에디터에 단일 이미지 블록 추가.
|
||||
- 관리자 블록 에디터에 복수 이미지 갤러리 블록 추가.
|
||||
- 이미지 블록의 기본/와이드/풀사이즈 표시 옵션 추가.
|
||||
- 관리자 이미지 업로드 API 추가.
|
||||
- 공개 본문 렌더러에 이미지와 갤러리 렌더링 추가.
|
||||
- 공개 갤러리 이미지 클릭 시 라이트박스로 크게 보는 기능 추가.
|
||||
- 업로드 파일이 Git에 포함되지 않도록 `public/uploads/` 제외 처리.
|
||||
- 향후 미디어 라이브러리 관리 기능 범위 정리.
|
||||
- 패키지 버전을 0.0.14로 갱신.
|
||||
|
||||
## v0.0.13
|
||||
|
||||
- 개발 서버 실행 로그를 프로젝트 전용 링크 요약 출력으로 정리.
|
||||
- `npm run dev`가 `scripts/dev-server.js`를 통해 Nuxt 개발 서버를 실행하도록 수정.
|
||||
- 패키지 버전을 0.0.13으로 갱신.
|
||||
|
||||
## v0.0.12
|
||||
|
||||
- 관리자 글 제목 입력에서 Enter 입력 시 본문 첫 블록으로 이동하도록 수정.
|
||||
- 관리자 블록 에디터 본문 글자 색상을 관리자 화면 기준으로 고정.
|
||||
- 패키지 버전을 0.0.12로 갱신.
|
||||
|
||||
## v0.0.11
|
||||
|
||||
- 관리자 블록 에디터에서 빈 문단 Enter 입력 시 새 빈 블록이 생성되도록 수정.
|
||||
- 관리자 블록 에디터의 `/` 명령 메뉴에서 Enter로 선택 항목을 적용하도록 수정.
|
||||
- 관리자 블록 에디터의 `/` 명령 메뉴에 위/아래 방향키 선택 이동 추가.
|
||||
- 관리자 글 에디터 후속 작업 순서 정리.
|
||||
- 패키지 버전을 0.0.11로 갱신.
|
||||
|
||||
## v0.0.10
|
||||
|
||||
- 관리자 블록 에디터의 `contenteditable` 입력 중복 문제 수정.
|
||||
- 관리자 블록 에디터의 `/` 명령 메뉴가 화면 하단에서 위로 열리도록 수정.
|
||||
- 빈 블록 placeholder가 활성 블록에만 표시되도록 수정.
|
||||
- 관리자 글 제목 입력을 본문 흐름 안의 큰 제목 필드로 변경.
|
||||
- 패키지 버전을 0.0.10으로 갱신.
|
||||
|
||||
## v0.0.9
|
||||
|
||||
- 관리자 글 작성/수정 폼을 textarea 방식에서 블록형 에디터 방식으로 변경.
|
||||
- 관리자 블록 에디터에 `/` 명령 메뉴 추가.
|
||||
- 관리자 블록 에디터에 `#`, `##`, `###`, `>`, `-` 입력 단축 변환 추가.
|
||||
- 공개 게시물과 고정 페이지 본문을 마크다운 렌더러에 연결.
|
||||
- 패키지 버전을 0.0.9로 갱신.
|
||||
|
||||
## v0.0.8
|
||||
|
||||
- 관리자 글 작성/수정 폼에 마크다운 미리보기 탭 추가.
|
||||
- 관리자 글 작성/수정 폼에 제목, 굵게, 목록, 인용, 코드 문법 삽입 버튼 추가.
|
||||
- 패키지 버전을 0.0.8로 갱신.
|
||||
|
||||
## v0.0.7
|
||||
|
||||
- 태그 정렬 순서와 색상 코드 필드 추가.
|
||||
- 태그 표시 필드 마이그레이션 추가.
|
||||
- 관리자 태그 생성/수정 화면을 개별 페이지로 분리.
|
||||
- 관리자 태그 목록 화면의 인라인 수정 제거.
|
||||
- 공개 좌측 사이드바 카테고리를 실제 태그 색상과 정렬 순서 기준으로 연결.
|
||||
- 관리자 태그 상세 조회 API 추가.
|
||||
- 관리자 태그 목록 API 추가.
|
||||
- 관리자 태그 생성 API 추가.
|
||||
- 관리자 태그 수정 API 추가.
|
||||
- 관리자 태그 삭제 API 추가.
|
||||
- 관리자 태그 관리 화면을 실제 API에 연결.
|
||||
- 관리자 글 삭제 API 추가.
|
||||
- 관리자 글 목록과 수정 화면에 삭제 액션 추가.
|
||||
- 관리자 글 작성 API 추가.
|
||||
- 관리자 글 상세 조회 API 추가.
|
||||
- 관리자 글 수정 API 추가.
|
||||
- 관리자 글 작성/수정 공통 폼 추가.
|
||||
- 관리자 새 글 작성 화면과 수정 화면을 실제 저장 API에 연결.
|
||||
- 관리자 글 상태를 초안/발행/비공개로 저장할 수 있도록 수정.
|
||||
- 관리자 접근 미들웨어의 서버 인증 확인 방식 수정.
|
||||
- 환경 변수 기반 관리자 로그인 기능 추가.
|
||||
- 관리자 세션 쿠키 인증 유틸리티 추가.
|
||||
- 관리자 로그아웃 및 세션 조회 API 추가.
|
||||
- 관리자 글 목록 API와 화면 연결.
|
||||
- 개발 서버의 `#app-manifest` 가상 모듈 분석 오류를 피하도록 Nuxt app manifest 실험 기능 비활성화.
|
||||
- Nuxt 컴포넌트 자동 import 설정을 문서의 컴포넌트명 기준에 맞게 수정.
|
||||
- 홈, 태그, 게시물, 고정 페이지 공개 화면을 실제 API 데이터에 연결.
|
||||
- 로컬 PostgreSQL 마이그레이션 실행 스크립트 추가.
|
||||
- 개발 DB 마이그레이션 npm 명령 추가.
|
||||
- Homebrew, Docker CLI, Docker Compose, Colima 기반 로컬 컨테이너 실행 환경 구성.
|
||||
- Docker Compose가 `ENV_FILE` 값으로 로컬/운영 환경 파일을 선택할 수 있도록 수정.
|
||||
- 로컬 PostgreSQL 개발 DB 컨테이너 실행 및 시드 데이터 확인.
|
||||
- Nuxt 개발/프리뷰 스크립트가 `.env.development`를 명시적으로 읽도록 수정.
|
||||
- 새 개발 환경에서 Node.js 22 LTS 기준 의존성 설치 및 빌드 검증.
|
||||
- 로컬 개발 필수 조건 문서의 Node.js 권장 버전 정리.
|
||||
- 패키지 버전을 0.0.7로 갱신.
|
||||
|
||||
## v0.0.6
|
||||
|
||||
- `.env.example`의 실제 계정/비밀번호 값을 예시 전용 placeholder로 교체.
|
||||
- 로컬 개발 전용 `.env.development` 파일 생성.
|
||||
- 개발 DB 비밀번호와 관리자 비밀번호를 랜덤 값으로 분리.
|
||||
- 환경 변수 파일 관리 기준 문서 정리.
|
||||
- 패키지 버전을 0.0.6으로 갱신.
|
||||
- 이미 원격에 올라간 비밀번호 사용 여부 점검 항목 추가.
|
||||
|
||||
## v0.0.5
|
||||
|
||||
- PostgreSQL 초기 스키마 마이그레이션 추가.
|
||||
- 개발용 시드 데이터 SQL 추가.
|
||||
- Nuxt 서버 API 저장소 계층 추가.
|
||||
- `DATABASE_URL`이 있으면 PostgreSQL을 사용하고, 없으면 샘플 데이터를 사용하도록 수정.
|
||||
- Docker Compose에 PostgreSQL 서비스와 전용 DB 포트 43119 추가.
|
||||
- 공개 API가 저장소 계층을 통해 게시물, 페이지, 태그를 조회하도록 수정.
|
||||
|
||||
## v0.0.4
|
||||
|
||||
- 헤더 좌측 아이콘을 브랜드 마크에서 메뉴 토글 버튼으로 수정.
|
||||
- Vue/Nuxt 상태 기반 좌측 사이드바 열기/닫기 기능 추가.
|
||||
- 메뉴 열림 상태를 `localStorage`의 `MENU_STATE`에 저장하도록 추가.
|
||||
- 메뉴 닫힘 상태에서 공개 레이아웃 그리드가 좌측 사이드바 폭을 제거하도록 수정.
|
||||
|
||||
## v0.0.3
|
||||
|
||||
- Thred 참고 화면 기준 공개 레이아웃 색상 토큰 정리.
|
||||
- 라이트/다크 모드 CSS 변수 기반 테마 추가.
|
||||
- 헤더 아래 3단 컬럼 최소 높이를 화면 전체 높이로 수정.
|
||||
- 좌우 사이드바를 본문과 별개로 전체 높이 패널처럼 표시하도록 수정.
|
||||
- 홈 화면 히어로, 추천 영역, 최신 글 영역 구조 보강.
|
||||
- 사이트 헤더 검색 영역과 구독/계정 액션 구조 추가.
|
||||
- 좌우 사이드바 임시 콘텐츠 구조 보강.
|
||||
- 로컬 개발/프리뷰 포트 43117 유지.
|
||||
|
||||
## v0.0.2
|
||||
|
||||
- Nuxt 3 프로젝트 기본 실행 구조 추가.
|
||||
- Tailwind CSS 설정과 전역 Pretendard 기준 스타일 추가.
|
||||
- 공개 레이아웃, 게시물 레이아웃, 고정 페이지 레이아웃, 관리자 레이아웃 골격 추가.
|
||||
- 사이트 컴포넌트와 콘텐츠 컴포넌트 초기 골격 추가.
|
||||
- 홈, 게시물 상세, 태그, 고정 페이지, 관리자 기본 화면 추가.
|
||||
- Nuxt `server/api` 기반 백엔드 골격 추가.
|
||||
- Zod 기반 콘텐츠 스키마와 샘플 API 추가.
|
||||
- `.env.example`, Dockerfile, docker-compose.yml, .dockerignore 추가.
|
||||
- 로컬 개발 포트 43117, NAS Docker 외부 포트 43118 기준 추가.
|
||||
|
||||
## v0.0.1
|
||||
|
||||
- sori.studio 개인 블로그/CMS 방향 정리.
|
||||
|
||||
48
layouts/admin.vue
Normal file
48
layouts/admin.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 관리자 로그아웃
|
||||
* @returns {Promise<void>} 로그아웃 처리 결과
|
||||
*/
|
||||
const logoutAdmin = async () => {
|
||||
await $fetch('/admin/api/auth/logout', {
|
||||
method: 'POST'
|
||||
})
|
||||
await navigateTo('/admin/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-layout min-h-screen bg-[#f5f5f2] text-ink">
|
||||
<aside class="admin-layout__sidebar fixed inset-y-0 left-0 hidden w-64 border-r border-line bg-[#15171a] p-5 text-white lg:block">
|
||||
<NuxtLink class="admin-layout__brand block text-lg font-semibold" to="/admin">
|
||||
sori.studio
|
||||
</NuxtLink>
|
||||
<nav class="admin-layout__nav mt-8 grid gap-2 text-sm text-white/75">
|
||||
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 hover:bg-white/10 hover:text-white" to="/admin/posts">
|
||||
글
|
||||
</NuxtLink>
|
||||
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 hover:bg-white/10 hover:text-white" to="/admin/pages">
|
||||
페이지
|
||||
</NuxtLink>
|
||||
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 hover:bg-white/10 hover:text-white" to="/admin/tags">
|
||||
태그
|
||||
</NuxtLink>
|
||||
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 hover:bg-white/10 hover:text-white" to="/admin/media">
|
||||
미디어
|
||||
</NuxtLink>
|
||||
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 hover:bg-white/10 hover:text-white" to="/admin/navigation">
|
||||
메뉴
|
||||
</NuxtLink>
|
||||
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 hover:bg-white/10 hover:text-white" to="/admin/settings">
|
||||
설정
|
||||
</NuxtLink>
|
||||
<button class="admin-layout__logout rounded px-3 py-2 text-left hover:bg-white/10 hover:text-white" type="button" @click="logoutAdmin">
|
||||
로그아웃
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
<main class="admin-layout__main min-h-screen p-5 lg:ml-64">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
16
layouts/default.vue
Normal file
16
layouts/default.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
const { menuOpen } = useMenuState()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="site-shell public-layout">
|
||||
<SiteHeader />
|
||||
<div class="site-content-grid public-layout__grid" :class="{ 'site-content-grid--menu-closed': !menuOpen }">
|
||||
<LeftSidebar v-show="menuOpen" />
|
||||
<main class="site-main w-full lg:w-[720px]" :class="{ 'site-main--menu-closed': !menuOpen }">
|
||||
<slot />
|
||||
</main>
|
||||
<RightSidebar />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
5
layouts/page.vue
Normal file
5
layouts/page.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<main class="page-layout min-h-screen bg-paper text-ink">
|
||||
<slot />
|
||||
</main>
|
||||
</template>
|
||||
16
layouts/post.vue
Normal file
16
layouts/post.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
const { menuOpen } = useMenuState()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="site-shell post-layout">
|
||||
<SiteHeader />
|
||||
<div class="site-content-grid post-layout__grid" :class="{ 'site-content-grid--menu-closed': !menuOpen }">
|
||||
<LeftSidebar v-show="menuOpen" />
|
||||
<main class="site-main post-main w-full px-5 py-8 lg:w-[720px]" :class="{ 'site-main--menu-closed': !menuOpen }">
|
||||
<slot />
|
||||
</main>
|
||||
<RightSidebar />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
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')
|
||||
}
|
||||
})
|
||||
39
nuxt.config.js
Normal file
39
nuxt.config.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2026-04-29',
|
||||
modules: ['@nuxtjs/tailwindcss'],
|
||||
components: [
|
||||
{
|
||||
path: '~/components',
|
||||
pathPrefix: false
|
||||
}
|
||||
],
|
||||
experimental: {
|
||||
appManifest: false
|
||||
},
|
||||
css: ['~/assets/css/main.css'],
|
||||
app: {
|
||||
head: {
|
||||
htmlAttrs: {
|
||||
lang: 'ko'
|
||||
},
|
||||
title: 'sori.studio',
|
||||
meta: [
|
||||
{ name: 'description', content: 'sori.studio 개인 블로그' },
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' }
|
||||
]
|
||||
}
|
||||
},
|
||||
runtimeConfig: {
|
||||
databaseUrl: process.env.DATABASE_URL || '',
|
||||
databaseName: process.env.DATABASE_NAME || '',
|
||||
adminEmail: process.env.ADMIN_EMAIL || '',
|
||||
adminPassword: process.env.ADMIN_PASSWORD || '',
|
||||
uploadDir: process.env.UPLOAD_DIR || '/uploads',
|
||||
maxFileSize: Number(process.env.MAX_FILE_SIZE || 10485760),
|
||||
public: {
|
||||
siteUrl: process.env.NUXT_PUBLIC_SITE_URL || 'https://sori.studio',
|
||||
siteTitle: process.env.NUXT_PUBLIC_SITE_TITLE || 'sori.studio'
|
||||
}
|
||||
}
|
||||
})
|
||||
11659
package-lock.json
generated
Normal file
11659
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
package.json
Normal file
21
package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.31",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev-server.js",
|
||||
"build": "nuxt build",
|
||||
"preview": "nuxt preview --dotenv .env.development --host 127.0.0.1 --port 43117",
|
||||
"db:migrate:dev": "node scripts/migrate-development-db.js",
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"nuxt": "^3.16.2",
|
||||
"postgres": "^3.4.9",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0",
|
||||
"zod": "^3.24.2"
|
||||
}
|
||||
}
|
||||
51
pages/admin/index.vue
Normal file
51
pages/admin/index.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
|
||||
const { data: posts } = await useFetch('/admin/api/posts', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
const publishedCount = computed(() => posts.value.filter((post) => post.status === 'published').length)
|
||||
const draftCount = computed(() => posts.value.filter((post) => post.status === 'draft').length)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-dashboard">
|
||||
<div class="admin-dashboard__header border-b border-line bg-paper p-6">
|
||||
<p class="admin-dashboard__eyebrow text-xs font-semibold uppercase text-muted">
|
||||
Admin
|
||||
</p>
|
||||
<h1 class="admin-dashboard__title mt-2 text-3xl font-semibold">
|
||||
대시보드
|
||||
</h1>
|
||||
</div>
|
||||
<div class="admin-dashboard__body grid gap-4 bg-paper p-6 text-sm text-muted md:grid-cols-3">
|
||||
<section class="admin-dashboard__metric border border-line bg-white p-4">
|
||||
<p class="admin-dashboard__metric-label text-xs font-semibold uppercase">
|
||||
Posts
|
||||
</p>
|
||||
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
|
||||
{{ posts.length }}
|
||||
</strong>
|
||||
</section>
|
||||
<section class="admin-dashboard__metric border border-line bg-white p-4">
|
||||
<p class="admin-dashboard__metric-label text-xs font-semibold uppercase">
|
||||
Published
|
||||
</p>
|
||||
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
|
||||
{{ publishedCount }}
|
||||
</strong>
|
||||
</section>
|
||||
<section class="admin-dashboard__metric border border-line bg-white p-4">
|
||||
<p class="admin-dashboard__metric-label text-xs font-semibold uppercase">
|
||||
Draft
|
||||
</p>
|
||||
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
|
||||
{{ draftCount }}
|
||||
</strong>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
79
pages/admin/login.vue
Normal file
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>
|
||||
634
pages/admin/media/index.vue
Normal file
634
pages/admin/media/index.vue
Normal file
@@ -0,0 +1,634 @@
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
|
||||
const searchText = ref('')
|
||||
const activeFolder = ref('')
|
||||
const newFolderName = ref('')
|
||||
const editingUrl = ref('')
|
||||
const editingName = ref('')
|
||||
const editingCategory = ref('')
|
||||
const deletingUrl = ref('')
|
||||
const errorMessage = ref('')
|
||||
const selectedMediaUrl = ref('')
|
||||
const selectedMediaUrls = ref([])
|
||||
const lastSelectedIndex = ref(-1)
|
||||
const draggingUrls = ref([])
|
||||
|
||||
const { data: mediaItems, refresh } = await useFetch('/admin/api/media', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
const { data: mediaFolders, refresh: refreshMediaFolders } = await useFetch('/admin/api/media-folders', {
|
||||
default: () => ['미분류']
|
||||
})
|
||||
|
||||
const selectedMedia = computed(() => mediaItems.value.find((item) => item.url === selectedMediaUrl.value) || null)
|
||||
|
||||
const normalizedFolders = computed(() => {
|
||||
const folderSet = new Set(['미분류'])
|
||||
|
||||
mediaFolders.value.forEach((folder) => {
|
||||
String(folder || '').split('/').filter(Boolean).reduce((parentPath, segment) => {
|
||||
const nextPath = parentPath ? `${parentPath}/${segment}` : segment
|
||||
folderSet.add(nextPath)
|
||||
return nextPath
|
||||
}, '')
|
||||
})
|
||||
|
||||
mediaItems.value.forEach((item) => {
|
||||
String(item.category || '미분류').split('/').filter(Boolean).reduce((parentPath, segment) => {
|
||||
const nextPath = parentPath ? `${parentPath}/${segment}` : segment
|
||||
folderSet.add(nextPath)
|
||||
return nextPath
|
||||
}, '')
|
||||
})
|
||||
|
||||
return [...folderSet].sort((left, right) => left.localeCompare(right))
|
||||
})
|
||||
|
||||
const folderMediaCounts = computed(() => normalizedFolders.value.reduce((counts, folder) => {
|
||||
counts[folder] = mediaItems.value.filter((item) => item.category === folder || item.category?.startsWith(`${folder}/`)).length
|
||||
return counts
|
||||
}, {}))
|
||||
|
||||
const filteredMediaItems = computed(() => {
|
||||
const query = searchText.value.trim().toLowerCase()
|
||||
const folder = activeFolder.value
|
||||
|
||||
return mediaItems.value.filter((item) => {
|
||||
const matchesFolder = !folder || item.category === folder || item.category?.startsWith(`${folder}/`)
|
||||
const matchesQuery = !query || [
|
||||
item.name,
|
||||
item.url,
|
||||
item.category,
|
||||
...item.usage.map((usage) => usage.title)
|
||||
].some((value) => String(value || '').toLowerCase().includes(query))
|
||||
|
||||
return matchesFolder && matchesQuery
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 파일 크기 표시 문자열 생성
|
||||
* @param {number} size - 파일 크기
|
||||
* @returns {string} 표시 문자열
|
||||
*/
|
||||
const formatFileSize = (size) => {
|
||||
if (size < 1024) {
|
||||
return `${size} B`
|
||||
}
|
||||
|
||||
if (size < 1024 * 1024) {
|
||||
return `${(size / 1024).toFixed(1)} KB`
|
||||
}
|
||||
|
||||
return `${(size / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
/**
|
||||
* 폴더 경로의 표시 이름 조회
|
||||
* @param {string} folder - 폴더 경로
|
||||
* @returns {string} 표시 이름
|
||||
*/
|
||||
const getFolderName = (folder) => folder.split('/').filter(Boolean).pop() || '미분류'
|
||||
|
||||
/**
|
||||
* 폴더 깊이 조회
|
||||
* @param {string} folder - 폴더 경로
|
||||
* @returns {number} 폴더 깊이
|
||||
*/
|
||||
const getFolderDepth = (folder) => Math.max(folder.split('/').filter(Boolean).length - 1, 0)
|
||||
|
||||
/**
|
||||
* 미디어 선택 여부 확인
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {boolean} 선택 여부
|
||||
*/
|
||||
const isMediaSelected = (item) => selectedMediaUrls.value.includes(item.url)
|
||||
|
||||
/**
|
||||
* 폴더 선택
|
||||
* @param {string} folder - 폴더 경로
|
||||
* @returns {void}
|
||||
*/
|
||||
const selectFolder = (folder) => {
|
||||
activeFolder.value = folder
|
||||
selectedMediaUrls.value = []
|
||||
lastSelectedIndex.value = -1
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 항목 선택
|
||||
* @param {MouseEvent} event - 클릭 이벤트
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @param {number} index - 필터 목록 내 순서
|
||||
* @returns {void}
|
||||
*/
|
||||
const selectMediaItem = (event, item, index) => {
|
||||
if (event.shiftKey && lastSelectedIndex.value >= 0) {
|
||||
const startIndex = Math.min(lastSelectedIndex.value, index)
|
||||
const endIndex = Math.max(lastSelectedIndex.value, index)
|
||||
const rangeUrls = filteredMediaItems.value.slice(startIndex, endIndex + 1).map((mediaItem) => mediaItem.url)
|
||||
selectedMediaUrls.value = [...new Set([...selectedMediaUrls.value, ...rangeUrls])]
|
||||
return
|
||||
}
|
||||
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
selectedMediaUrls.value = isMediaSelected(item)
|
||||
? selectedMediaUrls.value.filter((url) => url !== item.url)
|
||||
: [...selectedMediaUrls.value, item.url]
|
||||
lastSelectedIndex.value = index
|
||||
return
|
||||
}
|
||||
|
||||
selectedMediaUrls.value = [item.url]
|
||||
lastSelectedIndex.value = index
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택된 미디어 해제
|
||||
* @returns {void}
|
||||
*/
|
||||
const clearMediaSelection = () => {
|
||||
selectedMediaUrls.value = []
|
||||
lastSelectedIndex.value = -1
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 상세 모달 열기
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {void}
|
||||
*/
|
||||
const openMediaDetail = (item) => {
|
||||
selectedMediaUrl.value = item.url
|
||||
editingUrl.value = item.url
|
||||
editingName.value = item.title
|
||||
editingCategory.value = item.category
|
||||
errorMessage.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 상세 모달 닫기
|
||||
* @returns {void}
|
||||
*/
|
||||
const closeMediaDetail = () => {
|
||||
selectedMediaUrl.value = ''
|
||||
cancelRename()
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 파일명 변경 취소
|
||||
* @returns {void}
|
||||
*/
|
||||
const cancelRename = () => {
|
||||
editingUrl.value = ''
|
||||
editingName.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 폴더 생성
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const createFolder = async () => {
|
||||
const folderName = newFolderName.value.trim()
|
||||
|
||||
if (!folderName) {
|
||||
return
|
||||
}
|
||||
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const folderPath = activeFolder.value ? `${activeFolder.value}/${folderName}` : folderName
|
||||
const createdFolder = await $fetch('/admin/api/media-folders', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
path: folderPath
|
||||
}
|
||||
})
|
||||
|
||||
newFolderName.value = ''
|
||||
activeFolder.value = createdFolder.path
|
||||
await refreshMediaFolders()
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '폴더를 만들지 못했습니다.'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택한 미디어를 폴더로 이동
|
||||
* @param {string} folder - 폴더 경로
|
||||
* @param {Array<string>} urls - 이동할 미디어 URL 목록
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const moveMediaToFolder = async (folder, urls = selectedMediaUrls.value) => {
|
||||
const targetUrls = [...new Set(urls.filter(Boolean))]
|
||||
|
||||
if (!targetUrls.length) {
|
||||
return
|
||||
}
|
||||
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await $fetch('/admin/api/media', {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
urls: targetUrls,
|
||||
category: folder || '미분류'
|
||||
}
|
||||
})
|
||||
await Promise.all([
|
||||
refresh(),
|
||||
refreshMediaFolders()
|
||||
])
|
||||
activeFolder.value = folder || '미분류'
|
||||
clearMediaSelection()
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '미디어 폴더를 변경하지 못했습니다.'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 드래그 시작
|
||||
* @param {DragEvent} event - 드래그 이벤트
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {void}
|
||||
*/
|
||||
const startMediaDrag = (event, item) => {
|
||||
if (!isMediaSelected(item)) {
|
||||
selectedMediaUrls.value = [item.url]
|
||||
}
|
||||
|
||||
draggingUrls.value = [...selectedMediaUrls.value]
|
||||
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData('text/plain', draggingUrls.value.join('\n'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 폴더로 미디어 드롭
|
||||
* @param {string} folder - 폴더 경로
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const dropMediaOnFolder = async (folder) => {
|
||||
const urls = draggingUrls.value.length ? draggingUrls.value : selectedMediaUrls.value
|
||||
draggingUrls.value = []
|
||||
await moveMediaToFolder(folder, urls)
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 카테고리 저장
|
||||
* @returns {Promise<void>} 저장 결과
|
||||
*/
|
||||
const saveMediaCategory = async () => {
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await $fetch('/admin/api/media', {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
url: selectedMedia.value.url,
|
||||
category: editingCategory.value
|
||||
}
|
||||
})
|
||||
await Promise.all([
|
||||
refresh(),
|
||||
refreshMediaFolders()
|
||||
])
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '카테고리를 저장하지 못했습니다.'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 파일명 변경
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const renameMedia = async () => {
|
||||
const editingItem = mediaItems.value.find((item) => item.url === editingUrl.value)
|
||||
|
||||
if (editingItem?.usage.length) {
|
||||
errorMessage.value = '사용 중인 미디어는 파일명을 변경할 수 없습니다.'
|
||||
return
|
||||
}
|
||||
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const renamedItem = await $fetch('/admin/api/media', {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
url: editingUrl.value,
|
||||
name: editingName.value
|
||||
}
|
||||
})
|
||||
cancelRename()
|
||||
await refresh()
|
||||
selectedMediaUrl.value = renamedItem.url
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '파일명을 변경하지 못했습니다.'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 삭제
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const deleteMedia = async (item) => {
|
||||
if (item.usage.length) {
|
||||
errorMessage.value = '사용 중인 미디어는 삭제할 수 없습니다.'
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirm(`"${item.name}" 파일을 삭제할까요?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
deletingUrl.value = item.url
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await $fetch('/admin/api/media', {
|
||||
method: 'DELETE',
|
||||
body: {
|
||||
url: item.url
|
||||
}
|
||||
})
|
||||
closeMediaDetail()
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '파일을 삭제하지 못했습니다.'
|
||||
} finally {
|
||||
deletingUrl.value = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-media bg-paper p-6">
|
||||
<div class="admin-media__header flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="admin-media__eyebrow text-xs font-semibold uppercase text-muted">
|
||||
Media
|
||||
</p>
|
||||
<h1 class="admin-media__title mt-2 text-3xl font-semibold">
|
||||
미디어
|
||||
</h1>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchText"
|
||||
class="admin-media__search w-full rounded border border-line bg-white px-3 py-2 text-sm md:w-72"
|
||||
type="search"
|
||||
placeholder="파일명, 경로, 폴더, 사용처 검색"
|
||||
>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="admin-media__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-media__layout mt-8 grid gap-5 lg:grid-cols-[16rem_minmax(0,1fr)]">
|
||||
<aside class="admin-media__folders rounded border border-line bg-white p-3">
|
||||
<button
|
||||
class="admin-media__folder-button flex w-full items-center justify-between rounded px-3 py-2 text-left text-sm font-semibold hover:bg-surface"
|
||||
:class="!activeFolder ? 'bg-[#15171a] text-white hover:bg-[#15171a]' : 'text-ink'"
|
||||
type="button"
|
||||
@click="selectFolder('')"
|
||||
@dragover.prevent
|
||||
@drop.prevent="dropMediaOnFolder('미분류')"
|
||||
>
|
||||
<span>전체 미디어</span>
|
||||
<span>{{ mediaItems.length }}</span>
|
||||
</button>
|
||||
|
||||
<div class="admin-media__folder-list mt-3 grid gap-1">
|
||||
<button
|
||||
v-for="folder in normalizedFolders"
|
||||
:key="folder"
|
||||
class="admin-media__folder-button flex w-full items-center justify-between rounded py-2 pr-3 text-left text-sm hover:bg-surface"
|
||||
:class="activeFolder === folder ? 'bg-[#15171a] text-white hover:bg-[#15171a]' : 'text-ink'"
|
||||
:style="{ paddingLeft: `${12 + getFolderDepth(folder) * 14}px` }"
|
||||
type="button"
|
||||
@click="selectFolder(folder)"
|
||||
@dragover.prevent
|
||||
@drop.prevent="dropMediaOnFolder(folder)"
|
||||
>
|
||||
<span class="admin-media__folder-name min-w-0 truncate">{{ getFolderName(folder) }}</span>
|
||||
<span class="admin-media__folder-count shrink-0 text-xs opacity-70">{{ folderMediaCounts[folder] || 0 }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="admin-media__folder-create mt-4 grid gap-2 border-t border-line pt-4" @submit.prevent="createFolder">
|
||||
<label class="admin-media__folder-label text-xs font-semibold text-muted" for="media-folder-name">
|
||||
새 폴더
|
||||
</label>
|
||||
<input
|
||||
id="media-folder-name"
|
||||
v-model="newFolderName"
|
||||
class="admin-media__folder-input rounded border border-line px-3 py-2 text-sm"
|
||||
type="text"
|
||||
placeholder="폴더 이름"
|
||||
>
|
||||
<button class="admin-media__folder-submit rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white" type="submit">
|
||||
폴더 추가
|
||||
</button>
|
||||
</form>
|
||||
</aside>
|
||||
|
||||
<div class="admin-media__content min-w-0">
|
||||
<div class="admin-media__toolbar flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="admin-media__folder-title text-lg font-semibold text-ink">
|
||||
{{ activeFolder || '전체 미디어' }}
|
||||
</h2>
|
||||
<p class="admin-media__folder-summary mt-1 text-xs text-muted">
|
||||
{{ filteredMediaItems.length }}개 표시
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="selectedMediaUrls.length" class="admin-media__selection flex flex-wrap items-center gap-2 rounded border border-line bg-white px-3 py-2 text-xs">
|
||||
<strong class="admin-media__selection-count text-ink">{{ selectedMediaUrls.length }}개 선택됨</strong>
|
||||
<button class="admin-media__selection-clear font-semibold text-muted hover:text-ink" type="button" @click="clearMediaSelection">
|
||||
선택 해제
|
||||
</button>
|
||||
<button
|
||||
v-if="activeFolder"
|
||||
class="admin-media__selection-move rounded bg-[#15171a] px-2.5 py-1 font-semibold text-white"
|
||||
type="button"
|
||||
@click="moveMediaToFolder(activeFolder)"
|
||||
>
|
||||
현재 폴더로 이동
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredMediaItems.length" class="admin-media__grid mt-5 grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10">
|
||||
<button
|
||||
v-for="(item, index) in filteredMediaItems"
|
||||
:key="item.url"
|
||||
class="admin-media__item group relative overflow-hidden border border-line bg-white text-left outline-none transition"
|
||||
:class="isMediaSelected(item) ? 'ring-2 ring-[#15171a]' : 'hover:border-[#15171a]'"
|
||||
type="button"
|
||||
draggable="true"
|
||||
@click="selectMediaItem($event, item, index)"
|
||||
@dblclick="openMediaDetail(item)"
|
||||
@dragstart="startMediaDrag($event, item)"
|
||||
>
|
||||
<img class="admin-media__image aspect-square w-full bg-surface object-cover" :src="item.url" :alt="item.title">
|
||||
<span
|
||||
v-if="item.usage.length"
|
||||
class="admin-media__usage-badge absolute right-1.5 top-1.5 rounded bg-[#15171a] px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
||||
>
|
||||
{{ item.usage.length }}
|
||||
</span>
|
||||
<span
|
||||
v-if="isMediaSelected(item)"
|
||||
class="admin-media__selected-badge absolute left-1.5 top-1.5 grid h-5 w-5 place-items-center rounded-full bg-white text-[11px] font-bold text-ink shadow"
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
<span class="admin-media__name block truncate px-2 py-1.5 text-xs font-semibold text-ink">
|
||||
{{ item.name }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-else class="admin-media__empty mt-5 border border-dashed border-line bg-white p-8 text-center text-sm text-muted">
|
||||
표시할 미디어가 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedMedia"
|
||||
class="admin-media__modal fixed inset-0 z-50 grid place-items-center bg-black/40 px-5 py-8"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@click.self="closeMediaDetail"
|
||||
>
|
||||
<section class="admin-media__modal-panel grid max-h-[86vh] w-full max-w-5xl overflow-hidden bg-white text-ink shadow-xl lg:grid-cols-[minmax(0,1fr)_22rem]">
|
||||
<div class="admin-media__preview grid min-h-[20rem] place-items-center bg-[#f5f5f2] p-5">
|
||||
<img class="admin-media__preview-image max-h-[72vh] max-w-full object-contain" :src="selectedMedia.url" :alt="selectedMedia.title">
|
||||
</div>
|
||||
|
||||
<aside class="admin-media__detail grid max-h-[86vh] content-start gap-5 overflow-y-auto border-l border-line p-5">
|
||||
<div class="admin-media__detail-header flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="admin-media__detail-eyebrow text-xs font-semibold uppercase text-muted">
|
||||
Attachment
|
||||
</p>
|
||||
<h2 class="admin-media__detail-title mt-1 break-all text-xl font-semibold">
|
||||
{{ selectedMedia.name }}
|
||||
</h2>
|
||||
</div>
|
||||
<button class="admin-media__detail-close rounded border border-line px-3 py-1.5 text-sm font-semibold" type="button" @click="closeMediaDetail">
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<dl class="admin-media__info grid gap-3 text-sm">
|
||||
<div class="admin-media__info-row">
|
||||
<dt class="admin-media__info-label text-xs font-semibold text-muted">경로</dt>
|
||||
<dd class="admin-media__info-value mt-1 break-all">{{ selectedMedia.url }}</dd>
|
||||
</div>
|
||||
<div class="admin-media__info-row">
|
||||
<dt class="admin-media__info-label text-xs font-semibold text-muted">용량</dt>
|
||||
<dd class="admin-media__info-value mt-1">{{ formatFileSize(selectedMedia.size) }}</dd>
|
||||
</div>
|
||||
<div class="admin-media__info-row">
|
||||
<dt class="admin-media__info-label text-xs font-semibold text-muted">폴더</dt>
|
||||
<dd class="admin-media__info-value mt-1 break-all">{{ selectedMedia.category }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="admin-media__category grid gap-2">
|
||||
<label class="admin-media__category-label text-xs font-semibold text-muted" for="media-category">
|
||||
폴더
|
||||
</label>
|
||||
<div class="admin-media__category-row flex gap-2">
|
||||
<input
|
||||
id="media-category"
|
||||
v-model="editingCategory"
|
||||
class="admin-media__category-input min-w-0 flex-1 rounded border border-line px-3 py-2 text-sm"
|
||||
type="text"
|
||||
list="media-folder-options"
|
||||
placeholder="미분류"
|
||||
@keydown.enter.prevent="saveMediaCategory"
|
||||
>
|
||||
<button class="admin-media__category-save rounded border border-line px-3 py-2 text-xs font-semibold" type="button" @click="saveMediaCategory">
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
<datalist id="media-folder-options">
|
||||
<option v-for="folder in normalizedFolders" :key="folder" :value="folder" />
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<div class="admin-media__usage rounded bg-surface p-3 text-xs">
|
||||
<strong class="admin-media__usage-title text-ink">
|
||||
사용 현황 {{ selectedMedia.usage.length }}곳
|
||||
</strong>
|
||||
<ul v-if="selectedMedia.usage.length" class="admin-media__usage-list mt-2 grid gap-1.5 text-muted">
|
||||
<li v-for="usage in selectedMedia.usage" :key="`${selectedMedia.url}-${usage.type}-${usage.id}-${usage.location}`" class="admin-media__usage-item">
|
||||
<NuxtLink
|
||||
v-if="usage.adminUrl"
|
||||
class="admin-media__usage-link font-semibold text-ink hover:opacity-70"
|
||||
:to="usage.adminUrl"
|
||||
>
|
||||
{{ usage.title }}
|
||||
</NuxtLink>
|
||||
<span v-else class="admin-media__usage-name font-semibold text-ink">{{ usage.title }}</span>
|
||||
<span class="admin-media__usage-meta"> · {{ usage.typeLabel }} · {{ usage.label }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="admin-media__usage-empty mt-2 text-muted">
|
||||
사용 중인 곳이 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="admin-media__rename grid gap-2">
|
||||
<label class="admin-media__rename-label text-xs font-semibold text-muted" for="media-name">
|
||||
파일명
|
||||
</label>
|
||||
<input
|
||||
id="media-name"
|
||||
v-model="editingName"
|
||||
class="admin-media__rename-input rounded border border-line px-3 py-2 text-sm disabled:opacity-50"
|
||||
type="text"
|
||||
:disabled="selectedMedia.usage.length > 0"
|
||||
:placeholder="selectedMedia.title"
|
||||
@keydown.enter.prevent="renameMedia"
|
||||
>
|
||||
<p v-if="selectedMedia.usage.length" class="admin-media__locked text-xs text-muted">
|
||||
사용 중인 미디어는 파일명 변경과 삭제가 잠겨 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="admin-media__actions flex flex-wrap gap-2">
|
||||
<button
|
||||
class="admin-media__rename-save rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="selectedMedia.usage.length > 0 || !editingName"
|
||||
@click="renameMedia"
|
||||
>
|
||||
파일명 저장
|
||||
</button>
|
||||
<button
|
||||
class="admin-media__delete rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="deletingUrl === selectedMedia.url || selectedMedia.usage.length > 0"
|
||||
@click="deleteMedia(selectedMedia)"
|
||||
>
|
||||
{{ deletingUrl === selectedMedia.url ? '삭제 중' : '삭제' }}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
207
pages/admin/navigation/index.vue
Normal file
207
pages/admin/navigation/index.vue
Normal file
@@ -0,0 +1,207 @@
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const toast = ref(null)
|
||||
let toastTimer = null
|
||||
|
||||
const { data: navigationItems } = await useFetch('/admin/api/navigation', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
const items = ref(navigationItems.value.map((item) => ({ ...item })))
|
||||
|
||||
/**
|
||||
* 저장 상태 토스트 표시
|
||||
* @param {'success'|'error'|'info'} type - 토스트 타입
|
||||
* @param {string} message - 표시 메시지
|
||||
* @returns {void}
|
||||
*/
|
||||
const showToast = (type, message) => {
|
||||
window.clearTimeout(toastTimer)
|
||||
toast.value = { type, message }
|
||||
toastTimer = window.setTimeout(() => {
|
||||
toast.value = null
|
||||
}, 3200)
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 네비게이션 항목 추가
|
||||
* @param {'primary'|'footer'} location - 표시 위치
|
||||
* @returns {void}
|
||||
*/
|
||||
const addNavigationItem = (location = 'primary') => {
|
||||
items.value.push({
|
||||
id: `new-${Date.now()}`,
|
||||
label: '',
|
||||
url: '/',
|
||||
location,
|
||||
sortOrder: items.value.length * 10 + 10,
|
||||
isVisible: true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 네비게이션 항목 삭제
|
||||
* @param {number} index - 항목 인덱스
|
||||
* @returns {void}
|
||||
*/
|
||||
const removeNavigationItem = (index) => {
|
||||
items.value.splice(index, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 네비게이션 항목 목록 저장
|
||||
* @returns {Promise<void>} 저장 결과
|
||||
*/
|
||||
const saveNavigation = async () => {
|
||||
saving.value = true
|
||||
errorMessage.value = ''
|
||||
showToast('info', '네비게이션을 저장하는 중입니다.')
|
||||
|
||||
try {
|
||||
const savedItems = await $fetch('/admin/api/navigation', {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
items: items.value.map((item) => ({
|
||||
label: item.label,
|
||||
url: item.url,
|
||||
location: item.location,
|
||||
sortOrder: Number(item.sortOrder || 0),
|
||||
isVisible: Boolean(item.isVisible)
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
items.value = savedItems.map((item) => ({ ...item }))
|
||||
showToast('success', '네비게이션이 저장되었습니다.')
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '네비게이션을 저장하지 못했습니다.'
|
||||
showToast('error', errorMessage.value)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.clearTimeout(toastTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-navigation bg-paper p-6">
|
||||
<div class="admin-navigation__header mb-8 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="admin-navigation__eyebrow text-xs font-semibold uppercase text-muted">
|
||||
Navigation
|
||||
</p>
|
||||
<h1 class="admin-navigation__title mt-2 text-3xl font-semibold">
|
||||
메뉴 관리
|
||||
</h1>
|
||||
</div>
|
||||
<div class="admin-navigation__header-actions flex flex-wrap gap-2">
|
||||
<button class="admin-navigation__add rounded border border-line bg-white px-4 py-2 text-sm font-semibold" type="button" @click="addNavigationItem('primary')">
|
||||
상단 메뉴 추가
|
||||
</button>
|
||||
<button class="admin-navigation__add rounded border border-line bg-white px-4 py-2 text-sm font-semibold" type="button" @click="addNavigationItem('footer')">
|
||||
하단 메뉴 추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="admin-navigation__error mb-5 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<form class="admin-navigation__form grid gap-5" @submit.prevent="saveNavigation">
|
||||
<div class="admin-navigation__table overflow-hidden border border-line bg-white">
|
||||
<table class="admin-navigation__table-inner w-full border-collapse text-left text-sm">
|
||||
<thead class="admin-navigation__table-head bg-[#f5f5f2] text-xs uppercase text-muted">
|
||||
<tr>
|
||||
<th class="admin-navigation__cell px-4 py-3">표시</th>
|
||||
<th class="admin-navigation__cell px-4 py-3">라벨</th>
|
||||
<th class="admin-navigation__cell px-4 py-3">URL</th>
|
||||
<th class="admin-navigation__cell px-4 py-3">위치</th>
|
||||
<th class="admin-navigation__cell px-4 py-3">순서</th>
|
||||
<th class="admin-navigation__cell px-4 py-3">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="admin-navigation__table-body divide-y divide-line">
|
||||
<tr v-for="(item, index) in items" :key="item.id || index" class="admin-navigation__row">
|
||||
<td class="admin-navigation__cell px-4 py-3">
|
||||
<input v-model="item.isVisible" class="admin-navigation__checkbox h-4 w-4" type="checkbox">
|
||||
</td>
|
||||
<td class="admin-navigation__cell px-4 py-3">
|
||||
<input
|
||||
v-model="item.label"
|
||||
class="admin-navigation__input w-full rounded border border-line px-3 py-2"
|
||||
type="text"
|
||||
required
|
||||
>
|
||||
</td>
|
||||
<td class="admin-navigation__cell px-4 py-3">
|
||||
<input
|
||||
v-model="item.url"
|
||||
class="admin-navigation__input w-full rounded border border-line px-3 py-2"
|
||||
type="text"
|
||||
required
|
||||
pattern="^(\/|https?:\/\/).*"
|
||||
>
|
||||
</td>
|
||||
<td class="admin-navigation__cell px-4 py-3">
|
||||
<select v-model="item.location" class="admin-navigation__select rounded border border-line px-3 py-2">
|
||||
<option value="primary">상단</option>
|
||||
<option value="footer">하단</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="admin-navigation__cell px-4 py-3">
|
||||
<input
|
||||
v-model.number="item.sortOrder"
|
||||
class="admin-navigation__sort w-24 rounded border border-line px-3 py-2"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
>
|
||||
</td>
|
||||
<td class="admin-navigation__cell px-4 py-3">
|
||||
<button class="admin-navigation__remove rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700" type="button" @click="removeNavigationItem(index)">
|
||||
삭제
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p v-if="items.length === 0" class="admin-navigation__empty rounded border border-dashed border-line bg-white p-8 text-center text-sm text-muted">
|
||||
메뉴 항목이 없습니다.
|
||||
</p>
|
||||
|
||||
<div class="admin-navigation__actions flex justify-end border-t border-line pt-5">
|
||||
<button
|
||||
class="admin-navigation__submit rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
||||
type="submit"
|
||||
:disabled="saving"
|
||||
>
|
||||
{{ saving ? '저장 중' : '메뉴 저장' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div
|
||||
v-if="toast"
|
||||
class="admin-navigation__toast fixed right-5 top-5 z-50 rounded border px-4 py-3 text-sm font-semibold shadow-lg"
|
||||
:class="{
|
||||
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
|
||||
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
|
||||
'border-line bg-white text-ink': toast.type === 'info'
|
||||
}"
|
||||
role="status"
|
||||
>
|
||||
{{ toast.message }}
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
161
pages/admin/pages/[id].vue
Normal file
161
pages/admin/pages/[id].vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<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 toast = ref(null)
|
||||
let toastTimer = null
|
||||
|
||||
const { data: page } = await useFetch(() => `/admin/api/pages/${id.value}`)
|
||||
|
||||
if (!page.value) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: '페이지를 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장 상태 토스트 표시
|
||||
* @param {'success'|'error'|'info'} type - 토스트 타입
|
||||
* @param {string} message - 표시 메시지
|
||||
* @returns {void}
|
||||
*/
|
||||
const showToast = (type, message) => {
|
||||
window.clearTimeout(toastTimer)
|
||||
toast.value = { type, message }
|
||||
toastTimer = window.setTimeout(() => {
|
||||
toast.value = null
|
||||
}, 3200)
|
||||
}
|
||||
|
||||
/**
|
||||
* 이전 화면에서 전달한 토스트 표시
|
||||
* @returns {void}
|
||||
*/
|
||||
const showStoredToast = () => {
|
||||
const storedToast = sessionStorage.getItem('SORI_ADMIN_PAGE_TOAST')
|
||||
|
||||
if (!storedToast) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedToast = JSON.parse(storedToast)
|
||||
showToast(parsedToast.type || 'success', parsedToast.message || '저장되었습니다.')
|
||||
} finally {
|
||||
sessionStorage.removeItem('SORI_ADMIN_PAGE_TOAST')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 고정 페이지 수정 저장
|
||||
* @param {Object} payload - 페이지 입력값
|
||||
* @returns {Promise<void>} 저장 결과
|
||||
*/
|
||||
const savePage = async (payload) => {
|
||||
saving.value = true
|
||||
errorMessage.value = ''
|
||||
showToast('info', '변경 내용을 저장하는 중입니다.')
|
||||
|
||||
try {
|
||||
const updatedPage = await $fetch(`/admin/api/pages/${id.value}`, {
|
||||
method: 'PUT',
|
||||
body: payload
|
||||
})
|
||||
|
||||
page.value = updatedPage
|
||||
showToast('success', '변경 내용이 저장되었습니다.')
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '페이지를 저장하지 못했습니다.'
|
||||
showToast('error', errorMessage.value)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 고정 페이지 삭제
|
||||
* @returns {Promise<void>} 삭제 처리 결과
|
||||
*/
|
||||
const deletePage = async () => {
|
||||
if (!confirm(`"${page.value.title}" 페이지를 삭제할까요?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
deleting.value = true
|
||||
errorMessage.value = ''
|
||||
showToast('info', '페이지를 삭제하는 중입니다.')
|
||||
|
||||
try {
|
||||
await $fetch(`/admin/api/pages/${id.value}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
await navigateTo('/admin/pages')
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '페이지를 삭제하지 못했습니다.'
|
||||
showToast('error', errorMessage.value)
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(showStoredToast)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.clearTimeout(toastTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-page-edit bg-paper p-6">
|
||||
<div class="admin-page-edit__header mb-8 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="admin-page-edit__eyebrow text-xs font-semibold uppercase text-muted">
|
||||
Pages
|
||||
</p>
|
||||
<h1 class="admin-page-edit__title mt-2 text-3xl font-semibold">
|
||||
페이지 수정
|
||||
</h1>
|
||||
</div>
|
||||
<div class="admin-page-edit__actions flex gap-2">
|
||||
<NuxtLink
|
||||
class="admin-page-edit__view rounded border border-line bg-white px-4 py-2 text-sm font-semibold"
|
||||
:to="`/pages/${page.slug}`"
|
||||
target="_blank"
|
||||
>
|
||||
보기
|
||||
</NuxtLink>
|
||||
<button
|
||||
class="admin-page-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="deletePage"
|
||||
>
|
||||
{{ deleting ? '삭제 중' : '삭제' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="errorMessage" class="admin-page-edit__error mb-5 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<AdminPageForm :initial-page="page" submit-label="변경 저장" :saving="saving" @submit="savePage" />
|
||||
<div
|
||||
v-if="toast"
|
||||
class="admin-page-edit__toast fixed right-5 top-5 z-50 rounded border px-4 py-3 text-sm font-semibold shadow-lg"
|
||||
:class="{
|
||||
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
|
||||
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
|
||||
'border-line bg-white text-ink': toast.type === 'info'
|
||||
}"
|
||||
role="status"
|
||||
>
|
||||
{{ toast.message }}
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
117
pages/admin/pages/index.vue
Normal file
117
pages/admin/pages/index.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
|
||||
const deletingId = ref('')
|
||||
const errorMessage = ref('')
|
||||
|
||||
const { data: pages, refresh } = await useFetch('/admin/api/pages', {
|
||||
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} page - 삭제할 고정 페이지
|
||||
* @returns {Promise<void>} 삭제 처리 결과
|
||||
*/
|
||||
const deletePage = async (page) => {
|
||||
if (!confirm(`"${page.title}" 페이지를 삭제할까요?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
deletingId.value = page.id
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await $fetch(`/admin/api/pages/${page.id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '페이지를 삭제하지 못했습니다.'
|
||||
} finally {
|
||||
deletingId.value = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-pages bg-paper p-6">
|
||||
<div class="admin-pages__header flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="admin-pages__eyebrow text-xs font-semibold uppercase text-muted">
|
||||
Pages
|
||||
</p>
|
||||
<h1 class="admin-pages__title mt-2 text-3xl font-semibold">
|
||||
페이지 목록
|
||||
</h1>
|
||||
</div>
|
||||
<NuxtLink class="admin-pages__new rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white" to="/admin/pages/new">
|
||||
새 페이지
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="admin-pages__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-pages__table mt-8 overflow-hidden border border-line">
|
||||
<table class="admin-pages__table-inner w-full border-collapse text-left text-sm">
|
||||
<thead class="admin-pages__table-head bg-[#f5f5f2] text-xs uppercase text-muted">
|
||||
<tr>
|
||||
<th class="admin-pages__cell px-4 py-3">제목</th>
|
||||
<th class="admin-pages__cell px-4 py-3">수정일</th>
|
||||
<th class="admin-pages__cell px-4 py-3">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="admin-pages__table-body divide-y divide-line bg-white">
|
||||
<tr v-for="page in pages" :key="page.id" class="admin-pages__row">
|
||||
<td class="admin-pages__cell px-4 py-4">
|
||||
<NuxtLink class="admin-pages__title-link font-semibold hover:opacity-70" :to="`/admin/pages/${page.id}`">
|
||||
{{ page.title }}
|
||||
</NuxtLink>
|
||||
<p class="admin-pages__slug mt-1 text-xs text-muted">
|
||||
/pages/{{ page.slug }}
|
||||
</p>
|
||||
</td>
|
||||
<td class="admin-pages__cell px-4 py-4">
|
||||
{{ formatDate(page.updatedAt) }}
|
||||
</td>
|
||||
<td class="admin-pages__cell px-4 py-4">
|
||||
<button
|
||||
class="admin-pages__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 === page.id"
|
||||
@click="deletePage(page)"
|
||||
>
|
||||
{{ deletingId === page.id ? '삭제 중' : '삭제' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-if="pages.length === 0" class="admin-pages__empty mt-6 text-sm text-muted">
|
||||
아직 작성된 페이지가 없습니다.
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
86
pages/admin/pages/new.vue
Normal file
86
pages/admin/pages/new.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const toast = ref(null)
|
||||
let toastTimer = null
|
||||
|
||||
/**
|
||||
* 저장 상태 토스트 표시
|
||||
* @param {'success'|'error'|'info'} type - 토스트 타입
|
||||
* @param {string} message - 표시 메시지
|
||||
* @returns {void}
|
||||
*/
|
||||
const showToast = (type, message) => {
|
||||
window.clearTimeout(toastTimer)
|
||||
toast.value = { type, message }
|
||||
toastTimer = window.setTimeout(() => {
|
||||
toast.value = null
|
||||
}, 3200)
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 고정 페이지 저장
|
||||
* @param {Object} payload - 페이지 입력값
|
||||
* @returns {Promise<void>} 저장 결과
|
||||
*/
|
||||
const savePage = async (payload) => {
|
||||
saving.value = true
|
||||
errorMessage.value = ''
|
||||
showToast('info', '페이지를 저장하는 중입니다.')
|
||||
|
||||
try {
|
||||
const page = await $fetch('/admin/api/pages', {
|
||||
method: 'POST',
|
||||
body: payload
|
||||
})
|
||||
|
||||
sessionStorage.setItem('SORI_ADMIN_PAGE_TOAST', JSON.stringify({
|
||||
type: 'success',
|
||||
message: '페이지가 저장되었습니다.'
|
||||
}))
|
||||
await navigateTo(`/admin/pages/${page.id}`)
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '페이지를 저장하지 못했습니다.'
|
||||
showToast('error', errorMessage.value)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.clearTimeout(toastTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-page-editor bg-paper p-6">
|
||||
<div class="admin-page-editor__header mb-8">
|
||||
<p class="admin-page-editor__eyebrow text-xs font-semibold uppercase text-muted">
|
||||
Pages
|
||||
</p>
|
||||
<h1 class="admin-page-editor__title mt-2 text-3xl font-semibold">
|
||||
새 페이지 작성
|
||||
</h1>
|
||||
</div>
|
||||
<p v-if="errorMessage" class="admin-page-editor__error mb-5 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<AdminPageForm submit-label="페이지 저장" :saving="saving" @submit="savePage" />
|
||||
<div
|
||||
v-if="toast"
|
||||
class="admin-page-editor__toast fixed right-5 top-5 z-50 rounded border px-4 py-3 text-sm font-semibold shadow-lg"
|
||||
:class="{
|
||||
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
|
||||
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
|
||||
'border-line bg-white text-ink': toast.type === 'info'
|
||||
}"
|
||||
role="status"
|
||||
>
|
||||
{{ toast.message }}
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
186
pages/admin/posts/[id].vue
Normal file
186
pages/admin/posts/[id].vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<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 postForm = ref(null)
|
||||
const toast = ref(null)
|
||||
let toastTimer = null
|
||||
|
||||
const { data: post } = await useFetch(() => `/admin/api/posts/${id.value}`)
|
||||
|
||||
if (!post.value) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: '게시물을 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 화면에서 접근 가능한 게시물 여부 확인
|
||||
* @param {Object} value - 게시물
|
||||
* @returns {boolean} 공개 접근 가능 여부
|
||||
*/
|
||||
const isPublicPost = (value) => value?.status === 'published'
|
||||
&& (!value.publishedAt || new Date(value.publishedAt) <= new Date())
|
||||
|
||||
/**
|
||||
* 저장 상태 토스트 표시
|
||||
* @param {'success'|'error'|'info'} type - 토스트 타입
|
||||
* @param {string} message - 표시 메시지
|
||||
* @returns {void}
|
||||
*/
|
||||
const showToast = (type, message) => {
|
||||
window.clearTimeout(toastTimer)
|
||||
toast.value = { type, message }
|
||||
toastTimer = window.setTimeout(() => {
|
||||
toast.value = null
|
||||
}, 3200)
|
||||
}
|
||||
|
||||
/**
|
||||
* 이전 화면에서 전달한 토스트 표시
|
||||
* @returns {void}
|
||||
*/
|
||||
const showStoredToast = () => {
|
||||
const storedToast = sessionStorage.getItem('SORI_ADMIN_POST_TOAST')
|
||||
|
||||
if (!storedToast) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedToast = JSON.parse(storedToast)
|
||||
showToast(parsedToast.type || 'success', parsedToast.message || '저장되었습니다.')
|
||||
} finally {
|
||||
sessionStorage.removeItem('SORI_ADMIN_POST_TOAST')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 미리보기
|
||||
* @param {Object} payload - 게시물 입력값
|
||||
* @returns {void}
|
||||
*/
|
||||
const previewPost = (payload) => {
|
||||
localStorage.setItem('SORI_ADMIN_POST_PREVIEW', JSON.stringify({
|
||||
...payload,
|
||||
id: id.value,
|
||||
previewedAt: new Date().toISOString()
|
||||
}))
|
||||
window.open('/admin/posts/preview', '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 수정 저장
|
||||
* @param {Object} payload - 게시물 입력값
|
||||
* @returns {Promise<void>} 저장 결과
|
||||
*/
|
||||
const savePost = async (payload) => {
|
||||
saving.value = true
|
||||
errorMessage.value = ''
|
||||
showToast('info', '변경 내용을 저장하는 중입니다.')
|
||||
|
||||
try {
|
||||
const updatedPost = await $fetch(`/admin/api/posts/${id.value}`, {
|
||||
method: 'PUT',
|
||||
body: payload
|
||||
})
|
||||
|
||||
post.value = updatedPost
|
||||
postForm.value?.clearAutosave()
|
||||
showToast('success', '변경 내용이 저장되었습니다.')
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '글을 저장하지 못했습니다.'
|
||||
showToast('error', errorMessage.value)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 삭제
|
||||
* @returns {Promise<void>} 삭제 처리 결과
|
||||
*/
|
||||
const deletePost = async () => {
|
||||
if (!confirm(`"${post.value.title}" 글을 삭제할까요?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
deleting.value = true
|
||||
errorMessage.value = ''
|
||||
showToast('info', '글을 삭제하는 중입니다.')
|
||||
|
||||
try {
|
||||
await $fetch(`/admin/api/posts/${id.value}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
await navigateTo('/admin/posts')
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '글을 삭제하지 못했습니다.'
|
||||
showToast('error', errorMessage.value)
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(showStoredToast)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.clearTimeout(toastTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-post-edit bg-paper p-6">
|
||||
<div class="admin-post-edit__header mb-8 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="admin-post-edit__eyebrow text-xs font-semibold uppercase text-muted">
|
||||
Posts
|
||||
</p>
|
||||
<h1 class="admin-post-edit__title mt-2 text-3xl font-semibold">
|
||||
글 수정
|
||||
</h1>
|
||||
</div>
|
||||
<div class="admin-post-edit__actions flex gap-2">
|
||||
<NuxtLink
|
||||
v-if="isPublicPost(post)"
|
||||
class="admin-post-edit__view rounded border border-line bg-white px-4 py-2 text-sm font-semibold"
|
||||
:to="`/post/${post.slug}`"
|
||||
target="_blank"
|
||||
>
|
||||
보기
|
||||
</NuxtLink>
|
||||
<button
|
||||
class="admin-post-edit__delete rounded border border-red-200 bg-white px-4 py-2 text-sm font-semibold text-red-700 disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="deleting"
|
||||
@click="deletePost"
|
||||
>
|
||||
{{ deleting ? '삭제 중' : '삭제' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="errorMessage" class="admin-post-edit__error mb-5 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<AdminPostForm ref="postForm" :initial-post="post" submit-label="변경 저장" :saving="saving" @submit="savePost" @preview="previewPost" />
|
||||
<div
|
||||
v-if="toast"
|
||||
class="admin-post-edit__toast fixed right-5 top-5 z-50 rounded border px-4 py-3 text-sm font-semibold shadow-lg"
|
||||
:class="{
|
||||
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
|
||||
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
|
||||
'border-line bg-white text-ink': toast.type === 'info'
|
||||
}"
|
||||
role="status"
|
||||
>
|
||||
{{ toast.message }}
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
167
pages/admin/posts/index.vue
Normal file
167
pages/admin/posts/index.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
|
||||
const deletingId = ref('')
|
||||
const errorMessage = ref('')
|
||||
|
||||
const { data: posts, refresh } = await useFetch('/admin/api/posts', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
/**
|
||||
* 날짜 표시 형식 변환
|
||||
* @param {string | null} value - ISO 날짜 문자열
|
||||
* @returns {string} 화면 표시 날짜
|
||||
*/
|
||||
const formatDate = (value) => {
|
||||
if (!value) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
|
||||
return `${year}.${month}.${day}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 공개 여부 확인
|
||||
* @param {Object} post - 게시물
|
||||
* @returns {boolean} 공개 여부
|
||||
*/
|
||||
const isPublicPost = (post) => post.status === 'published'
|
||||
&& (!post.publishedAt || new Date(post.publishedAt) <= new Date())
|
||||
|
||||
/**
|
||||
* 게시물 상태 표시 문자열 생성
|
||||
* @param {Object} post - 게시물
|
||||
* @returns {string} 상태 표시 문자열
|
||||
*/
|
||||
const getPostStatusLabel = (post) => {
|
||||
if (post.status === 'published' && !isPublicPost(post)) {
|
||||
return '예약'
|
||||
}
|
||||
|
||||
if (post.status === 'published') {
|
||||
return '발행'
|
||||
}
|
||||
|
||||
if (post.status === 'private') {
|
||||
return '비공개'
|
||||
}
|
||||
|
||||
return '초안'
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 삭제
|
||||
* @param {Object} post - 삭제할 게시물
|
||||
* @returns {Promise<void>} 삭제 처리 결과
|
||||
*/
|
||||
const deletePost = async (post) => {
|
||||
if (!confirm(`"${post.title}" 글을 삭제할까요?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
deletingId.value = post.id
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await $fetch(`/admin/api/posts/${post.id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '글을 삭제하지 못했습니다.'
|
||||
} finally {
|
||||
deletingId.value = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-posts bg-paper p-6">
|
||||
<div class="admin-posts__header flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="admin-posts__eyebrow text-xs font-semibold uppercase text-muted">
|
||||
Posts
|
||||
</p>
|
||||
<h1 class="admin-posts__title mt-2 text-3xl font-semibold">
|
||||
글 목록
|
||||
</h1>
|
||||
</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">
|
||||
/post/{{ post.slug }}
|
||||
</p>
|
||||
</td>
|
||||
<td class="admin-posts__cell px-4 py-4">
|
||||
<span
|
||||
class="admin-posts__status rounded px-2 py-1 text-xs font-semibold"
|
||||
:class="{
|
||||
'bg-green-50 text-green-700': getPostStatusLabel(post) === '발행',
|
||||
'bg-blue-50 text-blue-700': getPostStatusLabel(post) === '예약',
|
||||
'bg-[#f5f5f2] text-muted': getPostStatusLabel(post) === '초안',
|
||||
'bg-red-50 text-red-700': getPostStatusLabel(post) === '비공개'
|
||||
}"
|
||||
>
|
||||
{{ getPostStatusLabel(post) }}
|
||||
</span>
|
||||
<p v-if="post.publishedAt" class="admin-posts__published-at mt-1 text-xs text-muted">
|
||||
{{ formatDate(post.publishedAt) }}
|
||||
</p>
|
||||
</td>
|
||||
<td class="admin-posts__cell px-4 py-4">
|
||||
{{ post.tags.join(', ') || '-' }}
|
||||
</td>
|
||||
<td class="admin-posts__cell px-4 py-4">
|
||||
{{ formatDate(post.updatedAt) }}
|
||||
</td>
|
||||
<td class="admin-posts__cell px-4 py-4">
|
||||
<button
|
||||
class="admin-posts__delete rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="deletingId === post.id"
|
||||
@click="deletePost(post)"
|
||||
>
|
||||
{{ deletingId === post.id ? '삭제 중' : '삭제' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-if="posts.length === 0" class="admin-posts__empty mt-6 text-sm text-muted">
|
||||
아직 작성된 글이 없습니다.
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
101
pages/admin/posts/new.vue
Normal file
101
pages/admin/posts/new.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const postForm = ref(null)
|
||||
const toast = ref(null)
|
||||
let toastTimer = null
|
||||
|
||||
/**
|
||||
* 저장 상태 토스트 표시
|
||||
* @param {'success'|'error'|'info'} type - 토스트 타입
|
||||
* @param {string} message - 표시 메시지
|
||||
* @returns {void}
|
||||
*/
|
||||
const showToast = (type, message) => {
|
||||
window.clearTimeout(toastTimer)
|
||||
toast.value = { type, message }
|
||||
toastTimer = window.setTimeout(() => {
|
||||
toast.value = null
|
||||
}, 3200)
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 게시물 미리보기
|
||||
* @param {Object} payload - 게시물 입력값
|
||||
* @returns {void}
|
||||
*/
|
||||
const previewPost = (payload) => {
|
||||
localStorage.setItem('SORI_ADMIN_POST_PREVIEW', JSON.stringify({
|
||||
...payload,
|
||||
previewedAt: new Date().toISOString()
|
||||
}))
|
||||
window.open('/admin/posts/preview', '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 게시물 저장
|
||||
* @param {Object} payload - 게시물 입력값
|
||||
* @returns {Promise<void>} 저장 결과
|
||||
*/
|
||||
const savePost = async (payload) => {
|
||||
saving.value = true
|
||||
errorMessage.value = ''
|
||||
showToast('info', '글을 저장하는 중입니다.')
|
||||
|
||||
try {
|
||||
const post = await $fetch('/admin/api/posts', {
|
||||
method: 'POST',
|
||||
body: payload
|
||||
})
|
||||
|
||||
postForm.value?.clearAutosave()
|
||||
sessionStorage.setItem('SORI_ADMIN_POST_TOAST', JSON.stringify({
|
||||
type: 'success',
|
||||
message: '글이 저장되었습니다.'
|
||||
}))
|
||||
await navigateTo(`/admin/posts/${post.id}`)
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '글을 저장하지 못했습니다.'
|
||||
showToast('error', errorMessage.value)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.clearTimeout(toastTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-post-editor bg-paper p-6">
|
||||
<div class="admin-post-editor__header mb-8">
|
||||
<p class="admin-post-editor__eyebrow text-xs font-semibold uppercase text-muted">
|
||||
Posts
|
||||
</p>
|
||||
<h1 class="admin-post-editor__title mt-2 text-3xl font-semibold">
|
||||
새 글 작성
|
||||
</h1>
|
||||
</div>
|
||||
<p v-if="errorMessage" class="admin-post-editor__error mb-5 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<AdminPostForm ref="postForm" submit-label="글 저장" :saving="saving" @submit="savePost" @preview="previewPost" />
|
||||
<div
|
||||
v-if="toast"
|
||||
class="admin-post-editor__toast fixed right-5 top-5 z-50 rounded border px-4 py-3 text-sm font-semibold shadow-lg"
|
||||
:class="{
|
||||
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
|
||||
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
|
||||
'border-line bg-white text-ink': toast.type === 'info'
|
||||
}"
|
||||
role="status"
|
||||
>
|
||||
{{ toast.message }}
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
54
pages/admin/posts/preview.vue
Normal file
54
pages/admin/posts/preview.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'post'
|
||||
})
|
||||
|
||||
const previewPost = ref(null)
|
||||
const previewError = ref('')
|
||||
|
||||
/**
|
||||
* 미리보기 저장 값을 읽어 온다
|
||||
* @returns {void}
|
||||
*/
|
||||
const loadPreviewPost = () => {
|
||||
const rawPreview = localStorage.getItem('SORI_ADMIN_POST_PREVIEW')
|
||||
|
||||
if (!rawPreview) {
|
||||
previewError.value = '미리보기 데이터가 없습니다.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
previewPost.value = JSON.parse(rawPreview)
|
||||
} catch {
|
||||
previewError.value = '미리보기 데이터를 읽지 못했습니다.'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadPreviewPost)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-post-preview">
|
||||
<div class="admin-post-preview__banner mb-5 rounded border border-line bg-white px-4 py-3 text-sm font-semibold text-ink">
|
||||
관리자 미리보기
|
||||
</div>
|
||||
|
||||
<p v-if="previewError" class="admin-post-preview__error rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ previewError }}
|
||||
</p>
|
||||
|
||||
<ContentRenderer v-else-if="previewPost">
|
||||
<ProseHeaderCard>
|
||||
<p class="post-detail__eyebrow text-sm uppercase text-white/70">
|
||||
PREVIEW
|
||||
</p>
|
||||
<h1 class="post-detail__title mt-3 text-4xl font-semibold leading-tight">
|
||||
{{ previewPost.title || '제목 없음' }}
|
||||
</h1>
|
||||
</ProseHeaderCard>
|
||||
|
||||
<ContentMarkdownRenderer class="post-detail__content" :content="previewPost.content || ''" />
|
||||
</ContentRenderer>
|
||||
</div>
|
||||
</template>
|
||||
181
pages/admin/settings/index.vue
Normal file
181
pages/admin/settings/index.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const toast = ref(null)
|
||||
let toastTimer = null
|
||||
|
||||
const { data: settings } = await useFetch('/admin/api/settings')
|
||||
|
||||
const form = reactive({
|
||||
title: settings.value?.title || 'sori.studio',
|
||||
description: settings.value?.description || '',
|
||||
siteUrl: settings.value?.siteUrl || 'https://sori.studio',
|
||||
logoText: settings.value?.logoText || '井',
|
||||
copyrightText: settings.value?.copyrightText || '©2026 sori.studio'
|
||||
})
|
||||
|
||||
/**
|
||||
* 저장 상태 토스트 표시
|
||||
* @param {'success'|'error'|'info'} type - 토스트 타입
|
||||
* @param {string} message - 표시 메시지
|
||||
* @returns {void}
|
||||
*/
|
||||
const showToast = (type, message) => {
|
||||
window.clearTimeout(toastTimer)
|
||||
toast.value = { type, message }
|
||||
toastTimer = window.setTimeout(() => {
|
||||
toast.value = null
|
||||
}, 3200)
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이트 설정 저장
|
||||
* @returns {Promise<void>} 저장 결과
|
||||
*/
|
||||
const saveSettings = async () => {
|
||||
saving.value = true
|
||||
errorMessage.value = ''
|
||||
showToast('info', '사이트 설정을 저장하는 중입니다.')
|
||||
|
||||
try {
|
||||
const updatedSettings = await $fetch('/admin/api/settings', {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
title: form.title,
|
||||
description: form.description,
|
||||
siteUrl: form.siteUrl,
|
||||
logoText: form.logoText,
|
||||
copyrightText: form.copyrightText
|
||||
}
|
||||
})
|
||||
|
||||
Object.assign(form, updatedSettings)
|
||||
showToast('success', '사이트 설정이 저장되었습니다.')
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '사이트 설정을 저장하지 못했습니다.'
|
||||
showToast('error', errorMessage.value)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.clearTimeout(toastTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-settings bg-paper p-6">
|
||||
<div class="admin-settings__header mb-8">
|
||||
<p class="admin-settings__eyebrow text-xs font-semibold uppercase text-muted">
|
||||
Settings
|
||||
</p>
|
||||
<h1 class="admin-settings__title mt-2 text-3xl font-semibold">
|
||||
사이트 설정
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="admin-settings__error mb-5 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<form class="admin-settings__form grid max-w-3xl gap-6" @submit.prevent="saveSettings">
|
||||
<label class="admin-settings__field grid gap-2 text-sm">
|
||||
<span class="admin-settings__label font-medium">사이트 이름</span>
|
||||
<input
|
||||
v-model="form.title"
|
||||
class="admin-settings__input rounded border border-line bg-white px-3 py-2"
|
||||
type="text"
|
||||
required
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="admin-settings__field grid gap-2 text-sm">
|
||||
<span class="admin-settings__label font-medium">사이트 설명</span>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
class="admin-settings__textarea min-h-28 resize-y rounded border border-line bg-white px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="admin-settings__field grid gap-2 text-sm">
|
||||
<span class="admin-settings__label font-medium">사이트 URL</span>
|
||||
<input
|
||||
v-model="form.siteUrl"
|
||||
class="admin-settings__input rounded border border-line bg-white px-3 py-2"
|
||||
type="url"
|
||||
required
|
||||
>
|
||||
</label>
|
||||
|
||||
<div class="admin-settings__grid grid gap-4 md:grid-cols-2">
|
||||
<label class="admin-settings__field grid gap-2 text-sm">
|
||||
<span class="admin-settings__label font-medium">로고 텍스트</span>
|
||||
<input
|
||||
v-model="form.logoText"
|
||||
class="admin-settings__input rounded border border-line bg-white px-3 py-2"
|
||||
type="text"
|
||||
maxlength="8"
|
||||
required
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="admin-settings__field grid gap-2 text-sm">
|
||||
<span class="admin-settings__label font-medium">저작권 문구</span>
|
||||
<input
|
||||
v-model="form.copyrightText"
|
||||
class="admin-settings__input rounded border border-line bg-white px-3 py-2"
|
||||
type="text"
|
||||
required
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="admin-settings__preview rounded border border-line bg-white p-5">
|
||||
<p class="admin-settings__preview-label text-xs font-semibold uppercase text-muted">
|
||||
공개 화면 미리보기
|
||||
</p>
|
||||
<div class="admin-settings__preview-body mt-4 flex items-center gap-3">
|
||||
<div class="admin-settings__preview-logo grid h-12 w-12 place-items-center rounded-2xl bg-[#15171a] text-2xl font-bold text-white">
|
||||
{{ form.logoText || '井' }}
|
||||
</div>
|
||||
<div>
|
||||
<p class="admin-settings__preview-title font-semibold">
|
||||
{{ form.title || 'sori.studio' }}
|
||||
</p>
|
||||
<p class="admin-settings__preview-description text-sm text-muted">
|
||||
{{ form.description || '사이트 설명이 공개 화면에 표시됩니다.' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-settings__actions flex justify-end border-t border-line pt-5">
|
||||
<button
|
||||
class="admin-settings__submit rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
||||
type="submit"
|
||||
:disabled="saving"
|
||||
>
|
||||
{{ saving ? '저장 중' : '설정 저장' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div
|
||||
v-if="toast"
|
||||
class="admin-settings__toast fixed right-5 top-5 z-50 rounded border px-4 py-3 text-sm font-semibold shadow-lg"
|
||||
:class="{
|
||||
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
|
||||
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
|
||||
'border-line bg-white text-ink': toast.type === 'info'
|
||||
}"
|
||||
role="status"
|
||||
>
|
||||
{{ toast.message }}
|
||||
</div>
|
||||
</section>
|
||||
</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>
|
||||
115
pages/admin/tags/index.vue
Normal file
115
pages/admin/tags/index.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
|
||||
const deletingId = ref('')
|
||||
const errorMessage = ref('')
|
||||
|
||||
const { data: tags, refresh } = await useFetch('/admin/api/tags', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
/**
|
||||
* 태그 삭제
|
||||
* @param {Object} tag - 삭제할 태그
|
||||
* @returns {Promise<void>} 삭제 처리 결과
|
||||
*/
|
||||
const deleteTag = async (tag) => {
|
||||
if (!confirm(`"${tag.name}" 태그를 삭제할까요? 연결된 글에서도 이 태그가 제거됩니다.`)) {
|
||||
return
|
||||
}
|
||||
|
||||
deletingId.value = tag.id
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await $fetch(`/admin/api/tags/${tag.id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '태그를 삭제하지 못했습니다.'
|
||||
} finally {
|
||||
deletingId.value = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-tags bg-paper p-6">
|
||||
<div class="admin-tags__header flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="admin-tags__eyebrow text-xs font-semibold uppercase text-muted">
|
||||
Tags
|
||||
</p>
|
||||
<h1 class="admin-tags__title mt-2 text-3xl font-semibold">
|
||||
태그 관리
|
||||
</h1>
|
||||
</div>
|
||||
<NuxtLink class="admin-tags__new rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white" to="/admin/tags/new">
|
||||
태그 추가
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="admin-tags__error mt-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<div class="admin-tags__table mt-8 overflow-hidden border border-line">
|
||||
<table class="admin-tags__table-inner w-full border-collapse text-left text-sm">
|
||||
<thead class="admin-tags__table-head bg-[#f5f5f2] text-xs uppercase text-muted">
|
||||
<tr>
|
||||
<th class="admin-tags__cell px-4 py-3">순서</th>
|
||||
<th class="admin-tags__cell px-4 py-3">색상</th>
|
||||
<th class="admin-tags__cell px-4 py-3">이름</th>
|
||||
<th class="admin-tags__cell px-4 py-3">슬러그</th>
|
||||
<th class="admin-tags__cell px-4 py-3">설명</th>
|
||||
<th class="admin-tags__cell px-4 py-3">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="admin-tags__table-body divide-y divide-line bg-white">
|
||||
<tr v-for="tag in tags" :key="tag.id" class="admin-tags__row">
|
||||
<td class="admin-tags__cell px-4 py-4 text-muted">
|
||||
{{ tag.sortOrder }}
|
||||
</td>
|
||||
<td class="admin-tags__cell px-4 py-4">
|
||||
<span class="admin-tags__color flex items-center gap-2">
|
||||
<span class="admin-tags__color-swatch h-5 w-2 rounded-full" :style="{ backgroundColor: tag.color }" />
|
||||
<span class="admin-tags__color-code text-xs text-muted">{{ tag.color }}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="admin-tags__cell px-4 py-4 font-semibold">
|
||||
{{ tag.name }}
|
||||
</td>
|
||||
<td class="admin-tags__cell px-4 py-4 text-muted">
|
||||
{{ tag.slug }}
|
||||
</td>
|
||||
<td class="admin-tags__cell px-4 py-4 text-muted">
|
||||
{{ tag.description || '-' }}
|
||||
</td>
|
||||
<td class="admin-tags__cell px-4 py-4">
|
||||
<div class="admin-tags__actions flex gap-2">
|
||||
<NuxtLink class="admin-tags__edit rounded border border-line px-3 py-1.5 text-xs font-semibold" :to="`/admin/tags/${tag.id}`">
|
||||
수정
|
||||
</NuxtLink>
|
||||
<button
|
||||
class="admin-tags__delete rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="deletingId === tag.id"
|
||||
@click="deleteTag(tag)"
|
||||
>
|
||||
{{ deletingId === tag.id ? '삭제 중' : '삭제' }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p v-if="tags.length === 0" class="admin-tags__empty mt-6 text-sm text-muted">
|
||||
아직 등록된 태그가 없습니다.
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
48
pages/admin/tags/new.vue
Normal file
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>
|
||||
102
pages/index.vue
Normal file
102
pages/index.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<script setup>
|
||||
const { data: posts } = await useFetch('/api/posts', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
/**
|
||||
* 날짜 표시 형식 변환
|
||||
* @param {string | null} value - ISO 날짜 문자열
|
||||
* @returns {string} 화면 표시 날짜
|
||||
*/
|
||||
const formatPostDate = (value) => {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
|
||||
return `${year}.${month}.${day}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 카드 데이터 변환
|
||||
* @param {Object} post - API 게시물
|
||||
* @returns {Object} 게시물 카드 데이터
|
||||
*/
|
||||
const mapPostCard = (post) => ({
|
||||
title: post.title,
|
||||
excerpt: post.excerpt,
|
||||
featuredImage: post.featuredImage,
|
||||
tag: post.tags?.[0]?.toUpperCase() || 'POST',
|
||||
publishedAt: formatPostDate(post.publishedAt),
|
||||
to: `/post/${post.slug}`
|
||||
})
|
||||
|
||||
const postCards = computed(() => posts.value.map(mapPostCard))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainColumn>
|
||||
<section class="home-hero site-section">
|
||||
<div class="home-hero__inner site-section-header text-center">
|
||||
<h1 class="home-hero__title mx-auto max-w-[620px] text-3xl font-semibold leading-tight tracking-normal md:text-[28px]">
|
||||
Ideas <em>published</em> for meaningful conversation, discussed and shaped by the community
|
||||
</h1>
|
||||
<p class="home-hero__description mx-auto mt-3 max-w-[500px] text-base leading-7 site-muted">
|
||||
글을 쌓고, 프로젝트와 링크를 연결하고, 오래 쓰기 좋은 개인 블로그를 직접 구축합니다.
|
||||
</p>
|
||||
<form class="home-hero__subscribe mx-auto mt-5 flex max-w-[345px] gap-2">
|
||||
<input class="home-hero__input min-w-0 flex-1 rounded-lg px-3 py-2 text-sm site-input" placeholder="Your email">
|
||||
<button class="home-hero__button rounded-lg px-5 py-2 text-sm font-semibold site-button" type="button">
|
||||
Subscribe
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="home-featured site-section">
|
||||
<div class="home-featured__header site-section-body flex items-center justify-between">
|
||||
<h2 class="home-featured__title text-sm font-semibold uppercase site-muted">
|
||||
Featured
|
||||
</h2>
|
||||
<div class="home-featured__controls flex gap-4">
|
||||
<span>‹</span>
|
||||
<span>›</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-featured__items grid grid-cols-1 gap-4 px-6 pb-6 md:grid-cols-3">
|
||||
<article class="home-featured__card h-[146px] rounded-lg bg-[linear-gradient(135deg,#071b22,#0f827c)] p-4 text-white">
|
||||
<h3 class="mt-20 text-sm font-semibold leading-tight">
|
||||
Essential tools and techniques for getting started
|
||||
</h3>
|
||||
</article>
|
||||
<article class="home-featured__card h-[146px] rounded-lg bg-[linear-gradient(135deg,#182434,#d4b06b)] p-4 text-white">
|
||||
<h3 class="mt-20 text-sm font-semibold leading-tight">
|
||||
Setting up your first home server from scratch
|
||||
</h3>
|
||||
</article>
|
||||
<article class="home-featured__card h-[146px] rounded-lg bg-[linear-gradient(135deg,#141414,#8a5a44)] p-4 text-white">
|
||||
<h3 class="mt-20 text-sm font-semibold leading-tight">
|
||||
Writing notes that stay useful over time
|
||||
</h3>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="home-latest site-section">
|
||||
<div class="home-latest__header site-section-body flex items-center justify-between">
|
||||
<h2 class="home-latest__title text-sm font-semibold uppercase site-muted">
|
||||
Latest
|
||||
</h2>
|
||||
<button class="home-latest__view rounded-lg px-3 py-2 text-sm site-input" type="button">
|
||||
목록
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PostCard v-for="post in postCards" :key="post.to" :post="post" />
|
||||
</MainColumn>
|
||||
</template>
|
||||
31
pages/pages/[slug].vue
Normal file
31
pages/pages/[slug].vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'page'
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const slug = computed(() => String(route.params.slug || ''))
|
||||
|
||||
const { data: page } = await useFetch(() => `/api/pages/${slug.value}`)
|
||||
|
||||
if (!page.value) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: '페이지를 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="static-page mx-auto min-h-screen max-w-3xl px-6 py-16">
|
||||
<p class="static-page__eyebrow text-xs font-semibold uppercase text-muted">
|
||||
Page
|
||||
</p>
|
||||
<h1 class="static-page__title mt-4 text-5xl font-semibold leading-tight">
|
||||
{{ page.title }}
|
||||
</h1>
|
||||
<ContentRenderer class="static-page__content mt-8">
|
||||
<ContentMarkdownRenderer :content="page.content" />
|
||||
</ContentRenderer>
|
||||
</article>
|
||||
</template>
|
||||
102
pages/post/[slug].vue
Normal file
102
pages/post/[slug].vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'post'
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const slug = computed(() => String(route.params.slug || ''))
|
||||
|
||||
const { data: post } = await useFetch(() => `/api/posts/${slug.value}`)
|
||||
|
||||
if (!post.value) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: '게시물을 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const postTag = computed(() => post.value.tags?.[0]?.toUpperCase() || 'POST')
|
||||
const config = useRuntimeConfig()
|
||||
const siteUrl = computed(() => String(config.public.siteUrl || '').replace(/\/$/g, ''))
|
||||
const pageUrl = computed(() => `${siteUrl.value}/post/${post.value.slug}`)
|
||||
const seoTitle = computed(() => post.value.seoTitle || post.value.title)
|
||||
const seoDescription = computed(() => post.value.seoDescription || post.value.excerpt || 'sori.studio 개인 블로그')
|
||||
const canonicalUrl = computed(() => post.value.canonicalUrl || pageUrl.value)
|
||||
const ogImage = computed(() => post.value.ogImage || post.value.featuredImage || '')
|
||||
|
||||
/**
|
||||
* 절대 URL 생성
|
||||
* @param {string} value - 원본 URL
|
||||
* @returns {string} 절대 URL
|
||||
*/
|
||||
const toAbsoluteUrl = (value) => {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
return `${siteUrl.value}${value.startsWith('/') ? value : `/${value}`}`
|
||||
}
|
||||
|
||||
useHead(() => ({
|
||||
title: seoTitle.value,
|
||||
link: [
|
||||
{
|
||||
rel: 'canonical',
|
||||
href: canonicalUrl.value
|
||||
}
|
||||
],
|
||||
meta: [
|
||||
{
|
||||
name: 'description',
|
||||
content: seoDescription.value
|
||||
},
|
||||
{
|
||||
name: 'robots',
|
||||
content: post.value.noindex ? 'noindex, nofollow' : 'index, follow'
|
||||
},
|
||||
{
|
||||
property: 'og:title',
|
||||
content: seoTitle.value
|
||||
},
|
||||
{
|
||||
property: 'og:description',
|
||||
content: seoDescription.value
|
||||
},
|
||||
{
|
||||
property: 'og:url',
|
||||
content: pageUrl.value
|
||||
},
|
||||
...(ogImage.value
|
||||
? [
|
||||
{
|
||||
property: 'og:image',
|
||||
content: toAbsoluteUrl(ogImage.value)
|
||||
},
|
||||
{
|
||||
name: 'twitter:card',
|
||||
content: 'summary_large_image'
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContentRenderer>
|
||||
<ProseHeaderCard>
|
||||
<p class="post-detail__eyebrow text-sm uppercase text-white/70">
|
||||
{{ postTag }}
|
||||
</p>
|
||||
<h1 class="post-detail__title mt-3 text-4xl font-semibold leading-tight">
|
||||
{{ post.title }}
|
||||
</h1>
|
||||
</ProseHeaderCard>
|
||||
|
||||
<ContentMarkdownRenderer class="post-detail__content" :content="post.content" />
|
||||
</ContentRenderer>
|
||||
</template>
|
||||
12
pages/posts/[slug].vue
Normal file
12
pages/posts/[slug].vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup>
|
||||
const route = useRoute()
|
||||
const slug = String(route.params.slug || '')
|
||||
|
||||
await navigateTo(`/post/${slug}`, {
|
||||
redirectCode: 301
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div />
|
||||
</template>
|
||||
49
pages/posts/index.vue
Normal file
49
pages/posts/index.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
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 postCards = computed(() => posts.value.map((post) => ({
|
||||
title: post.title,
|
||||
excerpt: post.excerpt,
|
||||
featuredImage: post.featuredImage,
|
||||
tag: post.tags?.[0]?.toUpperCase() || 'POST',
|
||||
publishedAt: formatPostDate(post.publishedAt),
|
||||
to: `/post/${post.slug}`
|
||||
})))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainColumn>
|
||||
<section class="posts-index site-section">
|
||||
<div class="posts-index__header site-section-header">
|
||||
<p class="posts-index__eyebrow text-xs font-semibold uppercase text-muted">
|
||||
Posts
|
||||
</p>
|
||||
<h1 class="posts-index__title mt-3 text-4xl font-semibold leading-tight">
|
||||
게시물
|
||||
</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PostCard v-for="post in postCards" :key="post.to" :post="post" />
|
||||
</MainColumn>
|
||||
</template>
|
||||
58
pages/tag/[slug].vue
Normal file
58
pages/tag/[slug].vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<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,
|
||||
featuredImage: post.featuredImage,
|
||||
tag: tag.value?.name || slug.value.toUpperCase(),
|
||||
publishedAt: formatPostDate(post.publishedAt),
|
||||
to: `/post/${post.slug}`
|
||||
})))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainColumn>
|
||||
<TagHeader
|
||||
:title="tag?.name || slug.toUpperCase()"
|
||||
:description="tag?.description || ''"
|
||||
/>
|
||||
<PostCard v-for="post in tagPosts" :key="post.to" :post="post" />
|
||||
<section v-if="tagPosts.length === 0" class="tag-posts site-section">
|
||||
<div class="tag-posts__empty site-section-body text-sm text-muted">
|
||||
이 태그에 연결된 글이 없습니다.
|
||||
</div>
|
||||
</section>
|
||||
</MainColumn>
|
||||
</template>
|
||||
12
pages/tags/[slug].vue
Normal file
12
pages/tags/[slug].vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup>
|
||||
const route = useRoute()
|
||||
const slug = String(route.params.slug || '')
|
||||
|
||||
await navigateTo(`/tag/${slug}`, {
|
||||
redirectCode: 301
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div />
|
||||
</template>
|
||||
45
pages/tags/index.vue
Normal file
45
pages/tags/index.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup>
|
||||
const { data: tags } = await useFetch('/api/tags', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
const { data: posts } = await useFetch('/api/posts', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
/**
|
||||
* 태그에 연결된 게시물 수 조회
|
||||
* @param {string} slug - 태그 슬러그
|
||||
* @returns {number} 게시물 수
|
||||
*/
|
||||
const getPostCount = (slug) => posts.value.filter((post) => post.tags.includes(slug)).length
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainColumn>
|
||||
<section class="tags-index site-section">
|
||||
<div class="tags-index__header site-section-header">
|
||||
<p class="tags-index__eyebrow text-xs font-semibold uppercase text-muted">
|
||||
Tags
|
||||
</p>
|
||||
<h1 class="tags-index__title mt-3 text-4xl font-semibold leading-tight">
|
||||
태그
|
||||
</h1>
|
||||
</div>
|
||||
<div class="tags-index__body site-section-body grid gap-3">
|
||||
<NuxtLink
|
||||
v-for="tag in tags"
|
||||
:key="tag.id"
|
||||
class="tags-index__item flex items-center justify-between gap-4 rounded border border-line bg-white px-4 py-3 hover:opacity-80"
|
||||
:to="`/tag/${tag.slug}`"
|
||||
>
|
||||
<span class="tags-index__name flex items-center gap-3 font-semibold">
|
||||
<span class="tags-index__color h-4 w-1 rounded-full" :style="{ backgroundColor: tag.color }" />
|
||||
{{ tag.name }}
|
||||
</span>
|
||||
<span class="tags-index__count text-sm text-muted">{{ getPostCount(tag.slug) }} posts</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
</MainColumn>
|
||||
</template>
|
||||
75
scripts/dev-server.js
Normal file
75
scripts/dev-server.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
const host = '127.0.0.1'
|
||||
const port = '43117'
|
||||
let printedLinks = false
|
||||
|
||||
const nuxtProcess = spawn('nuxt', [
|
||||
'dev',
|
||||
'--dotenv',
|
||||
'.env.development',
|
||||
'--host',
|
||||
host,
|
||||
'--port',
|
||||
port
|
||||
], {
|
||||
shell: true,
|
||||
stdio: ['inherit', 'pipe', 'pipe']
|
||||
})
|
||||
|
||||
/**
|
||||
* 개발 서버 링크 출력
|
||||
* @param {string} localUrl - Nuxt 로컬 URL
|
||||
* @returns {void}
|
||||
*/
|
||||
const printDevLinks = (localUrl) => {
|
||||
const url = new URL(localUrl)
|
||||
const origin = `${url.protocol}//${url.hostname}:${url.port}`
|
||||
const localhostOrigin = `${url.protocol}//localhost:${url.port}`
|
||||
|
||||
console.log('')
|
||||
console.log('sori.studio 개발 서버')
|
||||
console.log(`- Localhost: ${localhostOrigin}/`)
|
||||
console.log(`- Local IP: ${origin}/`)
|
||||
console.log(`- Admin: ${origin}/admin`)
|
||||
console.log(`- Tailwind Viewer: ${origin}/_tailwind/`)
|
||||
console.log('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Nuxt 로그 처리
|
||||
* @param {Buffer} chunk - 로그 버퍼
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleNuxtLog = (chunk) => {
|
||||
const text = chunk.toString()
|
||||
|
||||
for (const line of text.split('\n')) {
|
||||
const localMatch = line.match(/Local:\s+(https?:\/\/[^\s]+)/)
|
||||
|
||||
if (localMatch && !printedLinks) {
|
||||
printedLinks = true
|
||||
printDevLinks(localMatch[1])
|
||||
continue
|
||||
}
|
||||
|
||||
if (/(^|\s)(error:|warn:|warning:|failed|fatal|eaddrinuse|cannot)\b/i.test(line)) {
|
||||
console.log(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nuxtProcess.stdout.on('data', handleNuxtLog)
|
||||
nuxtProcess.stderr.on('data', handleNuxtLog)
|
||||
|
||||
nuxtProcess.on('close', (code) => {
|
||||
process.exit(code || 0)
|
||||
})
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
nuxtProcess.kill('SIGINT')
|
||||
})
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
nuxtProcess.kill('SIGTERM')
|
||||
})
|
||||
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()
|
||||
7
server/api/navigation.get.js
Normal file
7
server/api/navigation.get.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { getPublicNavigation } from '../repositories/content-repository'
|
||||
|
||||
/**
|
||||
* 공개 네비게이션 API
|
||||
* @returns {Promise<{primary: Array<Object>, footer: Array<Object>}>} 위치별 네비게이션 항목
|
||||
*/
|
||||
export default defineEventHandler(() => getPublicNavigation())
|
||||
7
server/api/pages.get.js
Normal file
7
server/api/pages.get.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { listPages } from '../repositories/content-repository'
|
||||
|
||||
/**
|
||||
* 공개 고정 페이지 목록 API
|
||||
* @returns {Array} 고정 페이지 목록
|
||||
*/
|
||||
export default defineEventHandler(() => listPages())
|
||||
20
server/api/pages/[slug].get.js
Normal file
20
server/api/pages/[slug].get.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { getPageBySlug } from '../../repositories/content-repository'
|
||||
|
||||
/**
|
||||
* 공개 고정 페이지 상세 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Object} 고정 페이지 상세
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const slug = getRouterParam(event, 'slug')
|
||||
const page = await getPageBySlug(slug)
|
||||
|
||||
if (!page) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: '페이지를 찾을 수 없습니다'
|
||||
})
|
||||
}
|
||||
|
||||
return page
|
||||
})
|
||||
7
server/api/posts.get.js
Normal file
7
server/api/posts.get.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { listPosts } from '../repositories/content-repository'
|
||||
|
||||
/**
|
||||
* 공개 게시물 목록 API
|
||||
* @returns {Array} 게시물 목록
|
||||
*/
|
||||
export default defineEventHandler(() => listPosts())
|
||||
20
server/api/posts/[slug].get.js
Normal file
20
server/api/posts/[slug].get.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { getPostBySlug } from '../../repositories/content-repository'
|
||||
|
||||
/**
|
||||
* 공개 게시물 상세 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Object} 게시물 상세
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const slug = getRouterParam(event, 'slug')
|
||||
const post = await getPostBySlug(slug)
|
||||
|
||||
if (!post) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: '게시물을 찾을 수 없습니다'
|
||||
})
|
||||
}
|
||||
|
||||
return post
|
||||
})
|
||||
7
server/api/site-settings.get.js
Normal file
7
server/api/site-settings.get.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { getSiteSettings } from '../repositories/content-repository'
|
||||
|
||||
/**
|
||||
* 공개 사이트 설정 API
|
||||
* @returns {Promise<Object>} 사이트 설정
|
||||
*/
|
||||
export default defineEventHandler(() => getSiteSettings())
|
||||
7
server/api/tags.get.js
Normal file
7
server/api/tags.get.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { listTags } from '../repositories/content-repository'
|
||||
|
||||
/**
|
||||
* 공개 태그 목록 API
|
||||
* @returns {Array} 태그 목록
|
||||
*/
|
||||
export default defineEventHandler(() => listTags())
|
||||
784
server/repositories/content-repository.js
Normal file
784
server/repositories/content-repository.js
Normal file
@@ -0,0 +1,784 @@
|
||||
import {
|
||||
getSamplePageBySlug,
|
||||
getSamplePages,
|
||||
getSamplePostBySlug,
|
||||
getSamplePosts,
|
||||
getSampleTags
|
||||
} from '../utils/sample-content'
|
||||
import { getDefaultNavigationItems, groupNavigationItems } from '../utils/navigation-items'
|
||||
import { getDefaultSiteSettings } from '../utils/site-settings'
|
||||
import { getPostgresClient } from './postgres-client'
|
||||
|
||||
/**
|
||||
* 게시물 행을 API 응답 구조로 변환
|
||||
* @param {Object} row - 게시물 행
|
||||
* @returns {Object} 게시물 응답
|
||||
*/
|
||||
const mapPostRow = (row) => ({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
slug: row.slug,
|
||||
content: row.content,
|
||||
excerpt: row.excerpt,
|
||||
featuredImage: row.featured_image,
|
||||
seoTitle: row.seo_title || '',
|
||||
seoDescription: row.seo_description || '',
|
||||
canonicalUrl: row.canonical_url || '',
|
||||
noindex: Boolean(row.noindex),
|
||||
ogImage: row.og_image || null,
|
||||
status: row.status,
|
||||
publishedAt: row.published_at ? row.published_at.toISOString() : null,
|
||||
createdAt: row.created_at.toISOString(),
|
||||
updatedAt: row.updated_at.toISOString(),
|
||||
tags: row.tags || []
|
||||
})
|
||||
|
||||
/**
|
||||
* 고정 페이지 행을 API 응답 구조로 변환
|
||||
* @param {Object} row - 고정 페이지 행
|
||||
* @returns {Object} 고정 페이지 응답
|
||||
*/
|
||||
const mapPageRow = (row) => ({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
slug: row.slug,
|
||||
content: row.content,
|
||||
featuredImage: row.featured_image,
|
||||
createdAt: row.created_at.toISOString(),
|
||||
updatedAt: row.updated_at.toISOString()
|
||||
})
|
||||
|
||||
/**
|
||||
* 태그 행을 API 응답 구조로 변환
|
||||
* @param {Object} row - 태그 행
|
||||
* @returns {Object} 태그 응답
|
||||
*/
|
||||
const mapTagRow = (row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
slug: row.slug,
|
||||
description: row.description,
|
||||
sortOrder: row.sort_order,
|
||||
color: row.color
|
||||
})
|
||||
|
||||
/**
|
||||
* 사이트 설정 행을 API 응답 구조로 변환
|
||||
* @param {Object} row - 사이트 설정 행
|
||||
* @returns {Object} 사이트 설정 응답
|
||||
*/
|
||||
const mapSiteSettingsRow = (row) => ({
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
siteUrl: row.site_url,
|
||||
logoText: row.logo_text,
|
||||
copyrightText: row.copyright_text,
|
||||
updatedAt: row.updated_at.toISOString()
|
||||
})
|
||||
|
||||
/**
|
||||
* 네비게이션 행을 API 응답 구조로 변환
|
||||
* @param {Object} row - 네비게이션 행
|
||||
* @returns {Object} 네비게이션 응답
|
||||
*/
|
||||
const mapNavigationItemRow = (row) => ({
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
url: row.url,
|
||||
location: row.location,
|
||||
sortOrder: row.sort_order,
|
||||
isVisible: row.is_visible,
|
||||
createdAt: row.created_at.toISOString(),
|
||||
updatedAt: row.updated_at.toISOString()
|
||||
})
|
||||
|
||||
/**
|
||||
* 태그 슬러그 목록 정규화
|
||||
* @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>} 게시물 목록
|
||||
*/
|
||||
export const listPosts = 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
|
||||
WHERE posts.status = 'published'
|
||||
AND (
|
||||
posts.published_at IS NULL
|
||||
OR posts.published_at <= now()
|
||||
)
|
||||
GROUP BY posts.id
|
||||
ORDER BY posts.published_at DESC NULLS LAST, posts.created_at DESC
|
||||
`
|
||||
|
||||
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,
|
||||
seo_title,
|
||||
seo_description,
|
||||
canonical_url,
|
||||
noindex,
|
||||
og_image,
|
||||
status,
|
||||
published_at
|
||||
)
|
||||
VALUES (
|
||||
${input.title},
|
||||
${input.slug},
|
||||
${input.content},
|
||||
${input.excerpt},
|
||||
${input.featuredImage},
|
||||
${input.seoTitle},
|
||||
${input.seoDescription},
|
||||
${input.canonicalUrl},
|
||||
${input.noindex},
|
||||
${input.ogImage},
|
||||
${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},
|
||||
seo_title = ${input.seoTitle},
|
||||
seo_description = ${input.seoDescription},
|
||||
canonical_url = ${input.canonicalUrl},
|
||||
noindex = ${input.noindex},
|
||||
og_image = ${input.ogImage},
|
||||
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 - 게시물 슬러그
|
||||
* @returns {Promise<Object | null>} 게시물 상세
|
||||
*/
|
||||
export const getPostBySlug = async (slug) => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
return getSamplePostBySlug(slug)
|
||||
}
|
||||
|
||||
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.slug = ${slug}
|
||||
AND posts.status = 'published'
|
||||
AND (
|
||||
posts.published_at IS NULL
|
||||
OR posts.published_at <= now()
|
||||
)
|
||||
GROUP BY posts.id
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
return rows[0] ? mapPostRow(rows[0]) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 고정 페이지 목록 조회
|
||||
* @returns {Promise<Array>} 고정 페이지 목록
|
||||
*/
|
||||
export const listPages = async () => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
return getSamplePages()
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
SELECT *
|
||||
FROM pages
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
return rows.map(mapPageRow)
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 고정 페이지 목록 조회
|
||||
* @returns {Promise<Array>} 관리자 고정 페이지 목록
|
||||
*/
|
||||
export const listAdminPages = async () => listPages()
|
||||
|
||||
/**
|
||||
* 관리자 고정 페이지 상세 조회
|
||||
* @param {string} id - 페이지 ID
|
||||
* @returns {Promise<Object | null>} 관리자 고정 페이지 상세
|
||||
*/
|
||||
export const getAdminPageById = async (id) => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
return getSamplePages().find((page) => page.id === id) || null
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
SELECT *
|
||||
FROM pages
|
||||
WHERE id = ${id}
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
return rows[0] ? mapPageRow(rows[0]) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 고정 페이지 생성
|
||||
* @param {Object} input - 페이지 입력값
|
||||
* @returns {Promise<Object>} 생성된 페이지
|
||||
*/
|
||||
export const createAdminPage = async (input) => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
throw new Error('DATABASE_REQUIRED')
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
INSERT INTO pages (
|
||||
title,
|
||||
slug,
|
||||
content,
|
||||
featured_image
|
||||
)
|
||||
VALUES (
|
||||
${input.title},
|
||||
${input.slug},
|
||||
${input.content},
|
||||
${input.featuredImage}
|
||||
)
|
||||
RETURNING *
|
||||
`
|
||||
|
||||
return mapPageRow(rows[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 고정 페이지 수정
|
||||
* @param {string} id - 페이지 ID
|
||||
* @param {Object} input - 페이지 입력값
|
||||
* @returns {Promise<Object | null>} 수정된 페이지
|
||||
*/
|
||||
export const updateAdminPage = async (id, input) => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
throw new Error('DATABASE_REQUIRED')
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
UPDATE pages
|
||||
SET
|
||||
title = ${input.title},
|
||||
slug = ${input.slug},
|
||||
content = ${input.content},
|
||||
featured_image = ${input.featuredImage},
|
||||
updated_at = now()
|
||||
WHERE id = ${id}
|
||||
RETURNING *
|
||||
`
|
||||
|
||||
return rows[0] ? mapPageRow(rows[0]) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 고정 페이지 삭제
|
||||
* @param {string} id - 페이지 ID
|
||||
* @returns {Promise<boolean>} 삭제 여부
|
||||
*/
|
||||
export const deleteAdminPage = async (id) => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
throw new Error('DATABASE_REQUIRED')
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
DELETE FROM pages
|
||||
WHERE id = ${id}
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
return Boolean(rows[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 고정 페이지 상세 조회
|
||||
* @param {string} slug - 페이지 슬러그
|
||||
* @returns {Promise<Object | null>} 고정 페이지 상세
|
||||
*/
|
||||
export const getPageBySlug = async (slug) => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
return getSamplePageBySlug(slug)
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
SELECT *
|
||||
FROM pages
|
||||
WHERE slug = ${slug}
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
return rows[0] ? mapPageRow(rows[0]) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 태그 목록 조회
|
||||
* @returns {Promise<Array>} 태그 목록
|
||||
*/
|
||||
export const listTags = async () => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
return getSampleTags()
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
SELECT *
|
||||
FROM tags
|
||||
ORDER BY sort_order ASC, name ASC
|
||||
`
|
||||
|
||||
return rows.map(mapTagRow)
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이트 설정 조회
|
||||
* @returns {Promise<Object>} 사이트 설정
|
||||
*/
|
||||
export const getSiteSettings = async () => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
return getDefaultSiteSettings()
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
SELECT *
|
||||
FROM site_settings
|
||||
WHERE id = 1
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
return rows[0] ? mapSiteSettingsRow(rows[0]) : getDefaultSiteSettings()
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 사이트 설정 수정
|
||||
* @param {Object} input - 사이트 설정 입력값
|
||||
* @returns {Promise<Object>} 수정된 사이트 설정
|
||||
*/
|
||||
export const updateSiteSettings = async (input) => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
throw new Error('DATABASE_REQUIRED')
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
INSERT INTO site_settings (
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
site_url,
|
||||
logo_text,
|
||||
copyright_text,
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
1,
|
||||
${input.title},
|
||||
${input.description},
|
||||
${input.siteUrl},
|
||||
${input.logoText},
|
||||
${input.copyrightText},
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET
|
||||
title = EXCLUDED.title,
|
||||
description = EXCLUDED.description,
|
||||
site_url = EXCLUDED.site_url,
|
||||
logo_text = EXCLUDED.logo_text,
|
||||
copyright_text = EXCLUDED.copyright_text,
|
||||
updated_at = now()
|
||||
RETURNING *
|
||||
`
|
||||
|
||||
return mapSiteSettingsRow(rows[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* 네비게이션 항목 목록 조회
|
||||
* @param {Object} options - 조회 옵션
|
||||
* @param {boolean} options.visibleOnly - 표시 항목만 조회할지 여부
|
||||
* @returns {Promise<Array>} 네비게이션 항목 목록
|
||||
*/
|
||||
export const listNavigationItems = async ({ visibleOnly = false } = {}) => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
return getDefaultNavigationItems()
|
||||
.filter((item) => !visibleOnly || item.isVisible)
|
||||
}
|
||||
|
||||
const rows = visibleOnly
|
||||
? await sql`
|
||||
SELECT *
|
||||
FROM navigation_items
|
||||
WHERE is_visible = true
|
||||
ORDER BY location ASC, sort_order ASC, label ASC
|
||||
`
|
||||
: await sql`
|
||||
SELECT *
|
||||
FROM navigation_items
|
||||
ORDER BY location ASC, sort_order ASC, label ASC
|
||||
`
|
||||
|
||||
return rows.map(mapNavigationItemRow)
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 네비게이션 조회
|
||||
* @returns {Promise<{primary: Array<Object>, footer: Array<Object>}>} 위치별 공개 네비게이션
|
||||
*/
|
||||
export const getPublicNavigation = async () => groupNavigationItems(await listNavigationItems({ visibleOnly: true }))
|
||||
|
||||
/**
|
||||
* 관리자 네비게이션 항목 일괄 저장
|
||||
* @param {Array<Object>} items - 저장할 네비게이션 항목 목록
|
||||
* @returns {Promise<Array>} 저장된 네비게이션 항목 목록
|
||||
*/
|
||||
export const updateNavigationItems = async (items) => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
throw new Error('DATABASE_REQUIRED')
|
||||
}
|
||||
|
||||
await sql.begin(async (transaction) => {
|
||||
await transaction`
|
||||
DELETE FROM navigation_items
|
||||
`
|
||||
|
||||
for (const item of items) {
|
||||
await transaction`
|
||||
INSERT INTO navigation_items (
|
||||
label,
|
||||
url,
|
||||
location,
|
||||
sort_order,
|
||||
is_visible
|
||||
)
|
||||
VALUES (
|
||||
${item.label},
|
||||
${item.url},
|
||||
${item.location},
|
||||
${item.sortOrder},
|
||||
${item.isVisible}
|
||||
)
|
||||
`
|
||||
}
|
||||
})
|
||||
|
||||
return listNavigationItems()
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 태그 상세 조회
|
||||
* @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])
|
||||
}
|
||||
25
server/repositories/postgres-client.js
Normal file
25
server/repositories/postgres-client.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import postgres from 'postgres'
|
||||
|
||||
let client = null
|
||||
|
||||
/**
|
||||
* PostgreSQL 클라이언트 조회
|
||||
* @returns {ReturnType<typeof postgres> | null} PostgreSQL 클라이언트
|
||||
*/
|
||||
export const getPostgresClient = () => {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
if (!config.databaseUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!client) {
|
||||
client = postgres(config.databaseUrl, {
|
||||
max: 5,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10
|
||||
})
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
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/media-folders.get.js
Normal file
13
server/routes/admin/api/media-folders.get.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { requireAdminSession } from '../../../utils/admin-auth'
|
||||
import { listMediaFolders } from '../../../utils/media-library'
|
||||
|
||||
/**
|
||||
* 관리자 미디어 폴더 목록 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<Array<string>>} 미디어 폴더 경로 목록
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
|
||||
return listMediaFolders()
|
||||
})
|
||||
16
server/routes/admin/api/media-folders.post.js
Normal file
16
server/routes/admin/api/media-folders.post.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { readBody } from 'h3'
|
||||
import { requireAdminSession } from '../../../utils/admin-auth'
|
||||
import { createMediaFolder } from '../../../utils/media-library'
|
||||
|
||||
/**
|
||||
* 관리자 미디어 폴더 생성 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ path: string }>} 생성된 미디어 폴더
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
|
||||
const body = await readBody(event)
|
||||
|
||||
return createMediaFolder(body?.path || '')
|
||||
})
|
||||
20
server/routes/admin/api/media.delete.js
Normal file
20
server/routes/admin/api/media.delete.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { readBody } from 'h3'
|
||||
import { requireAdminSession } from '../../../utils/admin-auth'
|
||||
import { deleteMediaItem } from '../../../utils/media-library'
|
||||
|
||||
/**
|
||||
* 관리자 미디어 삭제 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ ok: boolean }>} 삭제 결과
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
|
||||
const body = await readBody(event)
|
||||
|
||||
await deleteMediaItem(body?.url)
|
||||
|
||||
return {
|
||||
ok: true
|
||||
}
|
||||
})
|
||||
13
server/routes/admin/api/media.get.js
Normal file
13
server/routes/admin/api/media.get.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { requireAdminSession } from '../../../utils/admin-auth'
|
||||
import { listMediaItems } from '../../../utils/media-library'
|
||||
|
||||
/**
|
||||
* 관리자 미디어 목록 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<Array<Object>>} 미디어 목록
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
|
||||
return listMediaItems()
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user