Compare commits
55 Commits
v0.0.1
...
3cb1290711
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cb1290711 | |||
| 4704748582 | |||
| 082c6a9619 | |||
| 5f2b2b8c4f | |||
| 41406ca852 | |||
| a439af5b62 | |||
| e9161676e9 | |||
| 9363c10451 | |||
| 1a4086336f | |||
| 5e485eb3ec | |||
| 4554801294 | |||
| 34314a5c7d | |||
| 4d7aaa90ca | |||
| 59ea51e550 | |||
| d47134c46d | |||
| 97d2d8ffb3 | |||
| f757c3db78 | |||
| 0f60039126 | |||
| 4e5ccb2726 | |||
| 38f8abb1ff | |||
| 5bda4d5472 | |||
| 398877fd92 | |||
| 60c5c3d5c9 | |||
| 9054c9625c | |||
| 1ef50c111b | |||
| 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
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -5,6 +5,7 @@ node_modules/
|
||||
.output/
|
||||
.nuxt/
|
||||
dist/
|
||||
public/uploads/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
@@ -26,4 +27,8 @@ Thumbs.db
|
||||
npm-debug.log*
|
||||
|
||||
# Test
|
||||
coverage/
|
||||
coverage/
|
||||
|
||||
# Reference theme sample (do not commit)
|
||||
ZCF-v1.0.5/
|
||||
sample 깃에 올리지말것/
|
||||
|
||||
@@ -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>
|
||||
197
assets/css/main.css
Normal file
197
assets/css/main.css
Normal file
@@ -0,0 +1,197 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--site-bg: #fcfcfc;
|
||||
--site-panel: #fcfcfc;
|
||||
--site-panel-strong: #fcfcfc;
|
||||
--site-text: #111111;
|
||||
--site-muted: #454545;
|
||||
--site-soft: #6f7480;
|
||||
--site-line: #e2e2e0;
|
||||
--site-input: #fcfcfc;
|
||||
--site-accent: #ff4f2e;
|
||||
--site-accent-text: #ffffff;
|
||||
--site-invert: #111111;
|
||||
--site-invert-text: #ffffff;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
--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;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme='light']) {
|
||||
--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);
|
||||
}
|
||||
|
||||
html.admin-post-editor-document,
|
||||
body.admin-post-editor-document {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.site-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
color: var(--site-text);
|
||||
background: var(--site-bg);
|
||||
}
|
||||
|
||||
.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: 0;
|
||||
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: 0;
|
||||
background: var(--site-panel);
|
||||
color: var(--site-text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이드바 내부 스크롤 영역 — 스크롤바만 숨기고 스크롤 동작은 유지한다.
|
||||
*/
|
||||
.site-sidebar-scroll {
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.site-sidebar-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.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);
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.site-button {
|
||||
background: var(--site-invert);
|
||||
color: var(--site-invert-text);
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.site-button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.site-accent-button {
|
||||
background: var(--site-accent);
|
||||
color: var(--site-accent-text);
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.site-accent-button:hover {
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.site-interactive {
|
||||
transition: color 0.2s ease, background-color 0.2s ease, opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.site-interactive:hover {
|
||||
color: var(--site-text);
|
||||
}
|
||||
|
||||
.site-input:hover,
|
||||
.site-input:focus-visible {
|
||||
border-color: color-mix(in srgb, var(--site-text) 24%, var(--site-line));
|
||||
}
|
||||
|
||||
.site-panel-hover {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.site-panel-hover:hover {
|
||||
background: color-mix(in srgb, var(--site-panel) 72%, var(--site-text));
|
||||
}
|
||||
|
||||
}
|
||||
1740
components/admin/AdminBlockEditor.vue
Normal file
1740
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>
|
||||
920
components/admin/AdminPostForm.vue
Normal file
920
components/admin/AdminPostForm.vue
Normal file
@@ -0,0 +1,920 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
initialPost: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
submitLabel: {
|
||||
type: String,
|
||||
default: '저장'
|
||||
},
|
||||
saving: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
canViewPost: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
publicUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
deleting: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showDelete: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['submit', 'preview', 'delete'])
|
||||
|
||||
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 autosaveTimer = ref(null)
|
||||
const autosaveNotice = ref(null)
|
||||
const autosaveStatus = ref('')
|
||||
const isRestoringAutosave = ref(false)
|
||||
const isSettingsOpen = ref(true)
|
||||
const tagInput = ref('')
|
||||
const activeMediaPickerTab = ref('upload')
|
||||
const selectedMediaPickerUrl = ref('')
|
||||
|
||||
/**
|
||||
* 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 || '',
|
||||
noindex: Boolean(props.initialPost.noindex),
|
||||
status: props.initialPost.status || 'draft',
|
||||
publishedAt: toDateTimeLocalValue(props.initialPost.publishedAt),
|
||||
tagsText: props.initialPost.tags?.join(', ') || ''
|
||||
})
|
||||
|
||||
const autosaveKey = computed(() => `${autosaveStoragePrefix}:${props.initialPost.id || 'new'}`)
|
||||
const postUrlLabel = computed(() => form.slug || toSlug(form.title) || '')
|
||||
const postUrlHint = computed(() => props.publicUrl || (postUrlLabel.value ? `/post/${postUrlLabel.value}/` : '/post/'))
|
||||
|
||||
/**
|
||||
* 문자열을 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))]
|
||||
|
||||
const selectedTags = computed(() => parseTags(form.tagsText))
|
||||
|
||||
/**
|
||||
* 예약 발행 여부 확인
|
||||
* @returns {boolean} 예약 발행 여부
|
||||
*/
|
||||
const isScheduledPost = () => {
|
||||
const publishedAt = toIsoDateTime(form.publishedAt)
|
||||
|
||||
return form.status === 'published' && Boolean(publishedAt) && new Date(publishedAt) > new Date()
|
||||
}
|
||||
|
||||
const editorStatusLabel = computed(() => {
|
||||
if (autosaveStatus.value) {
|
||||
return autosaveStatus.value
|
||||
}
|
||||
|
||||
if (form.status === 'published') {
|
||||
return isScheduledPost() ? '예약 발행' : '발행됨'
|
||||
}
|
||||
|
||||
if (form.status === 'private') {
|
||||
return '비공개'
|
||||
}
|
||||
|
||||
return '초안'
|
||||
})
|
||||
|
||||
/**
|
||||
* 게시물 입력값 생성
|
||||
* @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: '',
|
||||
noindex: form.noindex,
|
||||
ogImage: null,
|
||||
status: form.status,
|
||||
publishedAt,
|
||||
tags: selectedTags.value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 자동 저장 데이터 생성
|
||||
* @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,
|
||||
noindex: form.noindex,
|
||||
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.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
|
||||
activeMediaPickerTab.value = 'upload'
|
||||
selectedMediaPickerUrl.value = form[target] || ''
|
||||
isMediaPickerOpen.value = true
|
||||
await fetchMediaItems()
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지 선택 창 닫기
|
||||
* @returns {void}
|
||||
*/
|
||||
const closeMediaPicker = () => {
|
||||
isMediaPickerOpen.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지 선택 상태 변경
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {void}
|
||||
*/
|
||||
const selectPickedImage = (item) => {
|
||||
selectedMediaPickerUrl.value = item.url
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택한 대표 이미지 적용
|
||||
* @returns {void}
|
||||
*/
|
||||
const applyPickedImage = () => {
|
||||
if (!selectedMediaPickerUrl.value) {
|
||||
return
|
||||
}
|
||||
|
||||
form[mediaPickerTarget.value] = selectedMediaPickerUrl.value
|
||||
closeMediaPicker()
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지 삭제
|
||||
* @returns {void}
|
||||
*/
|
||||
const removeFeaturedImage = () => {
|
||||
form.featuredImage = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 태그 입력값을 배지 목록에 추가
|
||||
* @returns {void}
|
||||
*/
|
||||
const addTagFromInput = () => {
|
||||
const nextTag = toSlug(tagInput.value)
|
||||
|
||||
if (!nextTag) {
|
||||
tagInput.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
form.tagsText = [...new Set([...selectedTags.value, nextTag])].join(', ')
|
||||
tagInput.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 태그 배지 삭제
|
||||
* @param {string} tag - 삭제할 태그
|
||||
* @returns {void}
|
||||
*/
|
||||
const removeTag = (tag) => {
|
||||
form.tagsText = selectedTags.value.filter((item) => item !== tag).join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
* 태그 입력 키 처리
|
||||
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleTagKeydown = (event) => {
|
||||
if (event.key === 'Enter' || event.key === ',') {
|
||||
event.preventDefault()
|
||||
addTagFromInput()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Backspace' && !tagInput.value && selectedTags.value.length) {
|
||||
event.preventDefault()
|
||||
removeTag(selectedTags.value.at(-1))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지 파일 업로드
|
||||
* @param {File} file - 업로드 파일
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const uploadFeaturedImageFile = async (file) => {
|
||||
const formData = new FormData()
|
||||
formData.append('files', file)
|
||||
isUploadingFeaturedImage.value = true
|
||||
|
||||
try {
|
||||
const result = await $fetch('/admin/api/uploads', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
selectedMediaPickerUrl.value = result.files?.[0]?.url || ''
|
||||
await fetchMediaItems()
|
||||
activeMediaPickerTab.value = 'library'
|
||||
} finally {
|
||||
isUploadingFeaturedImage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지 파일 입력 처리
|
||||
* @param {Event} event - 파일 입력 이벤트
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const uploadFeaturedImage = async (event) => {
|
||||
const files = event.target.files
|
||||
|
||||
if (!files?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await uploadFeaturedImageFile(files[0])
|
||||
} finally {
|
||||
event.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지 드롭 업로드
|
||||
* @param {DragEvent} event - 드롭 이벤트
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const dropFeaturedImage = async (event) => {
|
||||
const files = event.dataTransfer?.files
|
||||
|
||||
if (!files?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
await uploadFeaturedImageFile(files[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* 제목 입력 후 본문 에디터로 이동
|
||||
* @returns {void}
|
||||
*/
|
||||
const focusContentEditor = () => {
|
||||
blockEditor.value?.focusFirstBlock()
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 입력값 제출
|
||||
* @returns {void}
|
||||
*/
|
||||
const submitPost = () => {
|
||||
emit('submit', createPostPayload())
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 미리보기 요청
|
||||
* @returns {void}
|
||||
*/
|
||||
const previewPost = () => {
|
||||
emit('preview', createPostPayload())
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 삭제 요청
|
||||
* @returns {void}
|
||||
*/
|
||||
const deletePost = () => {
|
||||
emit('delete')
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 패널 표시 상태 전환
|
||||
* @returns {void}
|
||||
*/
|
||||
const toggleSettingsPanel = () => {
|
||||
isSettingsOpen.value = !isSettingsOpen.value
|
||||
}
|
||||
|
||||
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 flex h-screen min-h-screen overflow-hidden bg-white" @submit.prevent="submitPost">
|
||||
<div class="admin-post-form__workspace flex min-w-0 flex-1 flex-col bg-white">
|
||||
<header class="admin-post-form__toolbar flex h-[56px] shrink-0 items-center bg-white px-8">
|
||||
<div class="admin-post-form__toolbar-inner flex h-[34px] min-w-0 flex-1 items-center justify-between">
|
||||
<div class="admin-post-form__toolbar-left flex h-full min-w-0 items-center gap-3">
|
||||
<NuxtLink class="admin-post-form__toolbar-link inline-flex items-center gap-2 rounded px-2 py-1.5 text-sm font-medium text-[#394047] transition-colors hover:bg-[#eff1f2] hover:text-black" to="/admin/posts">
|
||||
<span class="admin-post-form__toolbar-back text-lg leading-none" aria-hidden="true"><</span>
|
||||
<span>Posts</span>
|
||||
</NuxtLink>
|
||||
<span class="admin-post-form__toolbar-status truncate rounded px-2 py-1.5 text-sm text-[#8e9cac]">
|
||||
{{ editorStatusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="admin-post-form__toolbar-actions flex h-full shrink-0 items-center gap-2">
|
||||
<button
|
||||
class="admin-post-form__toolbar-preview rounded px-3 py-1.5 text-sm font-bold text-[#394047] transition-colors hover:bg-[#eff1f2] hover:text-black"
|
||||
type="button"
|
||||
@click="previewPost"
|
||||
>
|
||||
미리보기
|
||||
</button>
|
||||
<button
|
||||
class="admin-post-form__toolbar-submit rounded px-3 py-1.5 text-sm font-bold text-[#2bba3c] transition-colors hover:bg-[#eaf8ec] hover:text-[#159624] disabled:opacity-50"
|
||||
type="submit"
|
||||
:disabled="saving"
|
||||
>
|
||||
{{ saving ? '저장 중' : submitLabel }}
|
||||
</button>
|
||||
<button
|
||||
class="admin-post-form__settings-toggle grid size-[34px] place-items-center rounded text-[#394047] transition-colors hover:bg-[#eff1f2] hover:text-black"
|
||||
type="button"
|
||||
:aria-pressed="isSettingsOpen"
|
||||
aria-label="게시물 설정 패널 전환"
|
||||
@click="toggleSettingsPanel"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 2.16699C14.3242 2.16699 14.4998 2.39365 14.5 2.57129V13.4287C14.5 13.606 14.3242 13.834 14 13.834H11.5V2.16699H14ZM2 2.16699H10.5V13.834H2C1.6756 13.834 1.5 13.6064 1.5 13.4287V2.57129C1.50024 2.39409 1.67607 2.16699 2 2.16699Z" stroke="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="admin-post-form__editor-scroll min-h-0 flex-1 overflow-y-auto">
|
||||
<section class="admin-post-form__content mx-auto w-full max-w-[804px] px-8 pb-16 pt-32">
|
||||
<div class="admin-post-form__feature-block mb-9">
|
||||
<figure v-if="form.featuredImage" class="admin-post-form__featured-editor group relative overflow-hidden bg-white">
|
||||
<img class="admin-post-form__featured-editor-image aspect-[16/9] w-full bg-surface object-cover" :src="form.featuredImage" alt="">
|
||||
<figcaption class="admin-post-form__featured-editor-actions pointer-events-none absolute inset-0 flex items-end justify-end gap-2 bg-gradient-to-t from-black/40 via-black/5 to-transparent p-4 opacity-0 transition-opacity duration-150 group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100">
|
||||
<button class="admin-post-form__featured-change rounded bg-white/95 px-3 py-1.5 text-xs font-semibold text-[#15171a] shadow-sm transition-colors hover:bg-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white" type="button" @click="openMediaPicker('featuredImage')">
|
||||
변경
|
||||
</button>
|
||||
<button class="admin-post-form__featured-remove rounded bg-[#fff1f2]/95 px-3 py-1.5 text-xs font-semibold text-red-700 shadow-sm transition-colors hover:bg-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white" type="button" @click="removeFeaturedImage">
|
||||
삭제
|
||||
</button>
|
||||
</figcaption>
|
||||
</figure>
|
||||
<div v-else class="admin-post-form__feature-empty flex h-6 items-start">
|
||||
<button class="admin-post-form__feature-add inline-flex items-center gap-1.5 rounded px-2 py-1 text-sm text-[#8e9cac] transition-colors hover:bg-[#eff1f2] hover:text-[#394047]" type="button" @click="openMediaPicker('featuredImage')">
|
||||
<span aria-hidden="true">+</span>
|
||||
<span>대표 이미지 추가</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
v-model="form.title"
|
||||
class="admin-post-form__title-input mb-2 w-full border-0 bg-transparent px-0 py-0 text-5xl font-bold leading-tight text-ink outline-none placeholder:text-[#8e9cac]"
|
||||
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 transition-colors hover:bg-black" 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 transition-colors hover:bg-[#eff1f2]" type="button" @click="discardAutosave">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-post-form__field admin-post-form__content-editor text-sm">
|
||||
<AdminBlockEditor ref="blockEditor" v-model="form.content" />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<aside
|
||||
class="admin-post-form__settings flex h-screen shrink-0 flex-col overflow-hidden border-[#e3e6e8] bg-white transition-[width,border-color] duration-300 ease-out"
|
||||
:class="isSettingsOpen ? 'w-[420px] border-l' : 'w-0 border-l-0'"
|
||||
:aria-hidden="!isSettingsOpen"
|
||||
>
|
||||
<div class="admin-post-form__settings-inner flex h-full w-[420px] flex-col">
|
||||
<div class="admin-post-form__settings-header flex h-[56px] shrink-0 items-center justify-between px-6">
|
||||
<h2 class="admin-post-form__settings-title text-xl font-bold text-black">
|
||||
게시물 설정
|
||||
</h2>
|
||||
<button class="admin-post-form__settings-close grid size-8 place-items-center rounded text-[#394047] transition-colors hover:bg-[#eff1f2] hover:text-black" type="button" aria-label="게시물 설정 닫기" @click="toggleSettingsPanel">
|
||||
<span aria-hidden="true">x</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-post-form__settings-body grid flex-1 content-start gap-4 overflow-y-auto px-6 pb-8 pt-8">
|
||||
<div class="admin-post-form__field grid gap-1 text-sm">
|
||||
<div class="admin-post-form__post-url-header flex h-[22px] items-center justify-between">
|
||||
<span class="admin-post-form__label font-bold text-[#15171a]">Post URL</span>
|
||||
<NuxtLink
|
||||
v-if="canViewPost"
|
||||
class="admin-post-form__view-post inline-flex items-center gap-1 rounded px-3 py-1.5 text-xs text-[#8e9cac] transition-colors hover:bg-[#eff1f2] hover:text-[#394047]"
|
||||
:to="publicUrl"
|
||||
target="_blank"
|
||||
>
|
||||
<span>View Post</span>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<label class="admin-post-form__post-url-input flex h-[38px] items-center gap-2 rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-1 transition-colors hover:border-[#c8ced3] focus-within:border-[#8e9cac]">
|
||||
<span class="admin-post-form__post-url-icon text-sm text-[#394047]" aria-hidden="true">⌘</span>
|
||||
<input
|
||||
v-model="form.slug"
|
||||
class="admin-post-form__input min-w-0 flex-1 border-0 bg-transparent p-0 text-sm text-black outline-none"
|
||||
type="text"
|
||||
pattern="[a-z0-9가-힣]+(-[a-z0-9가-힣]+)*"
|
||||
required
|
||||
@input="touchSlug"
|
||||
>
|
||||
</label>
|
||||
<p class="admin-post-form__post-url-hint text-xs text-[#7c8b9a]">
|
||||
{{ postUrlHint }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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 h-[38px] rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none">
|
||||
<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 h-[38px] rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none"
|
||||
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>
|
||||
<textarea
|
||||
v-model="form.excerpt"
|
||||
class="admin-post-form__textarea min-h-[108px] rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="admin-post-form__field grid gap-1 text-sm">
|
||||
<span class="admin-post-form__label h-[22px] font-bold text-[#15171a]">Tags</span>
|
||||
<label class="admin-post-form__tag-editor flex min-h-[38px] w-full items-center gap-2 rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-1 transition-colors hover:border-[#c8ced3] focus-within:border-[#8e9cac]">
|
||||
<span
|
||||
v-for="tag in selectedTags"
|
||||
:key="tag"
|
||||
class="admin-post-form__tag-badge inline-flex h-6 shrink-0 items-center gap-1.5 rounded-[3px] bg-[#ecd2de] px-2 text-sm text-[#e04e87]"
|
||||
>
|
||||
<span>{{ tag }}</span>
|
||||
<button
|
||||
class="admin-post-form__tag-remove grid size-3 place-items-center rounded text-[#e04e87] transition-colors hover:bg-[#e7c3d2]"
|
||||
type="button"
|
||||
:aria-label="`${tag} 태그 삭제`"
|
||||
@click="removeTag(tag)"
|
||||
>
|
||||
<span aria-hidden="true">x</span>
|
||||
</button>
|
||||
</span>
|
||||
<input
|
||||
v-model="tagInput"
|
||||
class="admin-post-form__tag-input min-w-0 flex-1 border-0 bg-transparent p-0 text-sm text-black outline-none placeholder:text-[#8e9cac]"
|
||||
type="text"
|
||||
placeholder="태그 입력"
|
||||
@blur="addTagFromInput"
|
||||
@keydown="handleTagKeydown"
|
||||
>
|
||||
<span class="admin-post-form__tag-chevron text-xs text-[#394047]" aria-hidden="true">⌄</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="admin-post-form__seo grid gap-3 border-t border-[#e3e6e8] pt-5 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 h-[38px] rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none"
|
||||
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-[108px] rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none"
|
||||
maxlength="180"
|
||||
placeholder="비워두면 요약을 사용"
|
||||
/>
|
||||
<span class="admin-post-form__hint text-xs text-muted">
|
||||
{{ form.seoDescription.length }}/180
|
||||
</span>
|
||||
</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>
|
||||
<div v-if="showDelete" class="admin-post-form__settings-bottom shrink-0 border-t border-[#e3e6e8] px-8 py-6">
|
||||
<button
|
||||
class="admin-post-form__delete-post flex h-10 w-full items-center justify-center gap-2 rounded border border-[#d21a26] text-sm font-bold text-[#d21a26] transition-colors hover:bg-red-50 disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="deleting"
|
||||
@click="deletePost"
|
||||
>
|
||||
<span aria-hidden="true">⌫</span>
|
||||
<span>{{ deleting ? '삭제 중' : 'Delete post' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<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 flex max-h-[86vh] min-h-[620px] w-full max-w-5xl flex-col 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">
|
||||
대표 이미지
|
||||
</h2>
|
||||
<button class="admin-post-form__media-picker-close rounded border border-line px-3 py-1.5 text-sm font-semibold transition-colors hover:bg-[#eff1f2]" type="button" @click="closeMediaPicker">
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
<div class="admin-post-form__media-picker-tabs flex border-b border-line px-5">
|
||||
<button
|
||||
class="admin-post-form__media-picker-tab border-b-2 px-3 py-3 text-sm font-semibold transition-colors"
|
||||
:class="activeMediaPickerTab === 'upload' ? 'border-[#15171a] text-[#15171a]' : 'border-transparent text-muted hover:text-[#15171a]'"
|
||||
type="button"
|
||||
@click="activeMediaPickerTab = 'upload'"
|
||||
>
|
||||
이미지 업로드
|
||||
</button>
|
||||
<button
|
||||
class="admin-post-form__media-picker-tab border-b-2 px-3 py-3 text-sm font-semibold transition-colors"
|
||||
:class="activeMediaPickerTab === 'library' ? 'border-[#15171a] text-[#15171a]' : 'border-transparent text-muted hover:text-[#15171a]'"
|
||||
type="button"
|
||||
@click="activeMediaPickerTab = 'library'"
|
||||
>
|
||||
미디어 라이브러리
|
||||
</button>
|
||||
</div>
|
||||
<div class="admin-post-form__media-picker-body flex-1 overflow-y-auto p-5">
|
||||
<div
|
||||
v-if="activeMediaPickerTab === 'upload'"
|
||||
class="admin-post-form__media-upload-zone grid min-h-[420px] place-items-center border border-dashed border-[#cfd5da] bg-white text-center"
|
||||
@dragover.prevent
|
||||
@drop.prevent="dropFeaturedImage"
|
||||
>
|
||||
<div class="admin-post-form__media-upload-inner grid gap-3">
|
||||
<p class="admin-post-form__media-upload-title text-lg font-semibold text-[#15171a]">
|
||||
파일을 끌어 업로드
|
||||
</p>
|
||||
<p class="admin-post-form__media-upload-or text-sm text-muted">
|
||||
또는
|
||||
</p>
|
||||
<label class="admin-post-form__media-upload-button mx-auto inline-flex h-10 cursor-pointer items-center justify-center rounded border border-[#2b78d0] px-8 text-sm font-semibold text-[#1f6fbf] transition-colors hover:bg-blue-50">
|
||||
{{ isUploadingFeaturedImage ? '업로드 중' : '파일 선택' }}
|
||||
<input class="sr-only" type="file" accept="image/*" @change="uploadFeaturedImage">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<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 relative overflow-hidden border bg-white text-left transition hover:border-[#8e9cac] hover:shadow-sm"
|
||||
:class="selectedMediaPickerUrl === item.url ? 'border-[#15171a] ring-2 ring-[#15171a]' : 'border-line'"
|
||||
type="button"
|
||||
:aria-pressed="selectedMediaPickerUrl === item.url"
|
||||
@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>
|
||||
<span v-if="selectedMediaPickerUrl === item.url" class="admin-post-form__media-picker-selected absolute right-2 top-2 grid size-6 place-items-center rounded-full bg-[#15171a] text-white" aria-hidden="true">
|
||||
<svg width="13" height="10" viewBox="0 0 13 10" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 5L4.5 8.5L12 1" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</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>
|
||||
</template>
|
||||
</div>
|
||||
<div class="admin-post-form__media-picker-footer flex h-14 shrink-0 items-center justify-end border-t border-line px-5">
|
||||
<button
|
||||
class="admin-post-form__media-picker-confirm h-9 rounded bg-[#15171a] px-4 text-sm font-semibold text-white transition-colors hover:bg-black disabled:cursor-not-allowed disabled:bg-[#d7dce0] disabled:text-[#8e9cac]"
|
||||
type="button"
|
||||
:disabled="!selectedMediaPickerUrl"
|
||||
@click="applyPickedImage"
|
||||
>
|
||||
대표 이미지로 적용
|
||||
</button>
|
||||
</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>
|
||||
466
components/content/ContentMarkdownRenderer.vue
Normal file
466
components/content/ContentMarkdownRenderer.vue
Normal file
@@ -0,0 +1,466 @@
|
||||
<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 || '',
|
||||
variant: options.variant || '',
|
||||
ordered: options.ordered || false,
|
||||
width: options.width || 'regular',
|
||||
images: options.images || [],
|
||||
meta: options.meta && typeof options.meta === 'object' ? { ...options.meta } : {}
|
||||
})
|
||||
|
||||
/**
|
||||
* 이미지 마크다운 행을 이미지 데이터로 변환
|
||||
* @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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 북마크 fenced 블록 본문에서 URL·제목·설명·썸네일을 파싱한다.
|
||||
* @param {string} raw - fenced 내부 텍스트
|
||||
* @returns {{url: string, title: string, description: string, thumbnail: string}} 북마크 메타
|
||||
*/
|
||||
const parseBookmarkMeta = (raw) => {
|
||||
const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean)
|
||||
const meta = {
|
||||
url: '',
|
||||
title: '',
|
||||
description: '',
|
||||
thumbnail: ''
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const kv = line.match(/^(\w+)=(.*)$/)
|
||||
|
||||
if (kv) {
|
||||
const key = kv[1].toLowerCase()
|
||||
const val = kv[2].trim()
|
||||
|
||||
if (key === 'url') {
|
||||
meta.url = val
|
||||
} else if (key === 'title') {
|
||||
meta.title = val
|
||||
} else if (key === 'description' || key === 'desc') {
|
||||
meta.description = val
|
||||
} else if (key === 'thumbnail' || key === 'image') {
|
||||
meta.thumbnail = val
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (!meta.url && /^https?:\/\//i.test(line)) {
|
||||
meta.url = line
|
||||
continue
|
||||
}
|
||||
|
||||
if (meta.url && !meta.title) {
|
||||
meta.title = line
|
||||
continue
|
||||
}
|
||||
|
||||
if (meta.url && meta.title && !meta.description) {
|
||||
meta.description = line
|
||||
}
|
||||
}
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입(CTA) fenced 블록 본문에서 표시 문구를 파싱한다.
|
||||
* @param {string} raw - fenced 내부 텍스트
|
||||
* @returns {{title: string, description: string, button: string, placeholder: string}} CTA 메타
|
||||
*/
|
||||
const parseSignupMeta = (raw) => {
|
||||
const meta = {
|
||||
title: '뉴스레터에 가입하세요',
|
||||
description: '새 글이 올라오면 받아보실 수 있어요.',
|
||||
button: '구독하기',
|
||||
placeholder: 'you@example.com'
|
||||
}
|
||||
const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean)
|
||||
|
||||
for (const line of lines) {
|
||||
const kv = line.match(/^(\w+)=(.*)$/)
|
||||
|
||||
if (!kv) {
|
||||
continue
|
||||
}
|
||||
|
||||
const key = kv[1].toLowerCase()
|
||||
const val = kv[2].trim()
|
||||
|
||||
if (key === 'title') {
|
||||
meta.title = val
|
||||
} else if (key === 'description' || key === 'desc') {
|
||||
meta.description = val
|
||||
} else if (key === 'button') {
|
||||
meta.button = val
|
||||
} else if (key === 'placeholder') {
|
||||
meta.placeholder = val
|
||||
}
|
||||
}
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
/**
|
||||
* 마크다운 문자열을 렌더링용 블록 목록으로 변환
|
||||
* @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 === '>>>') {
|
||||
const contentLines = []
|
||||
index += 1
|
||||
|
||||
while (index < lines.length && lines[index].trim() !== '<<<') {
|
||||
contentLines.push(lines[index])
|
||||
index += 1
|
||||
}
|
||||
|
||||
blocks.push(createBlock('quote', contentLines.join('\n').trim(), null, `block-${blocks.length}`, { variant: 'alt' }))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine === ':::bookmark') {
|
||||
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||
const bookmarkMeta = parseBookmarkMeta(contentLines.join('\n'))
|
||||
|
||||
if (bookmarkMeta.url) {
|
||||
blocks.push(createBlock('bookmark', '', null, `block-${blocks.length}`, { meta: bookmarkMeta }))
|
||||
}
|
||||
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine === ':::signup') {
|
||||
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||
const signupMeta = parseSignupMeta(contentLines.join('\n'))
|
||||
blocks.push(createBlock('signup', '', null, `block-${blocks.length}`, { meta: signupMeta }))
|
||||
index = nextIndex
|
||||
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,6})\s+(.+)$/)
|
||||
|
||||
if (headingMatch) {
|
||||
blocks.push(createBlock('heading', headingMatch[2], headingMatch[1].length, `block-${blocks.length}`))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith('> ')) {
|
||||
const quoteLines = []
|
||||
|
||||
while (index < lines.length && lines[index].trim().startsWith('>')) {
|
||||
quoteLines.push(lines[index].trim().replace(/^>\s?/, ''))
|
||||
index += 1
|
||||
}
|
||||
|
||||
blocks.push(createBlock('quote', quoteLines.join('\n').trim(), null, `block-${blocks.length}`))
|
||||
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
|
||||
}
|
||||
|
||||
if (/^\d+\.\s+/.test(trimmedLine)) {
|
||||
const items = []
|
||||
|
||||
while (index < lines.length && /^\d+\.\s+/.test(lines[index].trim())) {
|
||||
items.push(lines[index].trim().replace(/^\d+\.\s+/, ''))
|
||||
index += 1
|
||||
}
|
||||
|
||||
blocks.push(createBlock('list', items, null, `block-${blocks.length}`, { ordered: true }))
|
||||
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'" :variant="block.variant || 'default'">
|
||||
{{ block.text }}
|
||||
</ProseBlockquote>
|
||||
<ProseList v-else-if="block.type === 'list'" :ordered="block.ordered || false">
|
||||
<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>
|
||||
<ProseBookmark
|
||||
v-else-if="block.type === 'bookmark' && block.meta.url"
|
||||
:url="block.meta.url"
|
||||
:title="block.meta.title"
|
||||
:description="block.meta.description"
|
||||
:thumbnail="block.meta.thumbnail"
|
||||
/>
|
||||
<ProseSignup
|
||||
v-else-if="block.type === 'signup'"
|
||||
:title="block.meta.title"
|
||||
:description="block.meta.description"
|
||||
:button-label="block.meta.button"
|
||||
:placeholder="block.meta.placeholder"
|
||||
/>
|
||||
<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-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]"
|
||||
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 text-[15px] leading-8 text-[var(--site-text)]">
|
||||
{{ 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 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
21
components/content/ProseBlockquote.vue
Normal file
21
components/content/ProseBlockquote.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'default'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<blockquote
|
||||
class="prose-blockquote my-8 text-[15px] leading-8 text-[var(--site-text)]"
|
||||
:class="variant === 'alt'
|
||||
? 'rounded-[14px] border border-[var(--site-line)] bg-[var(--site-panel)] px-6 py-5 italic'
|
||||
: 'rounded-[10px] border-l-2 border-[var(--site-text)] bg-[var(--site-panel)] px-5 py-4 font-medium'"
|
||||
>
|
||||
<span class="whitespace-pre-line">
|
||||
<slot />
|
||||
</span>
|
||||
</blockquote>
|
||||
</template>
|
||||
95
components/content/ProseBookmark.vue
Normal file
95
components/content/ProseBookmark.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
url: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
thumbnail: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 북마크 카드에 표시할 호스트명을 반환한다.
|
||||
* @returns {string} www 없는 호스트 또는 빈 문자열
|
||||
*/
|
||||
const displayHost = computed(() => {
|
||||
try {
|
||||
return new URL(props.url).hostname.replace(/^www\./, '')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 썸네일이 비었을 때 파비콘 보조 URL을 만든다.
|
||||
* @returns {string} favicon 요청 URL
|
||||
*/
|
||||
const faviconUrl = computed(() => {
|
||||
if (!displayHost.value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(displayHost.value)}&sz=128`
|
||||
})
|
||||
|
||||
/**
|
||||
* 실제로 표시할 이미지 주소
|
||||
* @returns {string}
|
||||
*/
|
||||
const imageSrc = computed(() => props.thumbnail || faviconUrl.value)
|
||||
|
||||
/**
|
||||
* 표시 제목(없으면 호스트·URL)
|
||||
* @returns {string}
|
||||
*/
|
||||
const displayTitle = computed(() => props.title || displayHost.value || props.url)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a
|
||||
class="prose-bookmark group prose-bookmark-card my-8 flex max-w-full flex-col overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] no-underline transition-[background-color,box-shadow] hover:bg-[color-mix(in_srgb,var(--site-panel)_86%,var(--site-text)_14%)] sm:flex-row"
|
||||
:href="url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div class="prose-bookmark__media relative h-36 w-full shrink-0 overflow-hidden bg-[color-mix(in_srgb,var(--site-line)_40%,var(--site-panel))] sm:h-auto sm:w-[min(44%,220px)] sm:min-h-[9rem]">
|
||||
<img
|
||||
v-if="imageSrc"
|
||||
class="prose-bookmark__thumb h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
|
||||
:src="imageSrc"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
>
|
||||
</div>
|
||||
<div class="prose-bookmark__body flex min-w-0 flex-1 flex-col justify-center gap-1 px-4 py-4 sm:px-5 sm:py-5">
|
||||
<p v-if="displayHost" class="prose-bookmark__host text-[11px] font-semibold uppercase tracking-[0.06em] text-[var(--site-muted)]">
|
||||
{{ displayHost }}
|
||||
</p>
|
||||
<p class="prose-bookmark__title text-[15px] font-semibold leading-snug text-[var(--site-text)]">
|
||||
{{ displayTitle }}
|
||||
</p>
|
||||
<p v-if="description" class="prose-bookmark__desc line-clamp-2 text-sm leading-relaxed text-[var(--site-muted)]">
|
||||
{{ description }}
|
||||
</p>
|
||||
<p class="prose-bookmark__meta mt-1 flex items-center gap-1.5 text-xs font-medium text-[var(--site-soft)]">
|
||||
<svg class="shrink-0 opacity-80" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M12 6h-6a2 2 0 0 0 -2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-6" />
|
||||
<path d="M11 13l9 -9" />
|
||||
<path d="M15 4h5v5" />
|
||||
</svg>
|
||||
<span class="truncate">{{ url }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
23
components/content/ProseButton.vue
Normal file
23
components/content/ProseButton.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<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 items-center justify-center rounded-full bg-[var(--site-text)] px-5 py-2.5 text-sm font-semibold text-[var(--site-bg)] transition-opacity hover:opacity-80"
|
||||
:to="href"
|
||||
>
|
||||
<slot />
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</template>
|
||||
7
components/content/ProseCallout.vue
Normal file
7
components/content/ProseCallout.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<aside class="prose-callout prose-callout-card my-8 rounded-[10px] border border-[var(--site-line)] border-l-[3px] border-l-[var(--site-accent)] bg-[var(--site-panel)] p-5 pl-4 text-[15px] leading-8 text-[var(--site-text)]">
|
||||
<div class="whitespace-pre-line">
|
||||
<slot />
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
110
components/content/ProseEmbed.vue
Normal file
110
components/content/ProseEmbed.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
url: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const { theme } = useThemeMode()
|
||||
|
||||
/**
|
||||
* 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 ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Twitter/X 게시물 ID를 추출한다.
|
||||
* @param {string} value - 트윗 URL
|
||||
* @returns {string} 상태 ID
|
||||
*/
|
||||
const getTweetId = (value) => {
|
||||
try {
|
||||
const trimmed = value.trim()
|
||||
const parsedUrl = new URL(trimmed)
|
||||
const host = parsedUrl.hostname.replace(/^www\./, '')
|
||||
|
||||
if (!['twitter.com', 'x.com', 'mobile.twitter.com'].includes(host)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const parts = parsedUrl.pathname.split('/').filter(Boolean)
|
||||
const statusIdx = parts.indexOf('status')
|
||||
|
||||
if (statusIdx >= 0 && parts[statusIdx + 1]) {
|
||||
return parts[statusIdx + 1].split(/[?#]/)[0] || ''
|
||||
}
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const youtubeId = computed(() => getYouTubeId(props.url))
|
||||
const youtubeEmbedUrl = computed(() => youtubeId.value ? `https://www.youtube.com/embed/${youtubeId.value}` : '')
|
||||
const tweetId = computed(() => getTweetId(props.url))
|
||||
|
||||
/**
|
||||
* Twitter 공식 embed iframe 주소
|
||||
* @returns {string}
|
||||
*/
|
||||
const tweetEmbedUrl = computed(() => {
|
||||
if (!tweetId.value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const twitterTheme = theme.value === 'dark' ? 'dark' : 'light'
|
||||
|
||||
return `https://platform.twitter.com/embed/Tweet.html?id=${encodeURIComponent(tweetId.value)}&theme=${twitterTheme}&dnt=true`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prose-embed prose-embed-card my-8 overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]">
|
||||
<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
|
||||
/>
|
||||
<iframe
|
||||
v-else-if="tweetEmbedUrl"
|
||||
:key="tweetEmbedUrl"
|
||||
class="prose-embed__tweet min-h-[420px] w-full border-0 sm:min-h-[458px]"
|
||||
:src="tweetEmbedUrl"
|
||||
title="Embedded post"
|
||||
loading="lazy"
|
||||
/>
|
||||
<a
|
||||
v-else
|
||||
class="prose-embed__link block p-5 text-sm font-semibold text-[var(--site-text)] 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 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
components/content/ProseHeaderCard.vue
Normal file
17
components/content/ProseHeaderCard.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'simple'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
class="prose-header-card my-8 overflow-hidden rounded-[14px] border border-[var(--site-line)] bg-[var(--site-panel)] p-8 text-[var(--site-text)]"
|
||||
: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-12 font-semibold leading-[1.25] tracking-normal first:mt-0"
|
||||
:class="{
|
||||
'text-[clamp(1.35rem,1.25rem+0.35vw,1.6rem)] leading-[1.15]': level === 1,
|
||||
'text-[clamp(1.2rem,1.15rem+0.3vw,1.4rem)]': level === 2,
|
||||
'text-[clamp(1.1rem,1.05rem+0.25vw,1.25rem)]': level === 3,
|
||||
'text-[clamp(1.025rem,1rem+0.2vw,1.15rem)]': level === 4,
|
||||
'text-[clamp(0.95rem,0.925rem+0.15vw,1.05rem)]': level === 5,
|
||||
'text-[clamp(0.9rem,0.875rem+0.1vw,1rem)]': level === 6
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
33
components/content/ProseImage.vue
Normal file
33
components/content/ProseImage.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<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 lg:max-w-none': variant === 'wide',
|
||||
'prose-image--full lg:-mx-20 lg:max-w-none': variant === 'full'
|
||||
}"
|
||||
>
|
||||
<div class="overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]">
|
||||
<img class="prose-image__media w-full object-cover" :src="src" :alt="alt">
|
||||
</div>
|
||||
<figcaption v-if="$slots.default" class="prose-image__caption mt-3 text-center text-sm text-[var(--site-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-5 text-[15px] leading-8 text-[var(--site-text)] marker:text-[var(--site-muted)]"
|
||||
: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 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
44
components/content/ProseSignup.vue
Normal file
44
components/content/ProseSignup.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '뉴스레터에 가입하세요'
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '새 글이 올라오면 받아보실 수 있어요.'
|
||||
},
|
||||
buttonLabel: {
|
||||
type: String,
|
||||
default: '구독하기'
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'you@example.com'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="prose-signup prose-signup-card my-8 rounded-[12px] border border-[var(--site-line)] bg-[var(--site-panel-strong)] px-5 py-8 text-center sm:px-8">
|
||||
<h3 class="prose-signup__title text-[clamp(1rem,0.95rem+0.25vw,1.125rem)] font-semibold leading-snug text-[var(--site-text)]">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p class="prose-signup__desc mt-2 text-[15px] leading-7 text-[var(--site-muted)]">
|
||||
{{ description }}
|
||||
</p>
|
||||
<form class="prose-signup__form mt-6 flex flex-col items-stretch gap-2.5 sm:flex-row sm:justify-center" action="#" @submit.prevent>
|
||||
<input
|
||||
class="site-input prose-signup__input min-h-[44px] w-full rounded-full px-4 text-sm sm:max-w-[300px] sm:flex-1"
|
||||
type="email"
|
||||
:placeholder="placeholder"
|
||||
readonly
|
||||
tabindex="-1"
|
||||
aria-label="이메일"
|
||||
>
|
||||
<button class="site-accent-button prose-signup__submit min-h-[44px] shrink-0 rounded-full px-8 text-sm font-semibold" type="button">
|
||||
{{ buttonLabel }}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</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 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5">
|
||||
<summary class="prose-toggle__summary cursor-pointer text-[15px] font-semibold leading-7 text-[var(--site-text)]">
|
||||
{{ title }}
|
||||
</summary>
|
||||
<div class="prose-toggle__body mt-4 whitespace-pre-line text-[15px] leading-8 text-[var(--site-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 overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
113
components/site/LeftSidebar.vue
Normal file
113
components/site/LeftSidebar.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
menuOpen: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const { isDarkMode, toggleTheme } = useThemeMode()
|
||||
|
||||
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 overflow-hidden border-r border-[var(--site-line)] transition-[width,opacity,border-color] duration-300 ease-out lg:sticky lg:top-[57px] lg:z-10 lg:max-h-[calc(100vh-57px)] lg:self-start lg:flex lg:flex-col"
|
||||
:class="menuOpen ? 'w-[287px] opacity-100' : 'w-0 opacity-0 border-transparent'"
|
||||
>
|
||||
<div class="left-sidebar__scroll site-sidebar-scroll min-h-0 flex-1">
|
||||
<div class="left-sidebar__block site-sidebar-section py-3 pl-4 pr-3 sm:pl-5 xl:pl-0">
|
||||
<nav class="left-sidebar__nav" data-nav="menu">
|
||||
<ul class="flex flex-col gap-[3px] text-[15px] text-[var(--site-text)]">
|
||||
<li
|
||||
v-for="item in navigation.primary"
|
||||
:key="item.id"
|
||||
class="group relative flex w-full items-center"
|
||||
>
|
||||
<NuxtLink
|
||||
class="left-sidebar__nav-link flex flex-1 items-center gap-2 rounded-[10px] py-1.5 pr-3 pl-0 leading-tight transition-[padding,background-color] duration-200 before:h-4 before:w-1 before:flex-none before:rounded-sm before:rounded-l-none before:bg-[var(--site-line)] before:transition-[width,height,border-radius,background-color] before:duration-200 hover:bg-[#f2f2f2] hover:px-3 hover:before:h-2 hover:before:w-2 hover:before:rounded-full hover:before:bg-[rgba(17,17,17,0.25)]"
|
||||
:to="item.url"
|
||||
>
|
||||
<span class="left-sidebar__nav-link-label flex-1 transition-transform duration-200 group-hover:translate-x-[3px]">{{ item.label }}</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="left-sidebar__block site-sidebar-section px-5 py-4 pr-3 xl:pl-0">
|
||||
<div class="left-sidebar__section-title flex items-center justify-between pr-2 text-xs font-semibold uppercase tracking-[0.01em] site-muted">
|
||||
<span>Categories</span>
|
||||
<span class="text-sm">⌃</span>
|
||||
</div>
|
||||
<div class="left-sidebar__category-grid mt-1.5 grid grid-cols-2 gap-x-2 gap-y-[2px] text-[0.8rem] font-medium">
|
||||
<NuxtLink
|
||||
v-for="tag in tags"
|
||||
:key="tag.id"
|
||||
class="left-sidebar__category group flex items-center gap-2 rounded-[10px] py-2 pr-3 pl-0 leading-tight transition-[padding,background-color,color] duration-200 hover:bg-[#f2f2f2] hover:px-3"
|
||||
:to="`/tag/${tag.slug}`"
|
||||
>
|
||||
<span class="left-sidebar__category-color h-4 w-1 rounded-sm rounded-l-none transition-all duration-200 group-hover:h-2 group-hover:w-2 group-hover:rounded-full" :style="{ backgroundColor: tag.color }" />
|
||||
<span class="left-sidebar__category-name flex-1 truncate transition-transform duration-200 group-hover:translate-x-[3px]">{{ tag.name }}</span>
|
||||
<span
|
||||
v-if="tag.postCount"
|
||||
class="left-sidebar__category-count invisible text-xs font-medium opacity-0 transition-opacity duration-200 group-hover:visible group-hover:opacity-100"
|
||||
>
|
||||
{{ tag.postCount }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="left-sidebar__block site-sidebar-section px-5 py-5 pr-3 xl:pl-0">
|
||||
<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 shrink-0 items-center justify-between border-t border-[var(--site-line)] 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"
|
||||
class="site-interactive"
|
||||
:to="item.url"
|
||||
>
|
||||
{{ item.label }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
<button
|
||||
class="left-sidebar__theme-dot site-panel-hover site-interactive grid h-7 w-7 place-items-center rounded-full border border-[var(--site-line)]"
|
||||
type="button"
|
||||
:aria-label="isDarkMode ? '라이트 모드로 전환' : '다크 모드로 전환'"
|
||||
:title="isDarkMode ? '라이트 모드' : '다크 모드'"
|
||||
@click="toggleTheme"
|
||||
>
|
||||
<span v-if="isDarkMode">☀</span>
|
||||
<span v-else>☾</span>
|
||||
</button>
|
||||
</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 site-panel-hover">
|
||||
<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 site-interactive 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>
|
||||
186
components/site/RightSidebar.vue
Normal file
186
components/site/RightSidebar.vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<script setup>
|
||||
const followLinks = [
|
||||
{ id: 'facebook', label: 'Facebook', href: 'https://facebook.com', icon: 'facebook' },
|
||||
{ id: 'x', label: 'X', href: 'https://x.com', icon: 'x' },
|
||||
{ id: 'github', label: 'Github', href: 'https://github.com', icon: 'github' },
|
||||
{ id: 'instagram', label: 'Instagram', href: 'https://instagram.com', icon: 'instagram' },
|
||||
{ id: 'linkedin', label: 'Linkedin', href: 'https://linkedin.com', icon: 'linkedin' },
|
||||
{ id: 'rss', label: 'RSS', href: '/rss/', icon: 'rss' }
|
||||
]
|
||||
|
||||
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] overflow-hidden border-l border-[var(--site-line)] lg:sticky lg:top-[57px] lg:z-10 lg:max-h-[calc(100vh-57px)] lg:self-start lg:flex lg:flex-col">
|
||||
<div class="right-sidebar__scroll site-sidebar-scroll min-h-0 flex-1">
|
||||
<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>
|
||||
<nav class="right-sidebar__social relative z-10 flex flex-wrap items-center gap-1 text-sm text-[var(--site-text)]">
|
||||
<a
|
||||
v-for="item in followLinks"
|
||||
:key="item.id"
|
||||
class="site-interactive p-0.5 hover:opacity-75"
|
||||
:href="item.href"
|
||||
:aria-label="item.label"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<svg
|
||||
v-if="item.icon === 'facebook'"
|
||||
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"
|
||||
class="h-4 w-4"
|
||||
>
|
||||
<path d="M7 10v4h3v7h4v-7h3l1-4h-4V8a1 1 0 0 1 1-1h3V3h-3a5 5 0 0 0-5 5v2z" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="item.icon === 'x'"
|
||||
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"
|
||||
class="h-4 w-4"
|
||||
>
|
||||
<path d="M4 4l11.733 16H20L8.267 4z" />
|
||||
<path d="M4 20l6.768-6.768m2.46-2.46L20 4" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="item.icon === 'github'"
|
||||
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"
|
||||
class="h-4 w-4"
|
||||
>
|
||||
<path d="M9 19c-4.3 1.4-4.3-2.5-6-3m12 5v-3.5c0-1 .1-1.4-.5-2c2.8-.3 5.5-1.4 5.5-6a4.6 4.6 0 0 0-1.3-3.2 4.2 4.2 0 0 0-.1-3.2s-1.1-.3-3.5 1.3a12.3 12.3 0 0 0-6.2 0c-2.4-1.6-3.5-1.3-3.5-1.3a4.2 4.2 0 0 0-.1 3.2A4.6 4.6 0 0 0 4 9.5c0 4.6 2.7 5.7 5.5 6-.6.6-.6 1.2-.5 2V21" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="item.icon === 'instagram'"
|
||||
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"
|
||||
class="h-4 w-4"
|
||||
>
|
||||
<rect x="4" y="4" width="16" height="16" rx="4" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<line x1="16.5" y1="7.5" x2="16.5" y2="7.501" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="item.icon === 'linkedin'"
|
||||
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"
|
||||
class="h-4 w-4"
|
||||
>
|
||||
<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z" />
|
||||
<rect x="2" y="9" width="4" height="12" />
|
||||
<circle cx="4" cy="4" r="2" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
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"
|
||||
class="h-4 w-4"
|
||||
>
|
||||
<circle cx="5" cy="19" r="1" />
|
||||
<path d="M4 4a16 16 0 0 1 16 16" />
|
||||
<path d="M4 11a9 9 0 0 1 9 9" />
|
||||
</svg>
|
||||
<span class="sr-only">{{ item.label }}</span>
|
||||
</a>
|
||||
</nav>
|
||||
</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 site-interactive font-semibold" to="/post/hello-sori-studio">
|
||||
sori.studio 첫 글과 방향
|
||||
</NuxtLink>
|
||||
<NuxtLink class="right-sidebar__link site-interactive font-semibold" to="/pages/projects">
|
||||
Projects and services
|
||||
</NuxtLink>
|
||||
<NuxtLink class="right-sidebar__link site-interactive 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 shrink-0 border-t border-[var(--site-line)] py-4 pl-5 pr-0 text-xs site-muted">
|
||||
{{ siteSettings.copyrightText }}
|
||||
</footer>
|
||||
</aside>
|
||||
</template>
|
||||
73
components/site/SiteHeader.vue
Normal file
73
components/site/SiteHeader.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup>
|
||||
const { menuOpen, toggleMenu } = useMenuState()
|
||||
const { isDarkMode, toggleTheme } = useThemeMode()
|
||||
|
||||
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 site-interactive rounded-md px-2 py-1" to="/pages/about">
|
||||
Account
|
||||
</NuxtLink>
|
||||
<button
|
||||
class="site-header__theme-toggle site-panel-hover site-interactive grid h-8 w-8 place-items-center rounded-full border border-[var(--site-line)]"
|
||||
type="button"
|
||||
:aria-label="isDarkMode ? '라이트 모드로 전환' : '다크 모드로 전환'"
|
||||
:title="isDarkMode ? '라이트 모드' : '다크 모드'"
|
||||
@click="toggleTheme"
|
||||
>
|
||||
<span v-if="isDarkMode">☀</span>
|
||||
<span v-else>☾</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
25
components/site/TagHeader.vue
Normal file
25
components/site/TagHeader.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<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">
|
||||
<h1 class="tag-header__title mt-3 text-xl 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
|
||||
}
|
||||
}
|
||||
66
composables/useThemeMode.js
Normal file
66
composables/useThemeMode.js
Normal file
@@ -0,0 +1,66 @@
|
||||
const themeStorageKey = 'SITE_THEME'
|
||||
|
||||
/**
|
||||
* HTML 루트 요소에 현재 테마를 반영한다.
|
||||
* @param {'light' | 'dark'} theme - 적용할 테마
|
||||
* @returns {void}
|
||||
*/
|
||||
const applyThemeToDocument = (theme) => {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
document.documentElement.dataset.theme = theme
|
||||
document.documentElement.style.colorScheme = theme
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자의 시스템 테마를 조회한다.
|
||||
* @returns {'light' | 'dark'} 시스템 기준 기본 테마
|
||||
*/
|
||||
const getSystemTheme = () => {
|
||||
if (!import.meta.client) {
|
||||
return 'light'
|
||||
}
|
||||
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이트 라이트/다크 테마 상태를 관리한다.
|
||||
* @returns {{theme: import('vue').Ref<'light' | 'dark'>, isDarkMode: import('vue').ComputedRef<boolean>, toggleTheme: Function}} 테마 상태와 제어 함수
|
||||
*/
|
||||
export const useThemeMode = () => {
|
||||
const theme = useState('site-theme-mode', () => 'light')
|
||||
const isDarkMode = computed(() => theme.value === 'dark')
|
||||
|
||||
onMounted(() => {
|
||||
const savedTheme = localStorage.getItem(themeStorageKey)
|
||||
const nextTheme = savedTheme === 'light' || savedTheme === 'dark' ? savedTheme : getSystemTheme()
|
||||
theme.value = nextTheme
|
||||
applyThemeToDocument(nextTheme)
|
||||
})
|
||||
|
||||
watch(theme, (nextTheme) => {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
localStorage.setItem(themeStorageKey, nextTheme)
|
||||
applyThemeToDocument(nextTheme)
|
||||
})
|
||||
|
||||
/**
|
||||
* 라이트/다크 테마를 전환한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const toggleTheme = () => {
|
||||
theme.value = theme.value === 'dark' ? 'light' : 'dark'
|
||||
}
|
||||
|
||||
return {
|
||||
theme,
|
||||
isDarkMode,
|
||||
toggleTheme
|
||||
}
|
||||
}
|
||||
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]">
|
||||
|
||||
168
docs/deploy.md
168
docs/deploy.md
@@ -1,6 +1,6 @@
|
||||
# 배포 가이드
|
||||
|
||||
> 현재 프로젝트는 코드 스캐폴딩 전 상태다. 아래 내용은 Nuxt 앱 생성 후 적용할 기본 배포 방향이다.
|
||||
> 현재 프로젝트는 Nuxt 3 초기 스캐폴딩 상태다. Docker 설정은 파일 기준 초안이 있으며 운영 DB 확정 후 NAS에서 검증한다.
|
||||
|
||||
## 빌드 유형
|
||||
|
||||
@@ -9,13 +9,15 @@
|
||||
| 개발 | `npm run dev` | 로컬 테스트, 개발 서버 |
|
||||
| 프로덕션 | `npm run build` | NAS 배포, 운영 서버 |
|
||||
|
||||
> `npm run dev`는 프로젝트 전용 실행 스크립트를 통해 개발 서버, Admin, Tailwind Viewer 링크만 요약 출력한다.
|
||||
|
||||
---
|
||||
|
||||
## 로컬 개발
|
||||
|
||||
### 필수 조건
|
||||
|
||||
- Node.js 20+ 권장
|
||||
- Node.js 22 LTS 권장
|
||||
- npm 9+
|
||||
- 개발 DB
|
||||
|
||||
@@ -32,23 +34,77 @@ 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 시작
|
||||
# Docker Desktop을 사용하면 Docker.app을 먼저 실행
|
||||
# Colima를 사용하면 아래 명령 실행
|
||||
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/
|
||||
|
||||
### 로컬 DB 확인 방법
|
||||
|
||||
로컬 개발 DB는 PostgreSQL이며 호스트에서는 `127.0.0.1:43119`로 접근한다. 접속 정보는 Git에 포함하지 않는 `.env.development` 값을 사용한다.
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|----|
|
||||
| Host | `127.0.0.1` |
|
||||
| Port | `43119` |
|
||||
| Database | `.env.development`의 `POSTGRES_DB` |
|
||||
| User | `.env.development`의 `POSTGRES_USER` |
|
||||
| Password | `.env.development`의 `POSTGRES_PASSWORD` |
|
||||
|
||||
터미널에서 바로 확인할 때는 컨테이너 내부 `psql`을 사용한다.
|
||||
|
||||
```bash
|
||||
# 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;'
|
||||
|
||||
# psql 콘솔 접속
|
||||
docker exec -it sori-studio-db psql -U sori_studio -d sori_studio
|
||||
```
|
||||
|
||||
GUI로 확인할 때는 DBeaver, TablePlus, DataGrip, CloudBeaver 같은 PostgreSQL 클라이언트에서 위 접속 정보를 입력한다. phpMyAdmin은 MySQL/MariaDB용 도구라 이 프로젝트의 PostgreSQL DB 확인 용도로는 사용하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## UGREEN NAS Docker 배포
|
||||
|
||||
> Dockerfile과 docker-compose 설정은 아직 작성 전이다.
|
||||
> Dockerfile과 docker-compose 설정은 초안이며 NAS 운영 환경에서는 아직 검증 전이다.
|
||||
|
||||
### SSH 접속
|
||||
|
||||
@@ -69,11 +125,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 +147,10 @@ docker run -d -p 3000:3000 sori.studio:latest
|
||||
|
||||
### 포트
|
||||
|
||||
- HTTP: 3000
|
||||
- 로컬 개발: 43117
|
||||
- NAS Docker 외부: 43118
|
||||
- 컨테이너 내부: 3000
|
||||
- PostgreSQL 외부: 43119
|
||||
- HTTPS: 3001 (SSL 설정 시)
|
||||
|
||||
---
|
||||
@@ -98,13 +159,100 @@ 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`로 실행
|
||||
|
||||
### 개발/운영 DB 분리 검증 절차
|
||||
|
||||
검증 전제는 실제 비밀번호나 전체 `DATABASE_URL`을 화면 공유, 문서, 커밋 메시지에 노출하지 않는 것이다. 확인할 때는 호스트, 포트, DB 이름, 파일명만 대조한다.
|
||||
|
||||
1. `.env.development` 확인.
|
||||
|
||||
```bash
|
||||
# 로컬 개발 DB는 호스트 기준 127.0.0.1:43119를 사용해야 한다.
|
||||
# DATABASE_URL 전체 값은 공유하지 않는다.
|
||||
rg -n "^(DATABASE_URL|POSTGRES_DB|POSTGRES_USER|DB_PORT)=" .env.development
|
||||
```
|
||||
|
||||
기준:
|
||||
|
||||
- `DATABASE_URL` 호스트가 `127.0.0.1`
|
||||
- `DATABASE_URL` 포트가 `43119`
|
||||
- `DB_PORT=43119`
|
||||
- 운영 NAS 호스트명, 운영 IP, 운영 DB 이름이 포함되지 않음
|
||||
|
||||
2. `.env.production` 확인.
|
||||
|
||||
```bash
|
||||
# 운영 파일은 Git에 올리지 않는 운영 전용 파일이다.
|
||||
# 값이 없으면 NAS 배포 전 작성해야 한다.
|
||||
test -f .env.production && rg -n "^(DATABASE_URL|POSTGRES_DB|POSTGRES_USER|APP_PORT)=" .env.production
|
||||
```
|
||||
|
||||
기준:
|
||||
|
||||
- NAS Docker 내부 실행 기준이면 `DATABASE_URL` 호스트가 `sori-studio-db`
|
||||
- NAS 외부 DB를 별도 인스턴스로 쓰는 경우에도 로컬 개발 DB(`127.0.0.1:43119`)를 가리키지 않음
|
||||
- `APP_PORT=43118`
|
||||
- `.env.development`와 DB 비밀번호, 관리자 비밀번호가 서로 다름
|
||||
|
||||
3. 로컬 개발 DB 연결 확인.
|
||||
|
||||
```bash
|
||||
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 current_database(), current_user;'
|
||||
```
|
||||
|
||||
기준:
|
||||
|
||||
- `accepting connections` 표시
|
||||
- `current_database`가 로컬 개발 DB 이름
|
||||
- `current_user`가 로컬 개발 DB 계정
|
||||
|
||||
4. 로컬 개발 서버 연결 확인.
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
기준:
|
||||
|
||||
- 출력 주소가 `http://127.0.0.1:43117`
|
||||
- 관리자 API 요청에서 `127.0.0.1:43119` 연결 오류가 발생하지 않음
|
||||
|
||||
5. 커밋 전 민감 정보 확인.
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
git diff -- . ':!package-lock.json'
|
||||
```
|
||||
|
||||
기준:
|
||||
|
||||
- `.env.development`, `.env.production`이 변경 목록에 포함되지 않음
|
||||
- 문서와 코드 diff에 실제 DB 비밀번호, 관리자 비밀번호, 운영 접속 주소가 포함되지 않음
|
||||
|
||||
## 업로드 파일
|
||||
|
||||
- 관리자 글쓰기에서 업로드한 이미지는 `/uploads/posts/YYYY/MM/` URL로 제공한다.
|
||||
- 로컬 개발에서는 실제 파일이 `public/uploads/posts/YYYY/MM/` 아래 저장된다.
|
||||
- `public/uploads/`는 Git에 포함하지 않는다.
|
||||
- NAS 운영에서는 업로드 파일이 컨테이너 재생성으로 사라지지 않도록 별도 볼륨 연결을 확정해야 한다.
|
||||
- `MAX_FILE_SIZE` 환경 변수로 관리자 이미지 업로드 최대 크기를 제한한다.
|
||||
- 관리자 미디어 화면은 현재 업로드 파일 시스템을 기준으로 목록, 파일명 변경, 삭제를 처리한다.
|
||||
|
||||
## 사용자 액션 필요 항목
|
||||
|
||||
- NAS SSH 접속 주소 확인.
|
||||
- NAS 프로젝트 루트 경로 확정.
|
||||
- 운영 DB 이름, 계정, 권한 확정.
|
||||
- 운영 업로드 볼륨 경로 확정.
|
||||
- 도메인 `sori.studio`의 NAS 연결 방식 확정.
|
||||
|
||||
392
docs/history.md
392
docs/history.md
@@ -1,5 +1,397 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-08 v0.0.50
|
||||
|
||||
### 문서 스크롤과 스티키 사이드바
|
||||
|
||||
Thred에 가깝게 본문 길이에 따른 세로 스크롤은 `main`의 `overflow-y`가 아니라 **뷰포트(문서) 스크롤**로 통일한다. 스크롤바가 중앙 열에만 생기는 체감을 피하고 브라우저 기본 스크롤 위치와 맞추기 위함이다. 좌·우 사이드는 `position:sticky`와 `max-height: calc(100vh - 헤더)`로 고정감을 유지하고, 사이드 내용이 넘칠 때는 스크롤바를 숨긴 채 내부만 스크롤한다.
|
||||
|
||||
## 2026-05-08 v0.0.49
|
||||
|
||||
### 데스크톱 3단 레이아웃 스크롤 영역 분리
|
||||
|
||||
Thred 참고 화면처럼 긴 본문이 있을 때도 좌·우 사이드 하단(푸터)이 뷰포트 기준으로 고정되도록, 그리드 행 높이를 `calc(100vh - 헤더)`로 제한하고 `grid-template-rows: minmax(0,1fr)`로 자식이 넘치지 않게 한다. 세로 스크롤은 중앙 `main`에만 두어 사이드바는 내부 스크롤 영역과 고정 푸터로 나눈다.
|
||||
|
||||
## 2026-05-08 v0.0.48
|
||||
|
||||
### Twitter/X 공식 embed iframe과 북마크·회원가입 마크다운 블록
|
||||
|
||||
공개 본문에서 트위터 게시물은 `platform.twitter.com/embed/Tweet.html` iframe으로 표시한다. oEmbed API나 스크립트 삽입에 비해 구현이 단순하고 SSR·테마(`useThemeMode`)와 `theme` 쿼리만 맞추면 라이트/다크 일관성을 유지하기 쉽기 때문이다.
|
||||
|
||||
북마크·뉴스레터 CTA는 Ghost/Thred 스킨에서 흔한 카드 패턴이므로 `:::bookmark`·`:::signup` 확장 블록으로 저장하고 전용 Vue 컴포넌트로 렌더링한다. 메타데이터 풍부한 북마크는 추후 oEmbed나 서버 페치로 보강할 수 있도록 마크다운 키값 형식을 병행한다.
|
||||
|
||||
## 2026-05-07 v0.0.45
|
||||
|
||||
### 사용자 화면 단일 배경과 사이드바 전환 방식 결정
|
||||
|
||||
사용자 화면 라이트 모드는 전체 배경을 `#fcfcfc`로 통일하고, 영역 구분은 색상 차이가 아니라 보더로만 처리한다. Thred 참고 화면처럼 배경 톤 편차를 줄이면 카드, 사이드바, 본문이 하나의 캔버스 안에서 정돈되어 보이고 시선이 콘텐츠와 타이포에 더 집중되기 때문이다.
|
||||
|
||||
왼쪽 사이드바는 열림/닫힘 시 DOM을 제거하지 않고 너비를 애니메이션으로 줄인다. 사이드바가 즉시 사라지면 레이아웃이 튀어 보이므로, 그리드 컬럼과 사이드바 폭을 함께 전환해 스르륵 접히는 느낌을 유지한다.
|
||||
|
||||
왼쪽 네비게이션 항목은 기본 상태에서 회색 세로 바를 보이고 hover/focus 시 원형 아이콘으로 전환한다. 정적 상태에서는 구분선을 제공하고 상호작용 시 클릭 가능 영역을 명확하게 드러내기 위해서다.
|
||||
|
||||
## 2026-05-07 v0.0.44
|
||||
|
||||
### 사용자 화면 테마 상태 저장과 샘플 폴더 제외 결정
|
||||
|
||||
사용자 화면 라이트/다크 모드는 시스템 테마 자동 감지에만 의존하지 않고 수동 전환 상태를 `localStorage.SITE_THEME`에 저장한다. 공개 화면 헤더와 사이드바에서 같은 테마 상태를 공유해야 하며, 다음 방문에서도 사용자가 마지막으로 선택한 테마를 유지해야 하기 때문이다.
|
||||
|
||||
테마 색상 적용은 CSS 변수와 `html[data-theme]` 조합으로 처리한다. 기존 `prefers-color-scheme`는 기본 fallback으로 유지하되, 사용자가 명시적으로 라이트를 고른 경우 시스템이 다크여도 의도한 화면이 유지되도록 우선순위를 분리한다.
|
||||
|
||||
Thred 참고용 샘플인 `ZCF-v1.0.5`와 보관 폴더는 레퍼런스 자료로만 사용하고 Git 추적 대상에서 제외한다. 대용량 정적 자산과 외부 테마 원본이 변경 이력에 섞이면 실제 서비스 코드 변경 검토가 어려워지기 때문이다.
|
||||
|
||||
## 2026-05-07 v0.0.43
|
||||
|
||||
### 대표 이미지 액션과 선택 확정 흐름 결정
|
||||
|
||||
대표 이미지가 이미 설정된 상태의 변경과 삭제 액션은 이미지 아래 별도 영역이 아니라 이미지 hover 오버레이로 표시한다. 실제 공개 화면에서 보일 이미지 비율과 편집용 버튼 영역이 섞이면 작성자가 레이아웃을 잘못 인식할 수 있기 때문이다.
|
||||
|
||||
대표 이미지 선택 모달에서는 미디어 클릭 즉시 값을 바꾸지 않고 선택 상태만 표시한 뒤, 하단 대표 이미지로 적용 버튼으로 확정한다. 변경 작업은 실수했을 때 되돌리기보다 확정 전 확인이 더 안전한 흐름이기 때문이다.
|
||||
|
||||
## 2026-05-07 v0.0.42
|
||||
|
||||
### 태그 입력과 대표 이미지 선택 흐름 결정
|
||||
|
||||
관리자 글 설정의 태그 입력은 단순 텍스트 필드가 아니라 배지형 입력으로 처리한다. 태그 입력 중 Enter가 폼 제출로 전파되면 의도치 않게 게시물이 저장 또는 발행될 수 있으므로, Enter와 쉼표는 태그 추가 동작으로만 사용한다.
|
||||
|
||||
Canonical URL과 OG 이미지는 별도 입력 항목에서 제외한다. 현재 운영 흐름에서는 기본 글 주소와 대표 이미지가 자연스러운 기본값이며, 별도 OG 이미지를 관리하면 글 설정 패널이 불필요하게 길어지고 대표 이미지와 공유 이미지가 어긋날 수 있기 때문이다.
|
||||
|
||||
대표 이미지는 업로드 탭과 미디어 라이브러리 탭을 함께 제공한다. 작성자는 새 이미지를 바로 올릴 수도 있고, 이미 업로드한 이미지를 재사용할 수도 있어야 하기 때문이다.
|
||||
|
||||
## 2026-05-07 v0.0.41
|
||||
|
||||
### 명령 메뉴 계층과 개발 도구 표시 결정
|
||||
|
||||
관리자 블록 에디터의 `/` 명령 메뉴가 열린 행은 다른 블록 행보다 위 stacking 순서로 올린다. 메뉴가 절대 위치로 열릴 때 아래 블록의 텍스트가 같은 레이어에 남아 있으면 메뉴 배경 위로 겹쳐 보일 수 있기 때문이다.
|
||||
|
||||
블록 이동은 `drop` 이벤트뿐 아니라 `dragend`에서도 현재 삽입선 위치를 기준으로 확정한다. 브라우저와 입력 요소 조합에 따라 contenteditable 주변에서 `drop` 이벤트가 안정적으로 들어오지 않을 수 있으므로, 사용자가 본 삽입선과 실제 결과가 어긋나지 않게 하기 위해서다.
|
||||
|
||||
개발 서버의 Nuxt DevTools는 현재 관리자 글쓰기 전체 화면 QA를 방해하므로 기본 비활성화한다. 하단 검은 도킹 패널은 애플리케이션 UI가 아니라 개발 도구 영역이지만, 편집 화면 높이와 스크롤 문제를 확인할 때 혼동을 만들 수 있기 때문이다.
|
||||
|
||||
## 2026-05-07 v0.0.40
|
||||
|
||||
### 글쓰기 스크롤과 드래그 드롭 피드백 결정
|
||||
|
||||
관리자 글 작성/수정 화면은 글쓰기 라우트에서 관리자 레이아웃 자체를 화면 높이로 고정하고, 실제 세로 스크롤은 에디터 작업 영역 내부에서만 처리한다. 작성 화면 아래로 문서 전체가 밀리면 전역 다크 배경이 노출될 수 있고, Ghost 스타일 전체 화면 편집 모드의 집중감도 깨지기 때문이다.
|
||||
|
||||
블록 드래그 이동은 대상 블록 위/아래 절반을 기준으로 삽입 위치를 계산하고 같은 위치에 삽입선을 표시한다. 사용자가 놓는 순간의 결과를 드롭 전에 알 수 있어야 블록형 에디터의 이동 조작이 안전하게 느껴지기 때문이다.
|
||||
|
||||
## 2026-05-07 v0.0.39
|
||||
|
||||
### 블록 에디터 줄바꿈과 핸들 표시 보정 결정
|
||||
|
||||
관리자 블록 에디터의 Shift+Enter는 브라우저 기본 동작에 맡기지 않고 에디터가 줄바꿈 문자를 직접 삽입한 뒤 커서를 줄바꿈 뒤로 복구한다. `contenteditable`의 기본 줄바꿈은 브라우저별로 `<div>`, `<br>`, 텍스트 노드 처리가 달라 Vue 상태 동기화와 충돌할 수 있고, 특히 문단 끝에서 커서가 문단 앞으로 이동하는 현상을 만들 수 있기 때문이다.
|
||||
|
||||
블록 핸들은 문자 아이콘 대신 AFFiNE 참고 스타일의 세로 막대로 표시한다. 작성 중에는 시각 소음을 줄이고, hover 또는 선택 상태에서는 막대가 블록 높이만큼 늘어나 사용자가 선택, 삭제, 드래그할 범위를 바로 인식하게 하기 위해서다.
|
||||
|
||||
## 2026-05-07 v0.0.38
|
||||
|
||||
### 에디터 문단 모델과 설정 패널 액션 배치 결정
|
||||
|
||||
관리자 블록 에디터에서 Shift+Enter는 같은 블록 안의 줄바꿈으로, Enter는 새 문단 블록 생성으로 구분한다. Ghost와 AFFiNE 계열 에디터처럼 한 문단 안의 강제 줄바꿈과 문단 종료가 달라야 블록 선택, 삭제, 이동 범위가 사용자가 인식하는 문단 단위와 맞기 때문이다.
|
||||
|
||||
블록 간격은 각 구조형 블록의 위아래 margin을 섞지 않고 다음 블록의 `margin-top` 기준으로 정리한다. 이렇게 하면 갤러리, 코드, 토글 같은 구조형 블록을 선택했을 때 선택 범위 바깥으로 불필요한 여백이 잡히는 일을 줄이고, 인접 블록 사이 간격도 한 방향에서 관리할 수 있다.
|
||||
|
||||
글 수정 화면의 보기와 삭제 액션은 편집 본문 위에 띄우지 않고 우측 게시물 설정 패널 안에 배치한다. 보기 액션은 Post URL과 직접 관련되므로 Post URL 라벨 오른쪽에 두고, 삭제는 파괴적 액션이므로 설정 패널 하단의 독립 버튼으로 분리한다.
|
||||
|
||||
## 2026-05-07 v0.0.37
|
||||
|
||||
### 블록 에디터 입력 안정성과 블록 핸들 범위 결정
|
||||
|
||||
관리자 블록 에디터의 한글 조합 입력은 compositionend 직후 DOM 텍스트가 완전히 반영된 다음 슬래시 메뉴 필터와 Enter 처리를 갱신한다. 조합 직후 별도 Enter guard로 입력을 막으면 글자가 확정된 뒤에도 사용자가 Enter를 한 번 더 눌러야 하므로, 조합 중 이벤트만 막고 조합 종료 후에는 즉시 일반 입력 흐름으로 돌린다.
|
||||
|
||||
블록 핸들은 1차로 선택, Delete/Backspace 삭제, 드래그 이동까지 제공한다. AFFiNE식 잘라내기, 복사, 서식 툴바, 블록 단위 컨텍스트 메뉴는 작성 경험 전반에 영향을 주는 큰 기능이므로 기본 블록 조작이 안정화된 뒤 별도 단계로 확장한다.
|
||||
|
||||
## 2026-05-07 v0.0.35
|
||||
|
||||
### 관리자 글쓰기 전체 화면 모드 보정 결정
|
||||
|
||||
관리자 글 작성/수정 화면에서는 좌측 관리자 네비게이션과 공통 내부 패딩을 숨기고, 글쓰기 폼이 브라우저 높이를 직접 사용하는 전체 화면 편집 모드로 동작하게 한다. Ghost와 WordPress류 편집 화면은 작성 중 관리자 메뉴보다 글 본문과 설정 패널의 관계가 더 중요하므로, 네비게이션이 보이면 작성 영역을 불필요하게 압축하고 시선을 분산시키기 때문이다.
|
||||
|
||||
글쓰기 화면의 1차 레이아웃은 상단 헤더 전체 폭이 아니라 에디터 작업 영역과 우측 설정 패널의 좌우 분할로 둔다. 설정 패널이 열리거나 닫힐 때 에디터 작업 영역의 상단 도구막대와 본문 폭이 함께 변해야 사용자가 현재 편집 가능한 폭 변화를 자연스럽게 인식할 수 있다.
|
||||
|
||||
## 2026-05-07 v0.0.32
|
||||
|
||||
### 관리자 글 작성 화면 구조 정리 결정
|
||||
|
||||
관리자 글 작성/수정 화면은 별도 페이지 제목 영역을 제거하고, 글쓰기 폼 자체의 상단 도구막대와 본문 중심 레이아웃으로 정리한다. 기존 “새 글 작성” 제목은 현재 작업 맥락을 반복해 화면 높이만 차지했고, 실제 작성 흐름에서는 제목 입력과 본문 시작 위치를 아래로 밀어 Ghost 스타일 편집감과 멀어졌기 때문이다.
|
||||
|
||||
대표 이미지는 설정 패널의 부가 항목이 아니라 글 제목 위의 본문 흐름에서 바로 추가하도록 둔다. 게시물 설정은 420px 우측 패널로 분리하고 토글할 수 있게 해, 설정을 열었을 때는 Figma 설정 패널 상태를 따르고 닫았을 때는 집중형 작성 화면을 유지한다.
|
||||
|
||||
## 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
|
||||
|
||||
### 초기 제품 방향 결정
|
||||
|
||||
134
docs/map.md
134
docs/map.md
@@ -6,9 +6,9 @@
|
||||
|
||||
| 파일 | 화면 |
|
||||
|------|------|
|
||||
| layouts/default.vue | 메인, 목록, 태그 페이지 |
|
||||
| layouts/post.vue | 개별 게시물 |
|
||||
| layouts/admin.vue | 관리자 전체 |
|
||||
| layouts/default.vue | 메인·목록·태그 — 문서 스크롤 + 스티키 사이드, 사이드 내부는 무스크롤바 스크롤 |
|
||||
| layouts/post.vue | 개별 게시물 — 동일 |
|
||||
| layouts/admin.vue | 관리자 전체, 글 작성/수정 화면의 전체 화면 편집 모드와 문서 스크롤 잠금 |
|
||||
| layouts/page.vue | 고정 페이지 전체 화면 |
|
||||
|
||||
## 사이트 컴포넌트
|
||||
@@ -16,17 +16,27 @@
|
||||
| 파일 | 화면 위치 |
|
||||
|------|-----------|
|
||||
| components/site/SiteHeader.vue | 모든 공개 페이지 상단 |
|
||||
| components/site/LeftSidebar.vue | 메인 화면 왼쪽 |
|
||||
| components/site/RightSidebar.vue | 메인 화면 오른쪽 |
|
||||
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `sticky`+내부 무스크롤바 스크롤, 하단 푸터 고정 |
|
||||
| components/site/RightSidebar.vue | 오른쪽 사이드바, 동일 패턴, 카피라이트 하단 고정 |
|
||||
| components/site/MainColumn.vue | 메인 화면 중앙 |
|
||||
| components/site/PostCard.vue | 목록의 게시물 카드 |
|
||||
| components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일, 카드 hover 인터랙션 |
|
||||
| components/site/TagHeader.vue | 태그 페이지 헤더 |
|
||||
|
||||
## 관리자 컴포넌트
|
||||
|
||||
| 파일 | 화면 위치 |
|
||||
|------|-----------|
|
||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost 스타일 전체 화면 에디터, 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 하단 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 배지형 태그 입력, 로컬 자동 저장, 예약 발행 시각 입력, SEO 설정, 미리보기 요청 |
|
||||
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
|
||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
||||
| 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 | 목록 |
|
||||
@@ -38,26 +48,124 @@
|
||||
| components/content/ProseAudio.vue | 오디오 |
|
||||
| components/content/ProseFile.vue | 파일 |
|
||||
| components/content/ProseProduct.vue | 상품 카드 |
|
||||
| components/content/ProseBookmark.vue | 본문 북마크 카드(썸네일·도메인·외부 링크) |
|
||||
| components/content/ProseSignup.vue | 본문 회원가입·뉴스레터 CTA 카드 |
|
||||
| components/content/ProseHeaderCard.vue | 헤더 카드 |
|
||||
| components/content/ProseEmbed.vue | YouTube, Twitter 임베드 |
|
||||
| components/content/ProseEmbed.vue | YouTube·Twitter/X iframe 임베드, 기타 외부 링크 |
|
||||
|
||||
## 관리자 페이지
|
||||
|
||||
| 파일 | 화면 |
|
||||
|------|------|
|
||||
| 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 | 글 작성, Ghost 스타일 작성 폼, 저장 토스트 |
|
||||
| pages/admin/posts/[id].vue | 글 수정, Ghost 스타일 작성 폼, 저장/삭제 토스트 |
|
||||
| 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 | 사이트 설정 |
|
||||
|
||||
## 공개 페이지
|
||||
|
||||
| 파일 | 화면 |
|
||||
|------|------|
|
||||
| pages/index.vue | 홈 |
|
||||
| pages/posts/[slug].vue | 블로그 글 상세 |
|
||||
| pages/tags/[slug].vue | 태그별 글 목록 |
|
||||
| pages/index.vue | 홈, 중앙 Hero/Featured/Latest 섹션의 내부 컨테이너 보더 정렬과 리스트형 latest 카드 |
|
||||
| pages/posts/index.vue | 게시물 전체 목록 |
|
||||
| pages/posts/[slug].vue | `/post/:slug` 리다이렉트 |
|
||||
| pages/post/[slug].vue | 블로그 글 상세, 게시물 SEO/OG 메타 출력 |
|
||||
| pages/tags/index.vue | 태그 전체 목록, 중앙 히어로와 3열 태그 카드, 좌측 컬러 보더/hover 오버레이 |
|
||||
| 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 모듈 연결, 관리자 QA를 위한 개발 도구 비활성화 |
|
||||
| tailwind.config.js | Tailwind 테마 설정 |
|
||||
| assets/css/main.css | 전역 스타일, `#fcfcfc` 단일 배경 기준, 좌측 사이드바/그리드 전환 애니메이션, 네비게이션 세로 바 hover 효과 |
|
||||
| composables/useMenuState.js | 좌측 메뉴 열림 상태 관리 |
|
||||
| composables/useThemeMode.js | 사용자 화면 라이트/다크 테마 상태 관리 |
|
||||
| middleware/admin-auth.global.js | 관리자 페이지 접근 인증 |
|
||||
| scripts/dev-server.js | 로컬 개발 서버 링크 요약 출력 |
|
||||
| scripts/migrate-development-db.js | 로컬 개발 DB 마이그레이션 실행 |
|
||||
| .env.example | 환경 변수 예시 |
|
||||
| Dockerfile | NAS 운영 이미지 빌드 |
|
||||
| docker-compose.yml | NAS 컨테이너 실행 초안 |
|
||||
|
||||
322
docs/spec.md
322
docs/spec.md
@@ -6,7 +6,7 @@
|
||||
- **유형**: 커스텀 블로그/CMS
|
||||
- **목표**: 개인 블로그 중심 운영, 기존 포털성 링크와 서비스 진입점은 블로그 내부 구조에 통합
|
||||
- **참조**: Ghost(관리자 UX/글쓰기), Thred 테마(사용자 화면)
|
||||
- **현재 상태**: 코드 스캐폴딩 전 문서 기준점
|
||||
- **현재 상태**: Nuxt 3 초기 스캐폴딩과 PostgreSQL 저장소 계층 구성 완료
|
||||
- **원격 저장소**: https://git.sori.studio/zenn/sori.studio.git
|
||||
|
||||
---
|
||||
@@ -17,10 +17,28 @@
|
||||
|
||||
| 요소 | 크기/속성 |
|
||||
|------|-----------|
|
||||
| Header | 높이 57px |
|
||||
| Left Aside | 너비 287px, 패딩 12px 12px 12px 0 |
|
||||
| Main | 너비 720px, 패딩 32px 24px (헤더), 16px 24px (섹션) |
|
||||
| Right Aside | 너비 287px, 패딩 20px 0 20px 20px |
|
||||
| Header | 높이 57px, `sticky top-0`, `shrink-0` |
|
||||
| Shell | `min-height: 100vh`, `flex` 세로 컬럼 |
|
||||
| 그리드(데스크톱 `lg+`) | `items-start`, 본문(중앙) 높이에 맞춰 행이 늘어남 — **문서(`html`/`body`) 스크롤**로 긴 본문 처리(스크롤바는 브라우저 오른쪽) |
|
||||
| Left Aside | 너비 287px, `sticky top-[57px]`, `max-h-[calc(100vh-57px)]`, 내부 상단은 `.site-sidebar-scroll`(스크롤바 숨김), 하단 푸터 `shrink-0` |
|
||||
| Main | 너비 720px, 별도 `overflow-y` 없음 — 뷰포트와 동일한 문서 스크롤에 포함 |
|
||||
| Right Aside | Left와 동일 패턴(스티키·최대 높이·내부 무스크롤바 스크롤·하단 카피라이트) |
|
||||
|
||||
### 메뉴 토글
|
||||
|
||||
- 헤더 좌측 아이콘은 브랜드 마크가 아니라 왼쪽 사이드바 열기/닫기 버튼
|
||||
- 메뉴 상태는 Nuxt/Vue 상태로 관리
|
||||
- 브라우저에서는 `localStorage.MENU_STATE`에 `open` 또는 `closed` 저장
|
||||
- 닫힘 상태에서는 왼쪽 사이드바 폭을 0으로 줄이는 전환 애니메이션을 적용
|
||||
|
||||
### 공개 화면 색상
|
||||
|
||||
- 라이트/다크 모드는 CSS 변수로 관리
|
||||
- 기본 배경, 패널, 라인, 텍스트, 보조 텍스트, 입력, 강조 버튼 색상을 분리
|
||||
- 라이트 모드 기본 배경은 `#fcfcfc`로 통일하고 패널 구분은 보더로 처리
|
||||
- 시스템 다크 모드는 `prefers-color-scheme: dark` 기준으로 우선 대응
|
||||
- 사용자 수동 테마 전환은 `html[data-theme]`와 `localStorage.SITE_THEME`로 관리
|
||||
- Thred 참고 화면처럼 사이드바와 본문은 같은 화면 안에서 구분되는 패널과 라인으로 표현
|
||||
|
||||
### Post 페이지
|
||||
|
||||
@@ -34,6 +52,14 @@
|
||||
- 헤더와 사이드바를 사용하지 않고 본문 중심 전체 화면으로 표시
|
||||
- 진입 경로는 추후 메뉴/링크 설정을 통해 연결
|
||||
|
||||
### 공개 URL 구조
|
||||
|
||||
- `/posts` - 게시물 전체 목록
|
||||
- `/post/:slug` - 개별 게시물 상세
|
||||
- `/tags` - 태그 전체 목록
|
||||
- `/tag/:slug` - 태그별 게시물 목록
|
||||
- 기존 `/posts/:slug`, `/tags/:slug` 상세 경로는 새 단수형 상세 경로로 리다이렉트한다.
|
||||
|
||||
### 레이아웃 파일
|
||||
|
||||
```
|
||||
@@ -75,16 +101,44 @@ components/content/
|
||||
├── ProseAudio.vue # Audio 카드
|
||||
├── ProseFile.vue # File 카드
|
||||
├── ProseProduct.vue # Product 카드
|
||||
├── ProseBookmark.vue # 북마크 카드(썸네일·제목·도메인)
|
||||
├── ProseSignup.vue # 회원가입/뉴스레터 CTA 카드
|
||||
├── ProseHeaderCard.vue # Header 카드 (Simple/Wide/Full-width/Split)
|
||||
└── ProseEmbed.vue # Embeds (YouTube, Twitter)
|
||||
└── ProseEmbed.vue # Embeds (YouTube iframe, Twitter/X iframe, 기타 링크)
|
||||
```
|
||||
|
||||
### 공개 본문 스타일 가이드(Thred 기준)
|
||||
|
||||
- 리스트
|
||||
- Unordered: `- 항목`
|
||||
- Ordered: `1. 항목`
|
||||
- 렌더링: `ProseList.vue` (마커 컬러, 간격, 줄높이 통일)
|
||||
- 인용구
|
||||
- 기본: `> 한 줄` 또는 `>` 연속 여러 줄(멀티라인)
|
||||
- 대체 스타일(Alternative): `>>>`로 시작해 `<<<`로 끝나는 블록
|
||||
- 렌더링: `ProseBlockquote.vue` (`variant=default|alt`)
|
||||
- 이미지
|
||||
- 기본: ``
|
||||
- 와이드/풀: `{width=wide|full}`
|
||||
- 렌더링: `ProseImage.vue` (라운드/보더/패널 배경)
|
||||
- 이미지 갤러리
|
||||
- `:::gallery` ~ `:::` fenced block 내부에 이미지 마크다운 행을 여러 개 작성
|
||||
- 렌더링: `ContentMarkdownRenderer.vue` (그리드 + 라이트박스)
|
||||
- 카드류
|
||||
- Callout: `:::callout` ~ `:::` (왼쪽 강조선은 `var(--site-accent)`)
|
||||
- Toggle: `:::toggle 제목` ~ `:::`
|
||||
- Bookmark: `:::bookmark` ~ `:::` (본문은 `url=`, `title=`, `description=`, `thumbnail=` 키값 또는 첫 줄 URL·이어지는 제목/설명 줄)
|
||||
- Signup: `:::signup` ~ `:::` (선택: `title=`, `description=`, `button=`, `placeholder=`)
|
||||
- Embed: `:::embed` ~ `:::` (YouTube·YouTube Shorts URL은 iframe, `twitter.com`·`x.com` 게시물 URL은 X 공식 embed iframe, 그 외는 외부 링크 카드)
|
||||
- 렌더링: `ProseCallout.vue`, `ProseToggle.vue`, `ProseBookmark.vue`, `ProseSignup.vue`, `ProseEmbed.vue`
|
||||
|
||||
---
|
||||
|
||||
## 데이터베이스 구조
|
||||
|
||||
### 환경 분리 원칙
|
||||
|
||||
- 데이터베이스는 PostgreSQL을 기준으로 한다.
|
||||
- 로컬 개발 환경과 NAS 운영 환경은 서로 다른 DB를 사용
|
||||
- 로컬 개발 서버는 개발 DB만 연결
|
||||
- NAS 배포 환경은 운영 DB만 연결
|
||||
@@ -100,7 +154,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 +173,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 +185,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 +238,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 +263,148 @@ 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` 필드의 마크다운 문자열을 유지한다.
|
||||
- `/` 입력 시 블록 선택 메뉴를 표시한다.
|
||||
- `/` 명령 메뉴는 화면 하단 공간이 부족하면 현재 블록 위쪽으로 표시한다.
|
||||
- `/` 명령 메뉴가 열린 블록 행은 아래 블록보다 위 stacking 순서로 표시해 메뉴와 본문 텍스트가 겹쳐 보이지 않게 한다.
|
||||
- `/` 명령 메뉴가 열린 상태에서 Enter를 누르면 현재 강조된 메뉴 항목을 적용한다.
|
||||
- `/` 명령 메뉴가 열린 상태에서 위/아래 방향키로 강조 항목을 이동한다.
|
||||
- `/` 명령 메뉴 필터는 한글 조합 입력 완료와 방향키/Enter 입력 직전에 현재 DOM 텍스트를 기준으로 동기화한다.
|
||||
- `/갤`처럼 필터 결과가 하나로 좁혀진 상태에서 Enter를 누르면 해당 블록 명령을 적용한다.
|
||||
- 블록 메뉴는 문단, 제목 2, 제목 3, 이미지, 갤러리, 콜아웃, 토글, 임베드, 인용, 목록, 코드, 구분선을 제공한다.
|
||||
- `#`, `##`, `###`, `>`, `-` 입력 후 공백을 누르거나 ` ``` `을 입력하면 현재 블록 타입을 즉시 변환한다.
|
||||
- 한글 등 조합형 입력 중에는 단축 문법과 슬래시 메뉴 상태를 확정하지 않고 조합 종료 뒤 반영한다.
|
||||
- 한글 등 조합형 입력 직후 확정된 텍스트로 슬래시 메뉴 필터와 Enter 블록 이동을 반영한다.
|
||||
- 한글 등 조합형 입력 중 Shift+Enter가 들어오면 조합 완료 직후 줄바꿈을 예약 적용한다.
|
||||
- Shift+Enter는 같은 텍스트 블록 안에 줄바꿈 문자를 직접 삽입하고 커서를 줄바꿈 뒤로 유지하며, Enter는 다음 문단 블록을 생성한다.
|
||||
- 빈 문단에서 Enter를 누르면 다음 빈 문단 블록을 생성한다.
|
||||
- 문단 간 기본 간격은 다음 블록의 `margin-top: 32px` 기준으로 조정한다.
|
||||
- 블록 왼쪽 핸들은 hover/focus 상태에서 AFFiNE 참고 스타일의 세로 막대로 표시되며, hover 시 해당 블록 높이만큼 확장해 선택 범위를 드러낸다.
|
||||
- 블록 왼쪽 핸들을 클릭하면 블록을 선택하고 Delete 또는 Backspace로 해당 블록을 삭제할 수 있다.
|
||||
- 블록 왼쪽 핸들을 드래그하면 블록 순서를 이동할 수 있다.
|
||||
- 블록 드래그 중에는 현재 포인터 위치 기준으로 대상 블록 위 또는 아래에 삽입선을 표시하고, 드롭 또는 드래그 종료 시 표시 위치와 같은 곳으로 이동한다.
|
||||
- 빈 블록 placeholder는 현재 활성 블록 또는 첫 빈 블록에만 표시한다.
|
||||
- 에디터 마지막에는 클릭 가능한 빈 문단 블록을 항상 유지하며, 해당 블록이 비어 있으면 저장 콘텐츠에는 포함하지 않는다.
|
||||
- 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다.
|
||||
- 글 작성/수정 화면은 페이지 제목 헤더를 별도로 두지 않고, 폼 상단의 Ghost 스타일 도구막대에서 목록 이동, 상태, 미리보기, 저장, 설정 패널 토글을 제공한다.
|
||||
- 글 작성/수정 화면은 관리자 사이드바 네비게이션을 숨기고, 관리자 공통 내부 패딩 없이 전체 화면 편집 모드로 표시한다.
|
||||
- 글 작성/수정 화면은 브라우저 문서가 아니라 에디터 작업 영역 내부에서만 세로 스크롤한다.
|
||||
- 글 작성/수정 폼은 에디터 작업 영역과 우측 설정 패널을 먼저 좌우로 나누고, 에디터 작업 영역 안에 상단 도구막대와 본문 스크롤 영역을 배치한다.
|
||||
- 본문 에디터는 데스크톱에서 좌우 32px 패딩을 포함한 804px 중앙 컬럼으로 배치해 실제 입력 영역 최대 폭을 740px로 유지한다.
|
||||
- 게시물 설정은 데스크톱에서 420px 우측 패널로 표시하며, 도구막대 버튼으로 열고 닫을 수 있고 너비 전환 애니메이션을 적용한다.
|
||||
- 공개 가능한 글의 보기 액션은 게시물 설정 패널의 Post URL 라벨 오른쪽에 표시한다.
|
||||
- 글 삭제 액션은 게시물 설정 패널 하단의 빨간 outline 버튼으로 제공한다.
|
||||
- 대표 이미지는 본문 제목 위의 에디터 흐름 안에서 추가, 변경, 삭제한다.
|
||||
- 대표 이미지가 설정된 상태의 변경/삭제 액션은 실제 이미지 레이아웃을 바꾸지 않도록 이미지 hover 또는 focus 오버레이로 표시한다.
|
||||
- 대표 이미지 선택 모달은 이미지 업로드 탭과 미디어 라이브러리 탭을 제공한다.
|
||||
- 제목 입력에서 Enter를 누르면 본문 첫 블록으로 포커스를 이동한다.
|
||||
- 새 글 작성처럼 본문이 비어 있는 경우에도 빈 문단 블록을 먼저 생성한다.
|
||||
- 글 작성/수정 중인 입력값은 브라우저 `localStorage`에 자동 저장한다.
|
||||
- 자동 저장 키는 `SORI_ADMIN_POST_AUTOSAVE:new` 또는 `SORI_ADMIN_POST_AUTOSAVE:{postId}` 형식을 사용한다.
|
||||
- 자동 저장본이 있으면 작성 화면에서 복원 또는 삭제를 선택할 수 있다.
|
||||
- 글 저장 성공 시 해당 자동 저장본은 삭제한다.
|
||||
- 미리보기 버튼은 현재 작성 폼 값을 `SORI_ADMIN_POST_PREVIEW` 브라우저 저장소에 기록한 뒤 `/admin/posts/preview` 새 탭에서 렌더링한다.
|
||||
- 미리보기 화면은 공개 게시물 상세와 같은 본문 렌더링 컴포넌트를 사용하되, 데이터베이스에는 임시 글을 저장하지 않는다.
|
||||
- 글 저장/수정/삭제 진행과 성공/실패 상태는 화면 우측 상단 토스트로 표시한다.
|
||||
- 발행 상태에서 발행 시각을 미래로 지정하면 예약 발행으로 저장한다.
|
||||
- 예약 발행 글은 관리자 목록에서 예약 상태로 표시하되 공개 게시물 목록과 상세 API에는 발행 시각 이후부터 노출한다.
|
||||
- 관리자 글 에디터의 실제 입력 텍스트 색상은 placeholder보다 진하게 표시한다.
|
||||
- 관리자 작성 화면과 공개 본문은 같은 마크다운 렌더링 기준을 사용한다.
|
||||
- 태그 입력은 배지형 입력으로 제공하며 Enter 또는 쉼표 입력 시 태그를 추가하고, 배지의 x 버튼으로 삭제한다.
|
||||
- 대표 이미지는 URL 직접 입력이 아니라 미디어 선택 또는 이미지 업로드 탭으로 설정한다.
|
||||
- 대표 이미지 선택 모달에서 미디어를 클릭하면 선택 상태만 표시하고, 하단 대표 이미지로 적용 버튼을 눌렀을 때 글 폼에 반영한다.
|
||||
- 대표 이미지가 설정되면 관리자 글 폼에 썸네일과 hover 오버레이 삭제/변경 액션을 표시한다.
|
||||
- 글 SEO 설정은 SEO 제목, SEO 설명, 검색엔진 노출 제외 여부를 저장한다.
|
||||
- Canonical URL은 별도 입력을 받지 않고 기본 글 주소를 사용한다.
|
||||
- 공개 게시물 상세 화면은 SEO 제목이 없으면 글 제목, SEO 설명이 없으면 요약을 메타 태그 기본값으로 사용한다.
|
||||
- 검색엔진 노출 제외가 켜진 글은 robots 메타를 `noindex, nofollow`로 출력한다.
|
||||
- 공개 상세 화면의 `og:image`와 Twitter large image 카드는 대표 이미지를 기본값으로 사용한다.
|
||||
- 이미지 블록은 관리자 업로드 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으로 렌더링한다.
|
||||
- Twitter/X 게시물 URL(`twitter.com`·`x.com`·`mobile.twitter.com`, 경로에 `status` 포함)은 `platform.twitter.com/embed/Tweet.html` iframe으로 렌더링하며, 테마는 `useThemeMode()`와 동기화한다.
|
||||
- 그 외 URL은 외부 링크 텍스트 카드로 표시한다.
|
||||
- 북마크 블록은 `:::bookmark` fenced block으로 저장할 수 있으며 공개 화면에서 Thred형 가로 카드로 렌더링한다.
|
||||
- 회원가입(뉴스레터) CTA는 `:::signup` fenced block으로 저장할 수 있으며 실제 폼 연동은 후속 작업으로 분리한다.
|
||||
|
||||
### 관리자 페이지 편집
|
||||
|
||||
- 고정 페이지 작성/수정 화면은 게시물과 같은 블록형 에디터를 사용한다.
|
||||
- 고정 페이지는 제목, 슬러그, 본문, 대표 이미지만 저장한다.
|
||||
- 고정 페이지는 게시물 목록과 태그 목록에 노출하지 않는다.
|
||||
- 고정 페이지 공개 보기 경로는 `/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 +419,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별 경로 메타데이터로 저장한다.
|
||||
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
|
||||
- 미디어 사용 현황은 게시물/페이지의 대표 이미지와 본문 내 URL을 기준으로 표시한다.
|
||||
- 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다.
|
||||
- 향후 미디어 라이브러리는 프로필/사이트 설정 이미지 사용처 추적을 제공한다.
|
||||
|
||||
---
|
||||
|
||||
## 환경 변수 (.env)
|
||||
@@ -181,34 +441,56 @@ 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`를 사용
|
||||
- 로컬 DB 확인은 PostgreSQL 클라이언트에서 `127.0.0.1:43119`로 접속하거나 `docker exec sori-studio-db psql` 명령으로 확인한다.
|
||||
|
||||
### 포트 기준
|
||||
|
||||
| 용도 | 포트 |
|
||||
|------|------|
|
||||
| 로컬 개발 서버 | 43117 |
|
||||
| NAS Docker 외부 포트 | 43118 |
|
||||
| 컨테이너 내부 포트 | 3000 |
|
||||
| PostgreSQL 외부 포트 | 43119 |
|
||||
|
||||
---
|
||||
|
||||
## 버전 관리
|
||||
|
||||
- 현재 버전: v0.0.1
|
||||
- 현재 버전: v0.0.50
|
||||
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
|
||||
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정
|
||||
|
||||
74
docs/todo.md
74
docs/todo.md
@@ -2,68 +2,42 @@
|
||||
|
||||
## 1차 관리자 개발
|
||||
|
||||
- [ ] 로그인 기능 구현
|
||||
- [ ] 글 목록 조회
|
||||
- [ ] 글 작성/수정 (마크다운 에디터)
|
||||
- [ ] 글 발행/비공개 전환
|
||||
- [ ] 태그 관리 (생성/수정/삭제)
|
||||
- [ ] 이미지 업로드
|
||||
- [ ] 블록 핸들 액션 메뉴 확장: 잘라내기, 복사, 붙여넣기, 블록 타입 변경, 선택 블록 서식 적용
|
||||
|
||||
## 2차 관리자 개발
|
||||
|
||||
- [ ] 페이지 관리 (CRUD)
|
||||
- [ ] 사이트 설정
|
||||
- [ ] 메뉴/네비게이션 관리
|
||||
- [ ] 미디어 라이브러리
|
||||
|
||||
## 3차 관리자 개발
|
||||
|
||||
- [ ] 예약 발행
|
||||
- [ ] 초안 자동 저장
|
||||
- [ ] SEO 설정
|
||||
- [ ] OG 이미지 설정
|
||||
- [ ] 글 미리보기
|
||||
- [ ] 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적
|
||||
|
||||
## 프론트엔드 개발
|
||||
|
||||
- [ ] 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
|
||||
- [ ] 사이드바 토글 애니메이션 세부 조정
|
||||
- [ ] 사용자 화면 테마 전환 초기 로드 깜빡임(FART) 최소화
|
||||
|
||||
## 콘텐츠 스타일 구현
|
||||
|
||||
- [ ] 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 변형 구현
|
||||
## 데이터베이스
|
||||
|
||||
- [ ] Posts 테이블 설계
|
||||
- [ ] Pages 테이블 설계
|
||||
- [ ] Tags 테이블 설계
|
||||
- [ ] PostTags 테이블 설계
|
||||
- [ ] 로컬 개발 DB 연결 설정 작성
|
||||
- [ ] NAS 운영 DB 연결 설정 작성
|
||||
- [ ] 개발 DB와 운영 DB 분리 검증 절차 작성
|
||||
- [ ] CloudBeaver 등 DB 관리 도구 연결 방식 결정
|
||||
- [ ] NAS 운영 DB 연결 설정 실제 값 작성
|
||||
- [ ] 이전에 원격에 올라간 관리자 비밀번호가 실제 사용 값이면 즉시 폐기 및 변경
|
||||
|
||||
## 배포
|
||||
|
||||
- [ ] UGREEN NAS Docker 배포 가이드 작성
|
||||
- [ ] 로컬 개발 환경 가이드 작성
|
||||
- [ ] Dockerfile 작성
|
||||
- [ ] docker-compose.yml 작성
|
||||
- [ ] .env.example 작성
|
||||
- [ ] Docker 빌드 검증
|
||||
- [ ] `.env.production` 작성 후 `docker compose --env-file .env.production config` 검증
|
||||
- [ ] NAS 운영 환경 변수 작성
|
||||
|
||||
472
docs/update.md
472
docs/update.md
@@ -1,5 +1,477 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v0.0.50
|
||||
|
||||
- 데스크톱(`lg+`)에서 긴 본문은 **문서 스크롤**(브라우저 오른쪽 스크롤바)로 처리하고, `main` 단독 스크롤은 제거.
|
||||
- 좌·우 사이드바는 `sticky`+`max-h`로 뷰포트에 맞추고, 내부 스크롤은 `.site-sidebar-scroll`로 스크롤바만 숨김(휠·트랙패드 스크롤 유지).
|
||||
|
||||
## v0.0.49
|
||||
|
||||
- 데스크톱(`lg+`) 공개 레이아웃을 뷰포트 높이에 고정하고 중앙 컬럼만 세로 스크롤되도록 변경.
|
||||
- 좌·우 사이드바는 열 높이 안에서 본문만 스크롤하고 푸터(링크·테마·카피라이트)는 항상 열 하단에 고정.
|
||||
|
||||
## v0.0.48
|
||||
|
||||
- 공개 본문에 Thred형 북마크 카드(`ProseBookmark`, `:::bookmark`)와 회원가입 CTA(`ProseSignup`, `:::signup`) 추가.
|
||||
- `ProseEmbed`에서 Twitter/X 게시물 URL을 공식 embed iframe으로 렌더링하고 테마와 동기화.
|
||||
- Callout 왼쪽 강조선·이미지 캡션 변수 색상 등 카드/미디어 스타일을 Thred에 가깝게 보정.
|
||||
|
||||
## v0.0.47
|
||||
|
||||
- 공개 본문 렌더러에서 Ordered list(숫자 목록) 문법을 지원.
|
||||
- 공개 본문 인용구를 멀티라인 처리하고 대체(Alternative) 인용구 스타일 문법을 추가.
|
||||
- 공개 본문 리스트/인용/이미지/갤러리/카드류 기본 스타일을 Thred 톤(보더/라운드/패널/타이포)으로 통일.
|
||||
- 기술 명세에 공개 본문 스타일 가이드를 추가.
|
||||
|
||||
## v0.0.46
|
||||
|
||||
- 홈 Latest 피드 보기 방식 토글(4종) 드롭다운과 localStorage 저장 추가.
|
||||
- 홈 Latest 피드 Default 아이콘을 원본(restore) SVG로 보정.
|
||||
- 홈/태그 상세 목록 Featured 번개 아이콘을 SVG로 교체.
|
||||
- 홈/태그 상세 목록 댓글 아이콘을 SVG로 교체.
|
||||
- Nuxt future 호환성 버전을 명시해 설정 resolve 오류를 예방.
|
||||
- 개발 서버 실행 스크립트에서 종료 시 최근 로그를 함께 출력하도록 보강.
|
||||
- 로컬 개발 환경 파일 감시(EMFILE) 이슈 완화를 위해 Vite watch polling 옵션 추가.
|
||||
- 게시물 레이아웃에서 누락된 그리드 클래스를 복구해 `/post/:slug` 화면 깨짐을 수정.
|
||||
- 게시물 상세 헤더를 Thred 패턴(제목+메타+썸네일) 기준으로 재구성.
|
||||
- 태그 목록 카드의 ↗ 텍스트 아이콘을 SVG 아이콘으로 교체.
|
||||
|
||||
## v0.0.45
|
||||
|
||||
- 사용자 화면 기본 배경을 `#fcfcfc`로 통일하고 보더 기준 구분으로 정리.
|
||||
- 오른쪽 사이드바 왼쪽 경계선이 항상 보이도록 보더 추가.
|
||||
- 왼쪽 사이드바 열림/닫힘 시 폭이 스르륵 줄어드는 전환 애니메이션 추가.
|
||||
- 왼쪽 네비게이션 항목 왼쪽에 회색 세로 바 표시를 추가하고 hover 시 원형 아이콘으로 전환되도록 수정.
|
||||
- 왼쪽 네비게이션 hover 배경색을 더 연하게 조정하고, 마우스 오버 시 텍스트가 함께 이동하도록 전환 효과 보정.
|
||||
- 왼쪽 사이드바 전환과 네비게이션 hover 효과 구현을 커스텀 CSS에서 Tailwind 유틸리티 클래스로 전환.
|
||||
- 왼쪽 네비게이션과 카테고리 영역의 패딩, 간격, hover 동작을 원본 Thred 마크업 패턴에 맞춰 재정렬.
|
||||
- 왼쪽 사이드바 네비게이션/카테고리/작성자 섹션의 내부 패딩과 텍스트 이동량을 미세 조정.
|
||||
- 태그 목록 페이지를 원본 Thred 구조에 맞춰 중앙 히어로와 3열 태그 카드 레이아웃으로 재구성.
|
||||
- 태그 상세 페이지(`tag/[slug]`)를 원본 Thred 구조에 맞춰 헤더 간격과 리스트형 게시물 카드 레이아웃으로 재구성.
|
||||
- 태그 상세 페이지 게시물 메타 영역에 featured 강조, 태그 컬러 배지, 구분자 스타일을 원본 패턴에 맞춰 보정.
|
||||
- 태그 상세 페이지에서 복수 태그 글은 첫 번째 태그만 배지로 표시하고, 배지와 `/` 구분자가 겹치지 않도록 메타 구조 수정.
|
||||
- 오른쪽 사이드바 Follow 영역을 원본 패턴의 소셜 아이콘 링크 행으로 교체.
|
||||
- 홈 중앙 메인 영역을 원본 Thred 구조에 맞춰 Hero/Featured/Latest 섹션 간격과 내부 보더 정렬 기준으로 재구성.
|
||||
- 홈 Latest와 태그 상세 목록 카드 우하단 hover 화살표 액션 버튼 추가.
|
||||
- 홈 Latest와 태그 상세 목록 카드의 우하단 액션 화살표를 텍스트에서 SVG 아이콘으로 교체.
|
||||
- 홈 Featured 영역을 그리드에서 가로 슬라이드 트랙(좌우 버튼 이동) 구조로 변경.
|
||||
- 홈 Featured 슬라이드 폭을 브레이크포인트별 1.4/1.6/2.6 카드 비율 계산식으로 보정.
|
||||
- 기술 명세 현재 버전을 v0.0.45로 갱신.
|
||||
|
||||
## v0.0.44
|
||||
|
||||
- 사용자 화면 다크/라이트 테마 전환 composable 추가.
|
||||
- 헤더와 좌측 사이드바에 테마 전환 버튼 연결.
|
||||
- 사용자 화면 링크, 카드, 입력, 버튼 hover 인터랙션 보강.
|
||||
- `ZCF-v1.0.5` 및 샘플 폴더가 Git에 포함되지 않도록 제외 규칙 추가.
|
||||
- 기술 명세 현재 버전을 v0.0.44로 갱신.
|
||||
|
||||
## v0.0.43
|
||||
|
||||
- 대표 이미지가 설정된 상태의 변경/삭제 액션을 이미지 아래 버튼 영역이 아니라 이미지 hover 오버레이로 수정.
|
||||
- 대표 이미지 선택 모달에서 미디어 클릭 시 즉시 적용하지 않고 선택 상태만 표시하도록 수정.
|
||||
- 대표 이미지 선택 모달 하단의 대표 이미지로 적용 버튼으로 선택 이미지를 확정하도록 수정.
|
||||
- 기술 명세 현재 버전을 v0.0.43으로 갱신.
|
||||
- 패키지 버전을 0.0.43으로 갱신.
|
||||
|
||||
## v0.0.42
|
||||
|
||||
- 관리자 글쓰기 화면의 문서 스크롤 잠금 클래스를 html/body에 직접 적용하도록 보강.
|
||||
- 관리자 글 설정의 태그 입력을 Figma 기준 배지형 입력으로 수정.
|
||||
- 태그 입력 중 Enter가 게시물 저장으로 이어지던 문제 수정.
|
||||
- 관리자 글 설정에서 Canonical URL과 OG 이미지 입력 UI 제거.
|
||||
- 게시물 저장 시 Canonical URL은 기본 글 주소, OG 이미지는 대표 이미지를 따르도록 정리.
|
||||
- 대표 이미지 선택 모달에 이미지 업로드와 미디어 라이브러리 탭 추가.
|
||||
- 기술 명세 현재 버전을 v0.0.42로 갱신.
|
||||
- 패키지 버전을 0.0.42로 갱신.
|
||||
|
||||
## v0.0.41
|
||||
|
||||
- 관리자 블록 에디터 `/` 명령 메뉴가 아래 블록 텍스트와 겹쳐 보이던 문제 수정.
|
||||
- 관리자 블록 에디터 드래그 종료 시 삽입선 위치 기준으로 블록 이동이 확정되도록 보정.
|
||||
- 개발 서버에서 Nuxt DevTools 도킹 패널로 보이는 하단 검은 영역을 막기 위해 DevTools 비활성화.
|
||||
- 기술 명세 현재 버전을 v0.0.41로 갱신.
|
||||
- 패키지 버전을 0.0.41로 갱신.
|
||||
|
||||
## v0.0.40
|
||||
|
||||
- 관리자 글쓰기 화면에서 바깥 문서가 함께 스크롤되어 하단 배경이 노출되던 문제 수정.
|
||||
- 관리자 블록 에디터 드래그 중 드롭 위치를 위/아래 삽입선으로 표시하도록 추가.
|
||||
- 관리자 블록 에디터 드롭 이동 위치가 표시된 삽입선과 일치하도록 수정.
|
||||
- 관리자 블록 에디터 한글 조합 중 Shift+Enter 입력 시 조합 완료 후 줄바꿈이 바로 적용되도록 보정.
|
||||
- 기술 명세 현재 버전을 v0.0.40으로 갱신.
|
||||
- 패키지 버전을 0.0.40으로 갱신.
|
||||
|
||||
## v0.0.39
|
||||
|
||||
- 관리자 블록 에디터 Shift+Enter 줄바꿈이 문단 첫 위치로 커서를 이동시키던 문제 수정.
|
||||
- 관리자 블록 에디터 텍스트 DOM 동기화를 `textContent` 기준으로 보정.
|
||||
- 관리자 블록 에디터 왼쪽 블록 핸들을 AFFiNE 참고 스타일의 세로 막대형 hover 표시로 수정.
|
||||
- 기술 명세 현재 버전을 v0.0.39로 갱신.
|
||||
- 패키지 버전을 0.0.39로 갱신.
|
||||
|
||||
## v0.0.38
|
||||
|
||||
- 관리자 블록 에디터 슬래시 메뉴 필터가 입력 즉시 동기화되도록 보정.
|
||||
- 관리자 블록 에디터에서 `/갤` Enter 입력 시 필터링된 갤러리 명령이 적용되도록 수정.
|
||||
- 관리자 블록 에디터에서 Shift+Enter 줄바꿈과 Enter 문단 생성을 구분하도록 수정.
|
||||
- 관리자 블록 에디터 구조형 블록의 자체 위아래 여백을 제거하고 블록 행 사이 margin-top 기준으로 정리.
|
||||
- 관리자 블록 에디터 하단의 불필요한 최소 높이로 생기던 빈 영역 제거.
|
||||
- 관리자 글 수정 화면의 보기/삭제 액션을 Figma 기준에 맞춰 게시물 설정 패널 내부로 이동.
|
||||
- 기술 명세 현재 버전을 v0.0.38로 갱신.
|
||||
- 패키지 버전을 0.0.38로 갱신.
|
||||
|
||||
## v0.0.37
|
||||
|
||||
- 관리자 블록 에디터의 한글 조합 입력 종료 후 Enter 처리 보정.
|
||||
- 관리자 블록 에디터의 슬래시 메뉴 필터가 한글 조합 완료 뒤 갱신되도록 수정.
|
||||
- 관리자 블록 에디터에서 ` ``` ` 입력 시 코드 블록으로 즉시 변환되도록 추가.
|
||||
- 관리자 코드 블록 글자색과 caret 색상 보정.
|
||||
- 관리자 블록 에디터에 블록 핸들 선택, Delete/Backspace 삭제, 드래그 이동 기능 추가.
|
||||
- 할 일 문서에 블록 핸들 액션 메뉴 후속 작업 추가.
|
||||
- 기술 명세 현재 버전을 v0.0.37로 갱신.
|
||||
- 패키지 버전을 0.0.37로 갱신.
|
||||
|
||||
## v0.0.36
|
||||
|
||||
- 할 일 문서에서 완료 또는 현재 기준 불필요한 QA 항목 정리.
|
||||
- 기술 명세 현재 버전을 v0.0.36으로 갱신.
|
||||
- 패키지 버전을 0.0.36으로 갱신.
|
||||
|
||||
## v0.0.35
|
||||
|
||||
- 관리자 공통 레이아웃의 기본 내부 패딩 제거.
|
||||
- 관리자 글 작성/수정 화면에서 좌측 관리자 네비게이션을 숨기도록 수정.
|
||||
- 관리자 글 작성/수정 폼을 에디터 작업 영역과 우측 설정 패널의 1차 좌우 분할 구조로 수정.
|
||||
- 게시물 설정 패널 열림/닫힘 너비 전환 애니메이션 추가.
|
||||
- 관리자 글 작성/수정 화면의 버튼, 입력, 미디어 선택 요소 hover/focus 효과 보강.
|
||||
- 기술 명세 현재 버전을 v0.0.35로 갱신.
|
||||
- 패키지 버전을 0.0.35로 갱신.
|
||||
|
||||
## v0.0.34
|
||||
|
||||
- 배포 문서에 개발/운영 DB 분리 검증 절차 추가.
|
||||
- 로컬 개발 DB 실행 안내에 Docker Desktop과 Colima 기준 병기.
|
||||
- 할 일에서 개발 DB와 운영 DB 분리 검증 절차 작성 항목 정리.
|
||||
- 기술 명세 현재 버전을 v0.0.34로 갱신.
|
||||
- 패키지 버전을 0.0.34로 갱신.
|
||||
|
||||
## v0.0.33
|
||||
|
||||
- 개발 서버 실행 링크 출력을 개발 서버, Admin, Tailwind Viewer 3개 주소로 정리.
|
||||
- 개발 서버 실행 링크가 Nuxt 출력 URL 대신 프로젝트 고정 포트 43117 기준으로 표시되도록 수정.
|
||||
- 배포 문서에 로컬 PostgreSQL DB 확인 방법 추가.
|
||||
- 할 일에서 CloudBeaver PostgreSQL 연결 방식 확정 항목 정리.
|
||||
- 기술 명세 현재 버전을 v0.0.33으로 갱신.
|
||||
- 패키지 버전을 0.0.33으로 갱신.
|
||||
|
||||
## v0.0.32
|
||||
|
||||
- 관리자 글 작성/수정 화면의 별도 제목 헤더 제거.
|
||||
- 관리자 글 작성/수정 폼을 Ghost 스타일 상단 바와 중앙 에디터 구조로 수정.
|
||||
- 대표 이미지 추가 영역을 에디터 본문 상단으로 이동.
|
||||
- 게시물 설정 패널을 420px 우측 패널과 토글 구조로 정리.
|
||||
- 관리자 글 에디터 컬럼을 좌우 32px 패딩 포함 804px로 보정해 실제 본문 폭 740px 유지.
|
||||
- 블록 에디터 첫 placeholder 문구를 간결하게 수정.
|
||||
- 패키지 버전을 0.0.32로 갱신.
|
||||
|
||||
## 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 방향 정리.
|
||||
|
||||
91
layouts/admin.vue
Normal file
91
layouts/admin.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup>
|
||||
const route = useRoute()
|
||||
|
||||
const isPostEditorRoute = computed(() => route.path === '/admin/posts/new'
|
||||
|| (route.path.startsWith('/admin/posts/') && route.path !== '/admin/posts/preview'))
|
||||
|
||||
const editorDocumentClass = 'admin-post-editor-document'
|
||||
|
||||
/**
|
||||
* 글쓰기 전체 화면 문서 스크롤 잠금 적용
|
||||
* @returns {void}
|
||||
*/
|
||||
const syncPostEditorDocumentClass = () => {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
document.documentElement.classList.toggle(editorDocumentClass, isPostEditorRoute.value)
|
||||
document.body.classList.toggle(editorDocumentClass, isPostEditorRoute.value)
|
||||
}
|
||||
|
||||
watchEffect(syncPostEditorDocumentClass)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
document.documentElement.classList.remove(editorDocumentClass)
|
||||
document.body.classList.remove(editorDocumentClass)
|
||||
})
|
||||
|
||||
/**
|
||||
* 관리자 로그아웃
|
||||
* @returns {Promise<void>} 로그아웃 처리 결과
|
||||
*/
|
||||
const logoutAdmin = async () => {
|
||||
await $fetch('/admin/api/auth/logout', {
|
||||
method: 'POST'
|
||||
})
|
||||
await navigateTo('/admin/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="admin-layout bg-[#f5f5f2] text-ink"
|
||||
:class="isPostEditorRoute ? 'h-screen overflow-hidden bg-white' : 'min-h-screen'"
|
||||
>
|
||||
<aside
|
||||
v-if="!isPostEditorRoute"
|
||||
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 transition-colors hover:bg-white/10 hover:text-white" to="/admin/posts">
|
||||
글
|
||||
</NuxtLink>
|
||||
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 transition-colors hover:bg-white/10 hover:text-white" to="/admin/pages">
|
||||
페이지
|
||||
</NuxtLink>
|
||||
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 transition-colors hover:bg-white/10 hover:text-white" to="/admin/tags">
|
||||
태그
|
||||
</NuxtLink>
|
||||
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 transition-colors hover:bg-white/10 hover:text-white" to="/admin/media">
|
||||
미디어
|
||||
</NuxtLink>
|
||||
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 transition-colors hover:bg-white/10 hover:text-white" to="/admin/navigation">
|
||||
메뉴
|
||||
</NuxtLink>
|
||||
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 transition-colors hover:bg-white/10 hover:text-white" to="/admin/settings">
|
||||
설정
|
||||
</NuxtLink>
|
||||
<button class="admin-layout__logout rounded px-3 py-2 text-left transition-colors hover:bg-white/10 hover:text-white" type="button" @click="logoutAdmin">
|
||||
로그아웃
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
<main
|
||||
class="admin-layout__main"
|
||||
:class="[
|
||||
isPostEditorRoute ? 'h-screen overflow-hidden' : 'min-h-screen',
|
||||
{ 'lg:ml-64': !isPostEditorRoute }
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
22
layouts/default.vue
Normal file
22
layouts/default.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup>
|
||||
const { menuOpen } = useMenuState()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="site-shell public-layout">
|
||||
<SiteHeader class="shrink-0" />
|
||||
<div
|
||||
class="public-layout__grid mx-auto grid w-full max-w-[1294px] flex-1 grid-cols-1 bg-[var(--site-bg)] px-4 transition-[grid-template-columns,max-width] duration-300 ease-out lg:grid lg:items-start lg:px-0 lg:[grid-template-columns:287px_minmax(0,720px)_287px]"
|
||||
:class="menuOpen ? '' : 'max-w-[1007px] lg:[grid-template-columns:0_minmax(0,720px)_287px]'"
|
||||
>
|
||||
<LeftSidebar :menu-open="menuOpen" />
|
||||
<main
|
||||
class="site-main w-full overflow-x-hidden 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>
|
||||
22
layouts/post.vue
Normal file
22
layouts/post.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup>
|
||||
const { menuOpen } = useMenuState()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="site-shell post-layout">
|
||||
<SiteHeader class="shrink-0" />
|
||||
<div
|
||||
class="post-layout__grid mx-auto grid w-full max-w-[1294px] flex-1 grid-cols-1 bg-[var(--site-bg)] px-4 transition-[grid-template-columns,max-width] duration-300 ease-out lg:grid lg:items-start lg:px-0 lg:[grid-template-columns:287px_minmax(0,720px)_287px]"
|
||||
:class="menuOpen ? '' : 'max-w-[1007px] lg:[grid-template-columns:0_minmax(0,720px)_287px]'"
|
||||
>
|
||||
<LeftSidebar :menu-open="menuOpen" />
|
||||
<main
|
||||
class="site-main w-full overflow-x-hidden 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')
|
||||
}
|
||||
})
|
||||
53
nuxt.config.js
Normal file
53
nuxt.config.js
Normal file
@@ -0,0 +1,53 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2026-04-29',
|
||||
future: {
|
||||
compatibilityVersion: 3
|
||||
},
|
||||
devtools: {
|
||||
enabled: false
|
||||
},
|
||||
modules: ['@nuxtjs/tailwindcss'],
|
||||
components: [
|
||||
{
|
||||
path: '~/components',
|
||||
pathPrefix: false
|
||||
}
|
||||
],
|
||||
experimental: {
|
||||
appManifest: false
|
||||
},
|
||||
vite: {
|
||||
server: {
|
||||
watch: {
|
||||
usePolling: true,
|
||||
interval: 150
|
||||
}
|
||||
}
|
||||
},
|
||||
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.50",
|
||||
"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>
|
||||
172
pages/admin/posts/[id].vue
Normal file
172
pages/admin/posts/[id].vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<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())
|
||||
|
||||
const publicPostUrl = computed(() => post.value?.slug ? `/post/${post.value.slug}` : '')
|
||||
|
||||
/**
|
||||
* 저장 상태 토스트 표시
|
||||
* @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 relative bg-white">
|
||||
<p v-if="errorMessage" class="admin-post-edit__error mx-6 mt-6 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"
|
||||
:can-view-post="isPublicPost(post)"
|
||||
:public-url="publicPostUrl"
|
||||
:deleting="deleting"
|
||||
show-delete
|
||||
@submit="savePost"
|
||||
@preview="previewPost"
|
||||
@delete="deletePost"
|
||||
/>
|
||||
<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>
|
||||
93
pages/admin/posts/new.vue
Normal file
93
pages/admin/posts/new.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<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-white">
|
||||
<p v-if="errorMessage" class="admin-post-editor__error mx-6 mt-6 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>
|
||||
430
pages/index.vue
Normal file
430
pages/index.vue
Normal file
@@ -0,0 +1,430 @@
|
||||
<script setup>
|
||||
const { data: posts } = await useFetch('/api/posts', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
const { data: tags } = await useFetch('/api/tags', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
const postFeedStyleStorageKey = 'POST_FEED_STYLE'
|
||||
|
||||
const postFeedStyleOpen = ref(false)
|
||||
const postFeedStyle = ref('compact')
|
||||
|
||||
/**
|
||||
* Latest 피드 보기 방식을 저장한다.
|
||||
* @param {'list' | 'compact' | 'cards' | 'articles'} value - 보기 방식
|
||||
* @returns {void}
|
||||
*/
|
||||
const setPostFeedStyle = (value) => {
|
||||
postFeedStyle.value = value
|
||||
|
||||
if (import.meta.client) {
|
||||
localStorage.setItem(postFeedStyleStorageKey, value)
|
||||
}
|
||||
}
|
||||
|
||||
const closePostFeedStyleMenu = () => {
|
||||
postFeedStyleOpen.value = false
|
||||
}
|
||||
|
||||
const onDocumentPointerDown = (event) => {
|
||||
if (!postFeedStyleOpen.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const target = /** @type {HTMLElement | null} */ (event.target instanceof HTMLElement ? event.target : null)
|
||||
if (!target) {
|
||||
closePostFeedStyleMenu()
|
||||
return
|
||||
}
|
||||
|
||||
if (target.closest('[data-feed-style-root]')) {
|
||||
return
|
||||
}
|
||||
|
||||
closePostFeedStyleMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 표시 형식 변환
|
||||
* @param {string | null} value - ISO 날짜 문자열
|
||||
* @returns {string} 화면 표시 날짜
|
||||
*/
|
||||
const formatPostDate = (value) => {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
/**
|
||||
* 태그 슬러그로 태그 정보 조회
|
||||
* @param {string | undefined} slug - 태그 슬러그
|
||||
* @returns {{name: string, color: string}} 태그 정보
|
||||
*/
|
||||
const getTagMeta = (slug) => {
|
||||
const matchedTag = tags.value.find((item) => item.slug === slug)
|
||||
|
||||
return {
|
||||
name: matchedTag?.name || (slug ? slug.toUpperCase() : 'POST'),
|
||||
color: matchedTag?.color || '#4d4d4d'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest 목록 데이터 변환
|
||||
* @param {Object} post - API 게시물
|
||||
* @param {number} index - 목록 인덱스
|
||||
* @returns {Object} 화면 표시 데이터
|
||||
*/
|
||||
const mapLatestPost = (post, index) => {
|
||||
const primaryTagSlug = post.tags?.[0]
|
||||
const tagMeta = getTagMeta(primaryTagSlug)
|
||||
|
||||
return {
|
||||
title: post.title,
|
||||
excerpt: post.excerpt,
|
||||
featuredImage: post.featuredImage,
|
||||
tagName: tagMeta.name,
|
||||
tagColor: tagMeta.color,
|
||||
publishedAt: formatPostDate(post.publishedAt),
|
||||
to: `/post/${post.slug}`,
|
||||
isFeatured: index === 0
|
||||
}
|
||||
}
|
||||
|
||||
const featuredPosts = computed(() => posts.value.slice(0, 6))
|
||||
const latestPosts = computed(() => posts.value.map(mapLatestPost))
|
||||
|
||||
const featuredTrackRef = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
const storedStyle = localStorage.getItem(postFeedStyleStorageKey)
|
||||
if (storedStyle === 'list' || storedStyle === 'compact' || storedStyle === 'cards' || storedStyle === 'articles') {
|
||||
postFeedStyle.value = storedStyle
|
||||
}
|
||||
|
||||
document.addEventListener('pointerdown', onDocumentPointerDown)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown)
|
||||
})
|
||||
|
||||
/**
|
||||
* Featured 가로 트랙을 좌우로 이동한다.
|
||||
* @param {'left' | 'right'} direction - 이동 방향
|
||||
* @returns {void}
|
||||
*/
|
||||
const scrollFeatured = (direction) => {
|
||||
if (!featuredTrackRef.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const firstCard = featuredTrackRef.value.querySelector('[data-featured-slide]')
|
||||
const cardWidth = firstCard ? firstCard.getBoundingClientRect().width : 244
|
||||
const gap = 24
|
||||
const offset = direction === 'left' ? -(cardWidth + gap) : cardWidth + gap
|
||||
|
||||
featuredTrackRef.value.scrollBy({
|
||||
left: offset,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainColumn>
|
||||
<section class="px-5 py-6 sm:px-6 md:py-8">
|
||||
<div class="mx-auto flex max-w-[720px] flex-col-reverse gap-6">
|
||||
<div class="z-[2] flex flex-col items-center justify-center gap-2 text-center">
|
||||
<h1 class="text-xl font-semibold leading-[1.125] md:text-2xl">
|
||||
Ideas <em>published</em> for meaningful conversation, <em>discussed</em> and shaped by the community
|
||||
</h1>
|
||||
<p class="max-w-md text-base leading-snug site-muted">
|
||||
A modern Ghost theme for curated, community-driven publishing, where members join the conversation.
|
||||
</p>
|
||||
<form class="group relative mt-1 flex w-full max-w-xs flex-col items-start">
|
||||
<fieldset class="flex w-full flex-wrap gap-2 text-sm">
|
||||
<legend class="sr-only">Personal information</legend>
|
||||
<input class="site-input flex-[2] rounded-[10px] px-3 py-1.5 text-sm" type="email" placeholder="Your email" aria-label="Your email">
|
||||
<button class="site-button flex-1 cursor-pointer rounded-[10px] border border-[var(--site-invert)] bg-gradient-to-b from-[rgba(17,17,17,0.75)] to-[rgba(17,17,17,0.95)] px-3 py-1.5 font-medium text-[var(--site-invert-text)] hover:opacity-90" type="button">
|
||||
Subscribe
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="px-5 py-4 sm:px-6">
|
||||
<div class="mx-auto max-w-[720px]">
|
||||
<div class="flex items-end justify-between gap-2 border-b border-[var(--site-line)] pb-2">
|
||||
<h2 class="text-sm font-medium uppercase site-muted">Featured</h2>
|
||||
<div class="flex justify-between gap-2">
|
||||
<button class="cursor-pointer p-1 hover:opacity-75" type="button" aria-label="Previous" @click="scrollFeatured('left')">‹</button>
|
||||
<button class="cursor-pointer p-1 hover:opacity-75" type="button" aria-label="Next" @click="scrollFeatured('right')">›</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="featuredTrackRef"
|
||||
class="mt-4 flex snap-x snap-mandatory gap-6 overflow-x-auto scroll-smooth pb-1 [--slides:1.4] sm:[--slides:1.6] lg:[--slides:2.6] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
<NuxtLink
|
||||
v-for="post in featuredPosts"
|
||||
:key="`featured-${post.slug}`"
|
||||
:to="`/post/${post.slug}`"
|
||||
class="group relative block aspect-video w-[calc((100%-(24px*(var(--slides)-1)))/var(--slides))] shrink-0 snap-start overflow-hidden rounded-[10px]"
|
||||
data-featured-slide
|
||||
>
|
||||
<img
|
||||
v-if="post.featuredImage"
|
||||
:src="post.featuredImage"
|
||||
:alt="post.title"
|
||||
class="h-full w-full object-cover brightness-75 contrast-125 transition-all duration-200 group-hover:brightness-90 group-hover:contrast-110"
|
||||
loading="lazy"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="h-full w-full bg-[linear-gradient(135deg,#071b22,#5f6f85)]"
|
||||
/>
|
||||
<h3 class="absolute right-0 bottom-2.5 left-0 px-3 text-sm font-medium leading-tight text-white line-clamp-2">
|
||||
{{ post.title }}
|
||||
</h3>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="px-5 py-4 sm:px-6">
|
||||
<div class="mx-auto max-w-[720px]">
|
||||
<div class="flex items-end justify-between gap-2 border-b border-[var(--site-line)] pb-2">
|
||||
<h2 class="text-sm font-medium uppercase site-muted">Latest</h2>
|
||||
<div class="relative flex flex-col gap-1 text-sm font-medium" data-feed-style-root>
|
||||
<button
|
||||
class="site-input relative flex cursor-pointer items-center justify-center gap-1 rounded-[10px] border px-2 py-1.5 pr-1.5 leading-none hover:bg-[var(--site-panel)]"
|
||||
type="button"
|
||||
aria-label="피드 보기 방식 선택"
|
||||
data-feed-style-toggle
|
||||
@click="postFeedStyleOpen = !postFeedStyleOpen"
|
||||
>
|
||||
<span class="pointer-events-none" v-show="postFeedStyle === 'list'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 6h11" />
|
||||
<path d="M9 12h11" />
|
||||
<path d="M9 18h11" />
|
||||
<path d="M5 6v.01" />
|
||||
<path d="M5 12v.01" />
|
||||
<path d="M5 18v.01" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="pointer-events-none" v-show="postFeedStyle === 'compact'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13 5h8" />
|
||||
<path d="M13 9h5" />
|
||||
<path d="M13 15h8" />
|
||||
<path d="M13 19h5" />
|
||||
<path d="M3 5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V5" />
|
||||
<path d="M3 15a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-4" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="pointer-events-none" v-show="postFeedStyle === 'cards'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6" />
|
||||
<path d="M4 16a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="pointer-events-none" v-show="postFeedStyle === 'articles'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3.06 13a9 9 0 1 0 .49-4.087" />
|
||||
<path d="M3 4.001v5h5" />
|
||||
<path d="M11 12a1 1 0 1 0 2 0a1 1 0 1 0-2 0" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="pointer-events-none opacity-75">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<menu
|
||||
class="absolute top-9 right-0 z-10 flex w-44 flex-col gap-0.5 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-1.5 shadow transition-[transform,opacity,visibility,scale] duration-200"
|
||||
:class="postFeedStyleOpen ? 'visible translate-y-0 scale-100 opacity-100' : 'invisible -translate-y-3 scale-95 opacity-0'"
|
||||
>
|
||||
<li class="w-full">
|
||||
<button class="flex w-full cursor-pointer items-center gap-1.5 rounded-[10px] px-2 py-1 hover:bg-[var(--site-panel)]" type="button" @click="setPostFeedStyle('list'); closePostFeedStyleMenu()">
|
||||
<span class="pointer-events-none">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 6h11" />
|
||||
<path d="M9 12h11" />
|
||||
<path d="M9 18h11" />
|
||||
<path d="M5 6v.01" />
|
||||
<path d="M5 12v.01" />
|
||||
<path d="M5 18v.01" />
|
||||
</svg>
|
||||
</span>
|
||||
<span>List</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="w-full">
|
||||
<button class="flex w-full cursor-pointer items-center gap-1.5 rounded-[10px] px-2 py-1 hover:bg-[var(--site-panel)]" type="button" @click="setPostFeedStyle('compact'); closePostFeedStyleMenu()">
|
||||
<span class="pointer-events-none">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13 5h8" />
|
||||
<path d="M13 9h5" />
|
||||
<path d="M13 15h8" />
|
||||
<path d="M13 19h5" />
|
||||
<path d="M3 5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V5" />
|
||||
<path d="M3 15a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-4" />
|
||||
</svg>
|
||||
</span>
|
||||
<span>Compact</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="w-full">
|
||||
<button class="flex w-full cursor-pointer items-center gap-1.5 rounded-[10px] px-2 py-1 hover:bg-[var(--site-panel)]" type="button" @click="setPostFeedStyle('cards'); closePostFeedStyleMenu()">
|
||||
<span class="pointer-events-none">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6" />
|
||||
<path d="M4 16a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2" />
|
||||
</svg>
|
||||
</span>
|
||||
<span>Cards</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="w-full">
|
||||
<button class="flex w-full cursor-pointer items-center gap-1.5 rounded-[10px] px-2 py-1 hover:bg-[var(--site-panel)]" type="button" @click="setPostFeedStyle('articles'); closePostFeedStyleMenu()">
|
||||
<span class="pointer-events-none">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3.06 13a9 9 0 1 0 .49-4.087" />
|
||||
<path d="M3 4.001v5h5" />
|
||||
<path d="M11 12a1 1 0 1 0 2 0a1 1 0 1 0-2 0" />
|
||||
</svg>
|
||||
</span>
|
||||
<span>Default</span>
|
||||
</button>
|
||||
</li>
|
||||
</menu>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-8 flex flex-col">
|
||||
<div
|
||||
class="flex flex-col divide-y divide-[var(--site-line)]"
|
||||
:class="postFeedStyle === 'cards' ? 'divide-y-0 gap-4 sm:grid sm:grid-cols-2 sm:gap-4' : ''"
|
||||
>
|
||||
<article
|
||||
v-for="post in latestPosts"
|
||||
:key="post.to"
|
||||
class="group relative overflow-hidden"
|
||||
:class="postFeedStyle === 'cards' ? 'rounded-[10px] border border-[var(--site-line)] p-3' : 'flex flex-row gap-3 py-4'"
|
||||
>
|
||||
<NuxtLink
|
||||
:to="post.to"
|
||||
class="relative flex-1"
|
||||
:class="postFeedStyle === 'cards' ? 'mb-3 block aspect-video w-full' : 'aspect-square min-w-16 sm:aspect-video'"
|
||||
>
|
||||
<figure class="overflow-hidden rounded-[10px]">
|
||||
<img
|
||||
v-if="post.featuredImage"
|
||||
:src="post.featuredImage"
|
||||
:alt="post.title"
|
||||
class="w-full rounded-[inherit] object-cover transition-opacity duration-200 group-hover:opacity-90"
|
||||
:class="postFeedStyle === 'cards' ? 'aspect-video' : 'aspect-square sm:aspect-video'"
|
||||
loading="lazy"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="w-full rounded-[inherit] bg-[linear-gradient(135deg,#253444,#8f9dad)]"
|
||||
:class="postFeedStyle === 'cards' ? 'aspect-video' : 'aspect-square sm:aspect-video'"
|
||||
/>
|
||||
</figure>
|
||||
</NuxtLink>
|
||||
|
||||
<div
|
||||
class="relative"
|
||||
:class="postFeedStyle === 'cards' ? '' : 'flex-[3] md:flex-[4]'"
|
||||
>
|
||||
<div
|
||||
class="flex h-full flex-col gap-1.5"
|
||||
:class="postFeedStyle === 'cards' ? '' : ''"
|
||||
>
|
||||
<h2 class="max-w-[90%] text-sm font-medium leading-tight">
|
||||
<NuxtLink :to="post.to" class="transition-opacity duration-200 hover:opacity-75">
|
||||
<span v-if="post.isFeatured" class="mr-1 inline-flex text-[var(--site-accent)] [&_svg]:-mt-0.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13 3v7h6l-8 11v-7H5l8-11" />
|
||||
</svg>
|
||||
</span>
|
||||
{{ post.title }}
|
||||
</NuxtLink>
|
||||
</h2>
|
||||
|
||||
<p
|
||||
class="flex-1 text-[0.8rem] leading-tight site-muted"
|
||||
:class="postFeedStyle === 'list' ? 'line-clamp-3' : postFeedStyle === 'articles' ? 'line-clamp-4' : 'line-clamp-2'"
|
||||
>
|
||||
{{ post.excerpt }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs site-muted sm:gap-1.5">
|
||||
<time>{{ post.publishedAt }}</time>
|
||||
<span class="text-[var(--site-line)]">/</span>
|
||||
<span
|
||||
class="rounded-sm px-1.5 py-px font-medium text-[var(--site-text)]"
|
||||
:style="{ backgroundColor: `${post.tagColor}1a` }"
|
||||
>
|
||||
{{ post.tagName }}
|
||||
</span>
|
||||
<span class="text-[var(--site-line)]">/</span>
|
||||
<span class="flex items-center gap-0.75">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="-mt-px">
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
|
||||
</svg>
|
||||
<span>0</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="absolute top-0 right-0 flex cursor-pointer items-center gap-1 hover:opacity-75"
|
||||
:class="postFeedStyle === 'cards' ? '' : 'md:top-auto md:right-0 md:bottom-0 md:invisible md:opacity-0 md:transition-[opacity,visibility] md:duration-200 md:group-hover:visible md:group-hover:opacity-100'"
|
||||
type="button"
|
||||
aria-label="Share this post"
|
||||
>
|
||||
<svg
|
||||
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"
|
||||
class="h-4 w-4"
|
||||
>
|
||||
<path d="M17 7 7 17" />
|
||||
<path d="M8 7h9v9" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</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>
|
||||
246
pages/post/[slug].vue
Normal file
246
pages/post/[slug].vue
Normal file
@@ -0,0 +1,246 @@
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'post'
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const slug = computed(() => String(route.params.slug || ''))
|
||||
|
||||
const { data: post } = await useFetch(() => `/api/posts/${slug.value}`)
|
||||
const { data: tags } = await useFetch('/api/tags', {
|
||||
default: () => []
|
||||
})
|
||||
const { data: posts } = await useFetch('/api/posts', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
if (!post.value) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: '게시물을 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 날짜 표시 형식 변환 (Thred 참고)
|
||||
* @param {string | null} value - ISO 날짜 문자열
|
||||
* @returns {string} 화면 표시 날짜
|
||||
*/
|
||||
const formatPostDate = (value) => {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
const primaryTagSlug = computed(() => post.value.tags?.[0] || '')
|
||||
const primaryTagMeta = computed(() => {
|
||||
const matchedTag = tags.value.find((item) => item.slug === primaryTagSlug.value)
|
||||
|
||||
return {
|
||||
name: matchedTag?.name || (primaryTagSlug.value ? primaryTagSlug.value.toUpperCase() : 'POST'),
|
||||
color: matchedTag?.color || '#4d4d4d',
|
||||
to: primaryTagSlug.value ? `/tag/${primaryTagSlug.value}` : '/tags'
|
||||
}
|
||||
})
|
||||
|
||||
const publishedAtLabel = computed(() => formatPostDate(post.value.publishedAt || null))
|
||||
const authorLabel = computed(() => 'sori.studio')
|
||||
|
||||
const currentIndex = computed(() => posts.value.findIndex((item) => item.slug === post.value.slug))
|
||||
const previousPost = computed(() => {
|
||||
if (currentIndex.value <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const candidate = posts.value[currentIndex.value - 1]
|
||||
return candidate ? { title: candidate.title, to: `/post/${candidate.slug}` } : null
|
||||
})
|
||||
const nextPost = computed(() => {
|
||||
if (currentIndex.value === -1 || currentIndex.value >= posts.value.length - 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const candidate = posts.value[currentIndex.value + 1]
|
||||
return candidate ? { title: candidate.title, to: `/post/${candidate.slug}` } : null
|
||||
})
|
||||
|
||||
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 ogImage = computed(() => 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: pageUrl.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>
|
||||
<div class="post-detail">
|
||||
<section class="px-4 mt-6 mb-8 sm:px-5">
|
||||
<div class="mx-auto flex max-w-[720px] flex-col gap-2.5">
|
||||
<h1 class="text-xl font-semibold leading-[1.125] sm:text-2xl">
|
||||
{{ post.title }}
|
||||
</h1>
|
||||
|
||||
<div class="relative border-b border-[var(--site-line)] pb-4">
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs site-muted sm:gap-1.5">
|
||||
<time v-if="publishedAtLabel" :datetime="post.publishedAt">
|
||||
{{ publishedAtLabel }}
|
||||
</time>
|
||||
|
||||
<a href="#" class="hover:opacity-75">
|
||||
{{ authorLabel }}
|
||||
</a>
|
||||
|
||||
<ul class="flex flex-wrap items-center font-medium">
|
||||
<li v-if="primaryTagMeta.name" :style="{ '--color-accent': primaryTagMeta.color }">
|
||||
<NuxtLink
|
||||
class="rounded-sm px-1.5 py-px text-[var(--site-text)] hover:opacity-75"
|
||||
:style="{ backgroundColor: `${primaryTagMeta.color}1a` }"
|
||||
:to="primaryTagMeta.to"
|
||||
>
|
||||
{{ primaryTagMeta.name }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<a class="flex items-center gap-0.75 hover:opacity-75" :href="`${pageUrl}#comments`">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="-mt-px">
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
|
||||
</svg>
|
||||
<span class="pointer-events-none">0</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button class="absolute right-0 bottom-4 flex cursor-pointer items-center gap-1 hover:opacity-75" type="button" aria-label="Share this post">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13 4v4c-6.575 1.028-9.02 6.788-10 12c-.037.206 5.384-5.962 10-6v4l8-7-8-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<figure v-if="post.featuredImage" class="relative mt-2.5 w-full">
|
||||
<img
|
||||
class="aspect-video w-full rounded-[10px] object-cover bg-[var(--site-panel-strong)]"
|
||||
:src="post.featuredImage"
|
||||
:alt="post.title"
|
||||
loading="eager"
|
||||
>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="px-4 sm:px-5">
|
||||
<div class="mx-auto max-w-[720px]">
|
||||
<ContentRenderer>
|
||||
<ContentMarkdownRenderer class="post-detail__content" :content="post.content" />
|
||||
</ContentRenderer>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="comments" class="mb-6 border-y border-[var(--site-line)] bg-[var(--site-panel-strong)] px-5 py-5 scroll-mt-14 sm:px-6">
|
||||
<div class="mx-auto max-w-[720px] text-sm">
|
||||
<p class="font-medium">Comments</p>
|
||||
<p class="mt-2 site-muted">
|
||||
댓글 UI는 추후 연결 예정입니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-6 px-5 sm:px-6" aria-label="Previous and next post">
|
||||
<div class="mx-auto max-w-[720px]">
|
||||
<div class="grid gap-4 text-sm font-medium leading-tight md:grid-cols-2">
|
||||
<NuxtLink v-if="previousPost" :to="previousPost.to" class="flex flex-col items-start gap-1 hover:opacity-75">
|
||||
<span class="flex items-center gap-1 text-[0.7rem] font-medium uppercase opacity-75 site-muted">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 6 9 12l6 6" />
|
||||
</svg>
|
||||
Previous post
|
||||
</span>
|
||||
<h3 class="ml-4 text-left">
|
||||
{{ previousPost.title }}
|
||||
</h3>
|
||||
</NuxtLink>
|
||||
<div v-else />
|
||||
|
||||
<NuxtLink v-if="nextPost" :to="nextPost.to" class="flex flex-col items-end gap-1 hover:opacity-75">
|
||||
<span class="flex items-center gap-1 text-[0.7rem] font-medium uppercase opacity-75 site-muted">
|
||||
Next post
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m9 6 6 6-6 6" />
|
||||
</svg>
|
||||
</span>
|
||||
<h3 class="mr-4 text-right">
|
||||
{{ nextPost.title }}
|
||||
</h3>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</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>
|
||||
153
pages/tag/[slug].vue
Normal file
153
pages/tag/[slug].vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<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, index) => ({
|
||||
title: post.title,
|
||||
excerpt: post.excerpt,
|
||||
featuredImage: post.featuredImage,
|
||||
tag: tags.value.find((item) => item.slug === (post.tags?.[0] || slug.value))?.name || (post.tags?.[0] || slug.value).toUpperCase(),
|
||||
tagColor: tags.value.find((item) => item.slug === (post.tags?.[0] || slug.value))?.color || '#4d4d4d',
|
||||
isFeatured: index === 0,
|
||||
publishedAt: formatPostDate(post.publishedAt),
|
||||
to: `/post/${post.slug}`
|
||||
})))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainColumn>
|
||||
<section class="px-5 pt-4 sm:px-6 sm:pt-5">
|
||||
<div class="mx-auto flex max-w-[720px] flex-col gap-4 border-b border-[var(--site-line)] pb-4 sm:pb-5">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h1 class="text-lg font-medium leading-tight sm:text-xl">
|
||||
{{ tag?.name || slug.toUpperCase() }}
|
||||
</h1>
|
||||
<p class="max-w-lg text-sm leading-snug site-muted">
|
||||
{{ tag?.description || '이 주제와 관련된 게시물을 모아볼 수 있습니다.' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="px-5 sm:px-6">
|
||||
<div class="mx-auto flex max-w-[720px] flex-col gap-8">
|
||||
<div class="flex flex-col divide-y divide-[var(--site-line)]" data-post-feed="latest">
|
||||
<article
|
||||
v-for="post in tagPosts"
|
||||
:key="post.to"
|
||||
class="group relative flex flex-row gap-3 py-4 text-[var(--site-text)]"
|
||||
>
|
||||
<NuxtLink
|
||||
:to="post.to"
|
||||
class="relative aspect-square min-w-16 flex-1 sm:aspect-video"
|
||||
>
|
||||
<figure class="overflow-hidden rounded-[10px]">
|
||||
<img
|
||||
v-if="post.featuredImage"
|
||||
:src="post.featuredImage"
|
||||
:alt="post.title"
|
||||
class="aspect-square w-full rounded-[inherit] object-cover transition-opacity duration-200 group-hover:opacity-90 sm:aspect-video"
|
||||
loading="lazy"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="aspect-square w-full rounded-[inherit] bg-[linear-gradient(135deg,#253444,#8f9dad)] sm:aspect-video"
|
||||
/>
|
||||
</figure>
|
||||
</NuxtLink>
|
||||
|
||||
<div class="relative flex-[3] md:flex-[4]">
|
||||
<div class="flex h-full flex-col gap-1.5">
|
||||
<h2 class="max-w-[90%] text-sm font-medium leading-tight">
|
||||
<NuxtLink :to="post.to" class="transition-opacity duration-200 hover:opacity-75">
|
||||
<span v-if="post.isFeatured" class="mr-1 inline-flex text-[var(--site-accent)] [&_svg]:-mt-0.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13 3v7h6l-8 11v-7H5l8-11" />
|
||||
</svg>
|
||||
</span>
|
||||
{{ post.title }}
|
||||
</NuxtLink>
|
||||
</h2>
|
||||
<p class="flex-1 line-clamp-2 text-[0.8rem] leading-tight site-muted">
|
||||
{{ post.excerpt }}
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs site-muted sm:gap-1.5">
|
||||
<time>{{ post.publishedAt }}</time>
|
||||
<span class="text-[var(--site-line)]">/</span>
|
||||
<span
|
||||
class="rounded-sm px-1.5 py-px font-medium text-[var(--site-text)]"
|
||||
:style="{ backgroundColor: `${post.tagColor}1a` }"
|
||||
>
|
||||
{{ post.tag }}
|
||||
</span>
|
||||
<span class="text-[var(--site-line)]">/</span>
|
||||
<span class="flex items-center gap-0.75">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="-mt-px">
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
|
||||
</svg>
|
||||
<span>0</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="absolute top-0 right-0 flex cursor-pointer items-center gap-1 hover:opacity-75 md:top-auto md:right-0 md:bottom-0 md:invisible md:opacity-0 md:transition-[opacity,visibility] md:duration-200 md:group-hover:visible md:group-hover:opacity-100"
|
||||
type="button"
|
||||
aria-label="Share this post"
|
||||
>
|
||||
<svg
|
||||
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"
|
||||
class="h-4 w-4"
|
||||
>
|
||||
<path d="M17 7 7 17" />
|
||||
<path d="M8 7h9v9" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="tagPosts.length === 0" class="px-5 py-4 sm:px-6">
|
||||
<div class="mx-auto max-w-[720px] text-sm site-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>
|
||||
77
pages/tags/index.vue
Normal file
77
pages/tags/index.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<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-page-hero px-5 sm:px-6">
|
||||
<div class="mx-auto flex max-w-[720px] flex-col gap-6 py-6 text-center md:py-8">
|
||||
<div class="mx-auto flex max-w-xl flex-1 flex-col items-center justify-center gap-2">
|
||||
<h1 class="text-xl font-semibold leading-[1.125] md:text-2xl lg:text-3xl">
|
||||
Tags
|
||||
</h1>
|
||||
<p class="max-w-md text-base leading-snug site-muted">
|
||||
Browse by topic
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tags-page-list mb-8 px-5 sm:px-6">
|
||||
<ul class="mx-auto grid max-w-[720px] gap-4 sm:gap-5 lg:grid-cols-3">
|
||||
<li
|
||||
v-for="tag in tags"
|
||||
:key="tag.id"
|
||||
class="h-full"
|
||||
:data-tag-card="tag.slug"
|
||||
>
|
||||
<NuxtLink
|
||||
class="group relative flex h-full flex-col gap-2 rounded-[12px] rounded-l-none border border-[var(--site-line)] border-l-[3px] p-4 transition-colors duration-200 hover:border-l-[color:var(--tag-color)]"
|
||||
:style="{ '--tag-color': tag.color, borderLeftColor: tag.color }"
|
||||
:to="`/tag/${tag.slug}`"
|
||||
>
|
||||
<h2 class="flex items-center justify-between gap-1 pr-5 text-sm font-medium leading-tight">
|
||||
{{ tag.name }}
|
||||
</h2>
|
||||
<span class="absolute top-4 right-4 text-sm transition-transform duration-200 group-hover:translate-x-[1px] group-hover:-translate-y-[1px]">
|
||||
<svg
|
||||
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"
|
||||
class="h-4 w-4"
|
||||
>
|
||||
<path d="M17 7 7 17" />
|
||||
<path d="M8 7h9v9" />
|
||||
</svg>
|
||||
</span>
|
||||
<p class="flex-1 text-[0.8rem] leading-tight site-muted line-clamp-3">
|
||||
{{ tag.description || `${tag.name} 관련 주제를 확인하세요.` }}
|
||||
</p>
|
||||
<span class="text-[0.8rem] font-medium leading-tight site-muted">
|
||||
{{ getPostCount(tag.slug) }} posts
|
||||
</span>
|
||||
<span class="pointer-events-none absolute inset-0 rounded-[12px] rounded-l-none border border-transparent transition-colors duration-200 group-hover:border-[color:var(--tag-color)] group-hover:opacity-25" />
|
||||
<span class="pointer-events-none absolute inset-0 rounded-[12px] rounded-l-none opacity-0 transition-opacity duration-200 group-hover:opacity-[0.06]" :style="{ backgroundColor: tag.color }" />
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</MainColumn>
|
||||
</template>
|
||||
88
scripts/dev-server.js
Normal file
88
scripts/dev-server.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
const host = '127.0.0.1'
|
||||
const port = '43117'
|
||||
let printedLinks = false
|
||||
let collectedLines = []
|
||||
const maxCollectedLines = 400
|
||||
|
||||
const nuxtProcess = spawn('nuxt', [
|
||||
'dev',
|
||||
'--dotenv',
|
||||
'.env.development',
|
||||
'--host',
|
||||
host,
|
||||
'--port',
|
||||
port
|
||||
], {
|
||||
shell: true,
|
||||
stdio: ['inherit', 'pipe', 'pipe']
|
||||
})
|
||||
|
||||
/**
|
||||
* 개발 서버 링크 출력
|
||||
* @returns {void}
|
||||
*/
|
||||
const printDevLinks = () => {
|
||||
const origin = `http://${host}:${port}`
|
||||
console.log('')
|
||||
console.log('sori.studio 개발 서버')
|
||||
console.log(`- 개발 서버: ${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')) {
|
||||
if (line) {
|
||||
collectedLines.push(line)
|
||||
if (collectedLines.length > maxCollectedLines) {
|
||||
collectedLines = collectedLines.slice(-maxCollectedLines)
|
||||
}
|
||||
}
|
||||
|
||||
const localMatch = line.match(/Local:\s+(https?:\/\/[^\s]+)/)
|
||||
|
||||
if (localMatch && !printedLinks) {
|
||||
printedLinks = true
|
||||
printDevLinks()
|
||||
continue
|
||||
}
|
||||
|
||||
if (/(^|\s)(error:|warn:|warning:|failed|fatal|eaddrinuse|cannot|emfile|epipe|eacces|eperm)\b/i.test(line) || /\bat\s.+\(.+\)/.test(line) || /Object\.\$resolve/.test(line)) {
|
||||
console.log(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nuxtProcess.stdout.on('data', handleNuxtLog)
|
||||
nuxtProcess.stderr.on('data', handleNuxtLog)
|
||||
|
||||
nuxtProcess.on('close', (code) => {
|
||||
if (code && collectedLines.length) {
|
||||
console.log('')
|
||||
console.log('--- Nuxt 종료 로그 (최근) ---')
|
||||
for (const line of collectedLines) {
|
||||
console.log(line)
|
||||
}
|
||||
console.log('--- 종료 로그 끝 ---')
|
||||
console.log('')
|
||||
}
|
||||
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()
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user