Nuxt 초기 세팅 추가
This commit is contained in:
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
|
||||||
18
.env.example
Normal file
18
.env.example
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Database
|
||||||
|
DATABASE_URL=
|
||||||
|
DATABASE_NAME=
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
ADMIN_EMAIL=
|
||||||
|
ADMIN_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
|
||||||
@@ -157,6 +157,7 @@
|
|||||||
- 기존 API 호출 패턴을 따른다.
|
- 기존 API 호출 패턴을 따른다.
|
||||||
- 응답 구조 변경 시 docs/spec.md를 반드시 갱신한다.
|
- 응답 구조 변경 시 docs/spec.md를 반드시 갱신한다.
|
||||||
- 하드코딩된 값 사용을 금지한다.
|
- 하드코딩된 값 사용을 금지한다.
|
||||||
|
- 초기 백엔드는 별도 앱으로 분리하지 않고 Nuxt `server/api`를 사용한다.
|
||||||
- 개발 환경과 운영 환경의 데이터베이스 연결 문자열을 혼용하지 않는다.
|
- 개발 환경과 운영 환경의 데이터베이스 연결 문자열을 혼용하지 않는다.
|
||||||
- 운영 DB는 로컬 개발 서버에서 직접 사용하지 않는다.
|
- 운영 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>
|
||||||
42
assets/css/main.css
Normal file
42
assets/css/main.css
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html {
|
||||||
|
font-family: Pretendard, ui-sans-serif, system-ui, sans-serif;
|
||||||
|
color: #1f2328;
|
||||||
|
background: #f7f7f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-width: 320px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.site-shell {
|
||||||
|
@apply min-h-screen bg-surface text-ink;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content-grid {
|
||||||
|
@apply mx-auto grid max-w-[1294px] grid-cols-1 px-4 lg:grid-cols-[287px_minmax(0,720px)_287px] lg:px-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-section {
|
||||||
|
@apply border-b border-line bg-paper;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 text-ink;
|
||||||
|
}
|
||||||
|
}
|
||||||
5
components/content/ContentRenderer.vue
Normal file
5
components/content/ContentRenderer.vue
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<article class="content-renderer post-prose">
|
||||||
|
<slot />
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
5
components/content/ProseAudio.vue
Normal file
5
components/content/ProseAudio.vue
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div class="prose-audio my-8 border border-line bg-surface p-5">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
5
components/content/ProseBlockquote.vue
Normal file
5
components/content/ProseBlockquote.vue
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<blockquote class="prose-blockquote my-8 border-l-4 border-ink bg-surface px-5 py-4 text-xl font-medium leading-8">
|
||||||
|
<slot />
|
||||||
|
</blockquote>
|
||||||
|
</template>
|
||||||
20
components/content/ProseButton.vue
Normal file
20
components/content/ProseButton.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
href: {
|
||||||
|
type: String,
|
||||||
|
default: '#'
|
||||||
|
},
|
||||||
|
align: {
|
||||||
|
type: String,
|
||||||
|
default: 'left'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<p class="prose-button my-8" :class="{ 'text-center': align === 'center' }">
|
||||||
|
<NuxtLink class="prose-button__link inline-flex rounded bg-ink px-5 py-3 text-sm font-semibold text-white hover:bg-muted" :to="href">
|
||||||
|
<slot />
|
||||||
|
</NuxtLink>
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
5
components/content/ProseCallout.vue
Normal file
5
components/content/ProseCallout.vue
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<aside class="prose-callout my-8 border border-line bg-surface p-5">
|
||||||
|
<slot />
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
5
components/content/ProseEmbed.vue
Normal file
5
components/content/ProseEmbed.vue
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div class="prose-embed my-8 border border-line bg-paper p-5">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
5
components/content/ProseFile.vue
Normal file
5
components/content/ProseFile.vue
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div class="prose-file my-8 border border-line bg-paper p-5">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
14
components/content/ProseHeaderCard.vue
Normal file
14
components/content/ProseHeaderCard.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
variant: {
|
||||||
|
type: String,
|
||||||
|
default: 'simple'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="prose-header-card my-8 bg-ink p-8 text-white" :class="`prose-header-card--${variant}`">
|
||||||
|
<slot />
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
27
components/content/ProseHeading.vue
Normal file
27
components/content/ProseHeading.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
level: {
|
||||||
|
type: Number,
|
||||||
|
default: 2
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const tagName = computed(() => `h${Math.min(Math.max(props.level, 1), 6)}`)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="tagName"
|
||||||
|
class="prose-heading mt-10 font-semibold leading-tight tracking-normal first:mt-0"
|
||||||
|
:class="{
|
||||||
|
'text-5xl': level === 1,
|
||||||
|
'text-4xl': level === 2,
|
||||||
|
'text-3xl': level === 3,
|
||||||
|
'text-2xl': level === 4,
|
||||||
|
'text-xl': level === 5,
|
||||||
|
'text-lg': level === 6
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
31
components/content/ProseImage.vue
Normal file
31
components/content/ProseImage.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
src: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
alt: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
type: String,
|
||||||
|
default: 'regular'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<figure
|
||||||
|
class="prose-image my-8"
|
||||||
|
:class="{
|
||||||
|
'prose-image--wide lg:-mx-10': variant === 'wide',
|
||||||
|
'prose-image--full lg:-mx-20': variant === 'full'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<img class="prose-image__media w-full bg-surface object-cover" :src="src" :alt="alt">
|
||||||
|
<figcaption v-if="$slots.default" class="prose-image__caption mt-3 text-center text-sm text-muted">
|
||||||
|
<slot />
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
</template>
|
||||||
18
components/content/ProseList.vue
Normal file
18
components/content/ProseList.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
ordered: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="ordered ? 'ol' : 'ul'"
|
||||||
|
class="prose-list my-6 space-y-2 pl-6"
|
||||||
|
:class="ordered ? 'list-decimal' : 'list-disc'"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
5
components/content/ProseProduct.vue
Normal file
5
components/content/ProseProduct.vue
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div class="prose-product my-8 border border-line bg-surface p-5">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
19
components/content/ProseToggle.vue
Normal file
19
components/content/ProseToggle.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<details class="prose-toggle my-6 border border-line bg-paper p-5">
|
||||||
|
<summary class="prose-toggle__summary cursor-pointer font-semibold">
|
||||||
|
{{ title }}
|
||||||
|
</summary>
|
||||||
|
<div class="prose-toggle__body mt-4 text-muted">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</template>
|
||||||
5
components/content/ProseVideo.vue
Normal file
5
components/content/ProseVideo.vue
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div class="prose-video my-8 aspect-video bg-ink text-white">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
23
components/site/LeftSidebar.vue
Normal file
23
components/site/LeftSidebar.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<template>
|
||||||
|
<aside class="left-sidebar hidden w-[287px] lg:block">
|
||||||
|
<div class="left-sidebar__block py-3 pl-0 pr-3">
|
||||||
|
<p class="left-sidebar__eyebrow text-xs font-semibold uppercase text-muted">
|
||||||
|
Categories
|
||||||
|
</p>
|
||||||
|
<nav class="left-sidebar__nav mt-4 grid gap-2 text-sm">
|
||||||
|
<NuxtLink class="left-sidebar__nav-link hover:text-muted" to="/tags/dev">
|
||||||
|
DEV
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink class="left-sidebar__nav-link hover:text-muted" to="/tags/note">
|
||||||
|
NOTE
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink class="left-sidebar__nav-link hover:text-muted" to="/tags/review">
|
||||||
|
REVIEW
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink class="left-sidebar__nav-link hover:text-muted" to="/tags/play">
|
||||||
|
PLAY
|
||||||
|
</NuxtLink>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
26
components/site/PostCard.vue
Normal file
26
components/site/PostCard.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
post: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="post-card site-section">
|
||||||
|
<div class="post-card__body site-section-body">
|
||||||
|
<p class="post-card__meta text-xs font-semibold uppercase text-muted">
|
||||||
|
{{ post.tag }} · {{ post.publishedAt }}
|
||||||
|
</p>
|
||||||
|
<h2 class="post-card__title mt-3 text-2xl font-semibold leading-tight">
|
||||||
|
<NuxtLink class="post-card__title-link hover:text-muted" :to="post.to">
|
||||||
|
{{ post.title }}
|
||||||
|
</NuxtLink>
|
||||||
|
</h2>
|
||||||
|
<p class="post-card__excerpt mt-3 text-sm leading-6 text-muted">
|
||||||
|
{{ post.excerpt }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
17
components/site/RightSidebar.vue
Normal file
17
components/site/RightSidebar.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<aside class="right-sidebar hidden w-[287px] lg:block">
|
||||||
|
<div class="right-sidebar__block py-5 pl-5 pr-0">
|
||||||
|
<p class="right-sidebar__eyebrow text-xs font-semibold uppercase text-muted">
|
||||||
|
Portal
|
||||||
|
</p>
|
||||||
|
<div class="right-sidebar__links mt-4 grid gap-3 text-sm">
|
||||||
|
<NuxtLink class="right-sidebar__link hover:text-muted" to="/pages/projects">
|
||||||
|
Projects
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink class="right-sidebar__link hover:text-muted" to="/pages/contact">
|
||||||
|
Contact
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
20
components/site/SiteHeader.vue
Normal file
20
components/site/SiteHeader.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<template>
|
||||||
|
<header class="site-header sticky top-0 z-20 h-[57px] border-b border-line bg-paper/95 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 text-[19px] font-semibold tracking-normal" to="/">
|
||||||
|
sori.studio
|
||||||
|
</NuxtLink>
|
||||||
|
<nav class="site-header__nav flex items-center gap-5 text-sm text-muted">
|
||||||
|
<NuxtLink class="site-header__nav-link hover:text-ink" to="/pages/about">
|
||||||
|
About
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink class="site-header__nav-link hover:text-ink" to="/pages/links">
|
||||||
|
Links
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink class="site-header__nav-link hover:text-ink" to="/admin">
|
||||||
|
Admin
|
||||||
|
</NuxtLink>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
28
components/site/TagHeader.vue
Normal file
28
components/site/TagHeader.vue
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="tag-header site-section">
|
||||||
|
<div class="tag-header__inner site-section-header">
|
||||||
|
<p class="tag-header__eyebrow text-xs font-semibold uppercase text-muted">
|
||||||
|
Tag
|
||||||
|
</p>
|
||||||
|
<h1 class="tag-header__title mt-3 text-4xl font-semibold leading-tight">
|
||||||
|
{{ title }}
|
||||||
|
</h1>
|
||||||
|
<p v-if="description" class="tag-header__description mt-3 text-sm leading-6 text-muted">
|
||||||
|
{{ description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
13
docker-compose.yml
Normal file
13
docker-compose.yml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
services:
|
||||||
|
sori-studio:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: sori-studio
|
||||||
|
env_file:
|
||||||
|
- .env.production
|
||||||
|
ports:
|
||||||
|
- "${APP_PORT:-43118}:3000"
|
||||||
|
volumes:
|
||||||
|
- ./public/uploads:/app/public/uploads
|
||||||
|
restart: unless-stopped
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
# 업데이트 요약
|
# 업데이트 요약
|
||||||
|
|
||||||
|
## v0.0.2
|
||||||
|
|
||||||
|
- Nuxt 3 기반 프로젝트 실행 구조를 추가.
|
||||||
|
- Tailwind CSS, Zod, Nuxt 서버 API 초기 골격을 추가.
|
||||||
|
- 공개 화면, 관리자 화면, 콘텐츠 컴포넌트의 기본 파일 구조를 생성.
|
||||||
|
- Docker 기반 NAS 배포 초안을 추가.
|
||||||
|
- 프로젝트 전용 개발/운영 포트 기준을 추가.
|
||||||
|
|
||||||
## v0.0.1
|
## v0.0.1
|
||||||
|
|
||||||
- sori.studio 개인 블로그/CMS 초기 방향 정리.
|
- sori.studio 개인 블로그/CMS 초기 방향 정리.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 배포 가이드
|
# 배포 가이드
|
||||||
|
|
||||||
> 현재 프로젝트는 코드 스캐폴딩 전 상태다. 아래 내용은 Nuxt 앱 생성 후 적용할 기본 배포 방향이다.
|
> 현재 프로젝트는 Nuxt 3 초기 스캐폴딩 상태다. Docker 설정은 초안이며 운영 DB 확정 후 NAS에서 검증한다.
|
||||||
|
|
||||||
## 빌드 유형
|
## 빌드 유형
|
||||||
|
|
||||||
@@ -35,14 +35,14 @@ npm install
|
|||||||
cp .env.example .env.development
|
cp .env.example .env.development
|
||||||
# .env.development 파일에 개발 DB 연결 정보 입력
|
# .env.development 파일에 개발 DB 연결 정보 입력
|
||||||
|
|
||||||
# 개발 서버 실행
|
# 개발 서버 실행 (127.0.0.1:43117)
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### 확인 주소
|
### 확인 주소
|
||||||
|
|
||||||
- 개발 서버: http://localhost:3000
|
- 개발 서버: http://127.0.0.1:43117
|
||||||
- 관리자: http://localhost:3000/admin
|
- 관리자: http://127.0.0.1:43117/admin
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ cd sori.studio
|
|||||||
|
|
||||||
# 운영 환경 변수 설정
|
# 운영 환경 변수 설정
|
||||||
cp .env.example .env.production
|
cp .env.example .env.production
|
||||||
# .env.production 파일에 운영 DB 연결 정보 입력
|
# .env.production 파일에 운영 DB 연결 정보와 APP_PORT=43118 입력
|
||||||
|
|
||||||
# Docker 빌드 및 실행
|
# Docker 빌드 및 실행
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
@@ -89,7 +89,9 @@ docker run -d -p 3000:3000 sori.studio:latest
|
|||||||
|
|
||||||
### 포트
|
### 포트
|
||||||
|
|
||||||
- HTTP: 3000
|
- 로컬 개발: 43117
|
||||||
|
- NAS Docker 외부: 43118
|
||||||
|
- 컨테이너 내부: 3000
|
||||||
- HTTPS: 3001 (SSL 설정 시)
|
- HTTPS: 3001 (SSL 설정 시)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 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
|
## 2026-04-29 v0.0.1
|
||||||
|
|
||||||
### 초기 제품 방향 결정
|
### 초기 제품 방향 결정
|
||||||
|
|||||||
24
docs/map.md
24
docs/map.md
@@ -61,3 +61,27 @@
|
|||||||
| pages/posts/[slug].vue | 블로그 글 상세 |
|
| pages/posts/[slug].vue | 블로그 글 상세 |
|
||||||
| pages/tags/[slug].vue | 태그별 글 목록 |
|
| pages/tags/[slug].vue | 태그별 글 목록 |
|
||||||
| pages/pages/[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/utils/content-schema.js | Zod 콘텐츠 스키마 |
|
||||||
|
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
|
||||||
|
|
||||||
|
## 설정/배포
|
||||||
|
|
||||||
|
| 파일 | 기능 |
|
||||||
|
|------|------|
|
||||||
|
| package.json | Nuxt 실행 스크립트와 의존성 |
|
||||||
|
| nuxt.config.js | Nuxt 앱 설정 |
|
||||||
|
| tailwind.config.js | Tailwind 테마 설정 |
|
||||||
|
| assets/css/main.css | 전역 스타일 |
|
||||||
|
| .env.example | 환경 변수 예시 |
|
||||||
|
| Dockerfile | NAS 운영 이미지 빌드 |
|
||||||
|
| docker-compose.yml | NAS 컨테이너 실행 초안 |
|
||||||
|
|||||||
29
docs/spec.md
29
docs/spec.md
@@ -6,7 +6,7 @@
|
|||||||
- **유형**: 커스텀 블로그/CMS
|
- **유형**: 커스텀 블로그/CMS
|
||||||
- **목표**: 개인 블로그 중심 운영, 기존 포털성 링크와 서비스 진입점은 블로그 내부 구조에 통합
|
- **목표**: 개인 블로그 중심 운영, 기존 포털성 링크와 서비스 진입점은 블로그 내부 구조에 통합
|
||||||
- **참조**: Ghost(관리자 UX/글쓰기), Thred 테마(사용자 화면)
|
- **참조**: Ghost(관리자 UX/글쓰기), Thred 테마(사용자 화면)
|
||||||
- **현재 상태**: 코드 스캐폴딩 전 문서 기준점
|
- **현재 상태**: Nuxt 3 초기 스캐폴딩 완료
|
||||||
- **원격 저장소**: https://git.sori.studio/zenn/sori.studio.git
|
- **원격 저장소**: https://git.sori.studio/zenn/sori.studio.git
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -138,7 +138,15 @@ components/content/
|
|||||||
|
|
||||||
## API 구조
|
## API 구조
|
||||||
|
|
||||||
> 아직 구현 전 설계안이다. 실제 구현 시 응답 구조와 엔드포인트가 바뀌면 이 문서를 먼저 갱신한다.
|
> 현재 API는 Nuxt `server/api` 내부에 샘플 데이터 기반으로 구현되어 있다. DB 연결 후 같은 응답 구조를 유지하되 저장소만 교체한다.
|
||||||
|
|
||||||
|
### 백엔드 구성
|
||||||
|
|
||||||
|
- 별도 `backend/` 앱을 두지 않고 Nuxt/Nitro 서버 기능을 사용
|
||||||
|
- 공개 API는 `server/api`에 작성
|
||||||
|
- 서버 공통 스키마와 샘플 데이터는 `server/utils`에 작성
|
||||||
|
- 초기 단계에서는 같은 앱 배포로 관리 비용을 낮춤
|
||||||
|
- 독립 API 서버가 필요해지는 시점에만 백엔드 분리를 재검토
|
||||||
|
|
||||||
### 공개 API (`/api/`)
|
### 공개 API (`/api/`)
|
||||||
|
|
||||||
@@ -193,8 +201,11 @@ UPLOAD_DIR=/uploads
|
|||||||
MAX_FILE_SIZE=10485760
|
MAX_FILE_SIZE=10485760
|
||||||
|
|
||||||
# Site
|
# Site
|
||||||
SITE_URL=https://sori.studio
|
NUXT_PUBLIC_SITE_URL=https://sori.studio
|
||||||
SITE_TITLE=sori.studio
|
NUXT_PUBLIC_SITE_TITLE=sori.studio
|
||||||
|
|
||||||
|
# Server
|
||||||
|
APP_PORT=43118
|
||||||
```
|
```
|
||||||
|
|
||||||
### 환경 파일 기준
|
### 환경 파일 기준
|
||||||
@@ -205,10 +216,18 @@ SITE_TITLE=sori.studio
|
|||||||
| `.env.production` | NAS 운영 | 운영 DB |
|
| `.env.production` | NAS 운영 | 운영 DB |
|
||||||
| `.env.example` | 공유 예시 | 실제 접속 정보 없음 |
|
| `.env.example` | 공유 예시 | 실제 접속 정보 없음 |
|
||||||
|
|
||||||
|
### 포트 기준
|
||||||
|
|
||||||
|
| 용도 | 포트 |
|
||||||
|
|------|------|
|
||||||
|
| 로컬 개발 서버 | 43117 |
|
||||||
|
| NAS Docker 외부 포트 | 43118 |
|
||||||
|
| 컨테이너 내부 포트 | 3000 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 버전 관리
|
## 버전 관리
|
||||||
|
|
||||||
- 현재 버전: v0.0.1
|
- 현재 버전: v0.0.2
|
||||||
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
|
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
|
||||||
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정
|
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정
|
||||||
|
|||||||
43
docs/todo.md
43
docs/todo.md
@@ -26,28 +26,27 @@
|
|||||||
|
|
||||||
## 프론트엔드 개발
|
## 프론트엔드 개발
|
||||||
|
|
||||||
- [ ] SiteHeader.vue 구현 (57px 높이)
|
- [ ] 공개 화면 반응형 세부 스타일 조정
|
||||||
- [ ] LeftSidebar.vue 구현 (287px, 패딩 12px 12px 12px 0)
|
- [ ] Thred 참고 화면 기준 시각 QA
|
||||||
- [ ] RightSidebar.vue 구현 (287px, 패딩 20px 0 20px 20px)
|
- [ ] 게시물 카드 실제 데이터 연동
|
||||||
- [ ] MainColumn.vue 구현 (720px)
|
- [ ] 태그 페이지 실제 데이터 연동
|
||||||
- [ ] PostCard.vue 구현
|
- [ ] 고정 페이지 실제 데이터 연동
|
||||||
- [ ] TagHeader.vue 구현
|
|
||||||
|
|
||||||
## 콘텐츠 스타일 구현
|
## 콘텐츠 스타일 구현
|
||||||
|
|
||||||
- [ ] ProseHeading (h1~h6)
|
- [ ] ProseHeading 실제 스타일 세부 조정
|
||||||
- [ ] ProseList (Ordered/Unordered)
|
- [ ] ProseList 실제 스타일 세부 조정
|
||||||
- [ ] ProseBlockquote
|
- [ ] ProseBlockquote 실제 스타일 세부 조정
|
||||||
- [ ] ProseImage (Regular/Wide/Full-width)
|
- [ ] ProseImage Regular/Wide/Full-width 동작 검증
|
||||||
- [ ] ProseButton (Left-aligned/Centered)
|
- [ ] ProseButton Left/Center 정렬 검증
|
||||||
- [ ] ProseCallout
|
- [ ] ProseCallout 실제 스타일 세부 조정
|
||||||
- [ ] ProseToggle
|
- [ ] ProseToggle 실제 스타일 세부 조정
|
||||||
- [ ] ProseVideo
|
- [ ] ProseVideo 실제 임베드 렌더링 연결
|
||||||
- [ ] ProseAudio
|
- [ ] ProseAudio 실제 오디오 렌더링 연결
|
||||||
- [ ] ProseFile
|
- [ ] ProseFile 실제 파일 데이터 연결
|
||||||
- [ ] ProseProduct
|
- [ ] ProseProduct 실제 상품 카드 데이터 연결
|
||||||
- [ ] ProseHeaderCard (Simple/Wide/Full-width/Split)
|
- [ ] ProseHeaderCard Simple/Wide/Full-width/Split 변형 구현
|
||||||
- [ ] ProseEmbed (YouTube, Twitter)
|
- [ ] ProseEmbed YouTube, Twitter 실제 렌더링 연결
|
||||||
|
|
||||||
## 데이터베이스
|
## 데이터베이스
|
||||||
|
|
||||||
@@ -63,7 +62,5 @@
|
|||||||
## 배포
|
## 배포
|
||||||
|
|
||||||
- [ ] UGREEN NAS Docker 배포 가이드 작성
|
- [ ] UGREEN NAS Docker 배포 가이드 작성
|
||||||
- [ ] 로컬 개발 환경 가이드 작성
|
- [ ] Docker 빌드 검증
|
||||||
- [ ] Dockerfile 작성
|
- [ ] NAS 운영 환경 변수 작성
|
||||||
- [ ] docker-compose.yml 작성
|
|
||||||
- [ ] .env.example 작성
|
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## 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
|
## v0.0.1
|
||||||
|
|
||||||
- sori.studio 개인 블로그/CMS 방향 정리.
|
- sori.studio 개인 블로그/CMS 방향 정리.
|
||||||
|
|||||||
26
layouts/admin.vue
Normal file
26
layouts/admin.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<div class="admin-layout min-h-screen bg-[#f5f5f2] text-ink">
|
||||||
|
<aside class="admin-layout__sidebar fixed inset-y-0 left-0 hidden w-64 border-r border-line bg-[#15171a] p-5 text-white lg:block">
|
||||||
|
<NuxtLink class="admin-layout__brand block text-lg font-semibold" to="/admin">
|
||||||
|
sori.studio
|
||||||
|
</NuxtLink>
|
||||||
|
<nav class="admin-layout__nav mt-8 grid gap-2 text-sm text-white/75">
|
||||||
|
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 hover:bg-white/10 hover:text-white" to="/admin/posts">
|
||||||
|
글
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 hover:bg-white/10 hover:text-white" to="/admin/pages">
|
||||||
|
페이지
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 hover:bg-white/10 hover:text-white" to="/admin/tags">
|
||||||
|
태그
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 hover:bg-white/10 hover:text-white" to="/admin/settings">
|
||||||
|
설정
|
||||||
|
</NuxtLink>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
<main class="admin-layout__main min-h-screen p-5 lg:ml-64">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
12
layouts/default.vue
Normal file
12
layouts/default.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<template>
|
||||||
|
<div class="site-shell public-layout">
|
||||||
|
<SiteHeader />
|
||||||
|
<div class="site-content-grid public-layout__grid">
|
||||||
|
<LeftSidebar />
|
||||||
|
<main class="site-main w-full lg:w-[720px]">
|
||||||
|
<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>
|
||||||
12
layouts/post.vue
Normal file
12
layouts/post.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<template>
|
||||||
|
<div class="site-shell post-layout">
|
||||||
|
<SiteHeader />
|
||||||
|
<div class="site-content-grid post-layout__grid">
|
||||||
|
<LeftSidebar />
|
||||||
|
<main class="post-main w-full bg-paper px-5 py-8 lg:w-[720px]">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
<RightSidebar />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
30
nuxt.config.js
Normal file
30
nuxt.config.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
compatibilityDate: '2026-04-29',
|
||||||
|
modules: ['@nuxtjs/tailwindcss'],
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
11635
package-lock.json
generated
Normal file
11635
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
Normal file
20
package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "sori.studio",
|
||||||
|
"version": "0.0.2",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nuxt dev --host 127.0.0.1 --port 43117",
|
||||||
|
"build": "nuxt build",
|
||||||
|
"preview": "nuxt preview --host 127.0.0.1 --port 43117",
|
||||||
|
"postinstall": "nuxt prepare"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
"nuxt": "^3.16.2",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.5.0",
|
||||||
|
"zod": "^3.24.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {}
|
||||||
|
}
|
||||||
21
pages/admin/index.vue
Normal file
21
pages/admin/index.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin'
|
||||||
|
})
|
||||||
|
</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 bg-paper p-6 text-sm text-muted">
|
||||||
|
관리자 기능은 Ghost 스타일의 글쓰기 흐름을 기준으로 단계별 구현합니다.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
16
pages/admin/pages/index.vue
Normal file
16
pages/admin/pages/index.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="admin-pages bg-paper p-6">
|
||||||
|
<h1 class="admin-pages__title text-3xl font-semibold">
|
||||||
|
페이지 관리
|
||||||
|
</h1>
|
||||||
|
<p class="admin-pages__description mt-4 text-sm text-muted">
|
||||||
|
고정 페이지 CRUD는 2차 관리자 개발 범위입니다.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
16
pages/admin/posts/[id].vue
Normal file
16
pages/admin/posts/[id].vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="admin-post-edit bg-paper p-6">
|
||||||
|
<h1 class="admin-post-edit__title text-3xl font-semibold">
|
||||||
|
글 수정
|
||||||
|
</h1>
|
||||||
|
<p class="admin-post-edit__description mt-4 text-sm text-muted">
|
||||||
|
저장된 글 데이터 연결 후 수정 화면을 구성합니다.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
16
pages/admin/posts/index.vue
Normal file
16
pages/admin/posts/index.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="admin-posts bg-paper p-6">
|
||||||
|
<h1 class="admin-posts__title text-3xl font-semibold">
|
||||||
|
글 목록
|
||||||
|
</h1>
|
||||||
|
<p class="admin-posts__description mt-4 text-sm text-muted">
|
||||||
|
글 목록 조회는 DB 설계 이후 연결합니다.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
16
pages/admin/posts/new.vue
Normal file
16
pages/admin/posts/new.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="admin-post-editor bg-paper p-6">
|
||||||
|
<h1 class="admin-post-editor__title text-3xl font-semibold">
|
||||||
|
새 글 작성
|
||||||
|
</h1>
|
||||||
|
<p class="admin-post-editor__description mt-4 text-sm text-muted">
|
||||||
|
마크다운 기반 위지윅 에디터는 다음 단계에서 구현합니다.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
16
pages/admin/settings/index.vue
Normal file
16
pages/admin/settings/index.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="admin-settings bg-paper p-6">
|
||||||
|
<h1 class="admin-settings__title text-3xl font-semibold">
|
||||||
|
사이트 설정
|
||||||
|
</h1>
|
||||||
|
<p class="admin-settings__description mt-4 text-sm text-muted">
|
||||||
|
사이트 설정은 2차 관리자 개발 범위입니다.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
16
pages/admin/tags/index.vue
Normal file
16
pages/admin/tags/index.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="admin-tags bg-paper p-6">
|
||||||
|
<h1 class="admin-tags__title text-3xl font-semibold">
|
||||||
|
태그 관리
|
||||||
|
</h1>
|
||||||
|
<p class="admin-tags__description mt-4 text-sm text-muted">
|
||||||
|
DEV, NOTE, REVIEW, PLAY 같은 카테고리성 태그를 관리합니다.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
38
pages/index.vue
Normal file
38
pages/index.vue
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup>
|
||||||
|
const posts = [
|
||||||
|
{
|
||||||
|
title: 'sori.studio를 직접 만들기 시작하며',
|
||||||
|
excerpt: '블로그와 포털의 경계에 있는 개인 공간을 직접 구축하기 위한 첫 기록입니다.',
|
||||||
|
tag: 'NOTE',
|
||||||
|
publishedAt: '2026.04.29',
|
||||||
|
to: '/posts/hello-sori-studio'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '글쓰기 도구는 왜 직접 만들게 되는가',
|
||||||
|
excerpt: '네이버 블로그, 티스토리, 워드프레스, Ghost를 거쳐 남은 취향의 빈칸을 정리합니다.',
|
||||||
|
tag: 'DEV',
|
||||||
|
publishedAt: '2026.04.29',
|
||||||
|
to: '/posts/custom-writing-tool'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MainColumn>
|
||||||
|
<section class="home-hero site-section">
|
||||||
|
<div class="home-hero__inner site-section-header">
|
||||||
|
<p class="home-hero__eyebrow text-xs font-semibold uppercase text-muted">
|
||||||
|
Personal Blog / CMS
|
||||||
|
</p>
|
||||||
|
<h1 class="home-hero__title mt-3 text-5xl font-semibold leading-tight tracking-normal">
|
||||||
|
sori.studio
|
||||||
|
</h1>
|
||||||
|
<p class="home-hero__description mt-4 max-w-xl text-base leading-7 text-muted">
|
||||||
|
글을 쌓고, 프로젝트와 링크를 연결하고, 오래 쓰기 좋은 개인 블로그를 직접 구축합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<PostCard v-for="post in posts" :key="post.to" :post="post" />
|
||||||
|
</MainColumn>
|
||||||
|
</template>
|
||||||
19
pages/pages/[slug].vue
Normal file
19
pages/pages/[slug].vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'page'
|
||||||
|
})
|
||||||
|
</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">
|
||||||
|
고정 페이지
|
||||||
|
</h1>
|
||||||
|
<p class="static-page__description mt-6 text-lg leading-8 text-muted">
|
||||||
|
About, Projects, Links, Contact 같은 고정 페이지는 헤더와 사이드바 없이 본문 중심으로 표시합니다.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
44
pages/posts/[slug].vue
Normal file
44
pages/posts/[slug].vue
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'post'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContentRenderer>
|
||||||
|
<ProseHeaderCard>
|
||||||
|
<p class="post-detail__eyebrow text-sm uppercase text-white/70">
|
||||||
|
NOTE
|
||||||
|
</p>
|
||||||
|
<h1 class="post-detail__title mt-3 text-4xl font-semibold leading-tight">
|
||||||
|
sori.studio를 직접 만들기 시작하며
|
||||||
|
</h1>
|
||||||
|
</ProseHeaderCard>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
이 페이지는 게시물 본문 스타일을 확인하기 위한 초기 샘플입니다.
|
||||||
|
실제 글 데이터와 마크다운 기반 위지윅 렌더링은 다음 단계에서 연결합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ProseHeading :level="2">
|
||||||
|
본문 스타일 기준
|
||||||
|
</ProseHeading>
|
||||||
|
<p>
|
||||||
|
제목, 리스트, 인용구, 이미지, 버튼, 카드류 컴포넌트를 개별 컴포넌트로 분리해 이후 스타일 변경이 쉽도록 둡니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ProseList>
|
||||||
|
<li>Regular image, Wide image, Full-width image 구분</li>
|
||||||
|
<li>Callout, Toggle, File, Product 카드 분리</li>
|
||||||
|
<li>YouTube, Twitter 임베드 영역 분리</li>
|
||||||
|
</ProseList>
|
||||||
|
|
||||||
|
<ProseBlockquote>
|
||||||
|
글쓰기 경험은 Ghost를 참고하되, 공개 화면은 sori.studio에 맞게 조정합니다.
|
||||||
|
</ProseBlockquote>
|
||||||
|
|
||||||
|
<ProseCallout>
|
||||||
|
<strong>초기 상태:</strong> 지금은 샘플 콘텐츠이며, DB와 관리자 글쓰기 연결 후 실제 데이터로 교체합니다.
|
||||||
|
</ProseCallout>
|
||||||
|
</ContentRenderer>
|
||||||
|
</template>
|
||||||
10
pages/tags/[slug].vue
Normal file
10
pages/tags/[slug].vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<template>
|
||||||
|
<MainColumn>
|
||||||
|
<TagHeader title="NOTE" description="생각과 기록을 모아두는 태그 페이지입니다." />
|
||||||
|
<section class="tag-posts site-section">
|
||||||
|
<div class="tag-posts__empty site-section-body text-sm text-muted">
|
||||||
|
태그별 글 목록은 DB 연결 후 표시합니다.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</MainColumn>
|
||||||
|
</template>
|
||||||
7
server/api/pages.get.js
Normal file
7
server/api/pages.get.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { getSamplePages } from '../utils/sample-content'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개 고정 페이지 목록 API
|
||||||
|
* @returns {Array} 고정 페이지 목록
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(() => getSamplePages())
|
||||||
20
server/api/pages/[slug].get.js
Normal file
20
server/api/pages/[slug].get.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { getSamplePageBySlug } from '../../utils/sample-content'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개 고정 페이지 상세 API
|
||||||
|
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||||
|
* @returns {Object} 고정 페이지 상세
|
||||||
|
*/
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const slug = getRouterParam(event, 'slug')
|
||||||
|
const page = getSamplePageBySlug(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 { getSamplePosts } from '../utils/sample-content'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개 게시물 목록 API
|
||||||
|
* @returns {Array} 게시물 목록
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(() => getSamplePosts())
|
||||||
20
server/api/posts/[slug].get.js
Normal file
20
server/api/posts/[slug].get.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { getSamplePostBySlug } from '../../utils/sample-content'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개 게시물 상세 API
|
||||||
|
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||||
|
* @returns {Object} 게시물 상세
|
||||||
|
*/
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const slug = getRouterParam(event, 'slug')
|
||||||
|
const post = getSamplePostBySlug(slug)
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: '게시물을 찾을 수 없습니다'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return post
|
||||||
|
})
|
||||||
7
server/api/tags.get.js
Normal file
7
server/api/tags.get.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { getSampleTags } from '../utils/sample-content'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개 태그 목록 API
|
||||||
|
* @returns {Array} 태그 목록
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(() => getSampleTags())
|
||||||
34
server/utils/content-schema.js
Normal file
34
server/utils/content-schema.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const postStatusSchema = z.enum(['published', 'draft', 'private'])
|
||||||
|
|
||||||
|
export const postSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
title: z.string().min(1),
|
||||||
|
slug: z.string().min(1),
|
||||||
|
content: z.string(),
|
||||||
|
excerpt: z.string().default(''),
|
||||||
|
featuredImage: z.string().nullable().default(null),
|
||||||
|
status: postStatusSchema,
|
||||||
|
publishedAt: z.string().nullable().default(null),
|
||||||
|
createdAt: z.string(),
|
||||||
|
updatedAt: z.string(),
|
||||||
|
tags: z.array(z.string()).default([])
|
||||||
|
})
|
||||||
|
|
||||||
|
export const pageSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
title: z.string().min(1),
|
||||||
|
slug: z.string().min(1),
|
||||||
|
content: z.string(),
|
||||||
|
featuredImage: z.string().nullable().default(null),
|
||||||
|
createdAt: z.string(),
|
||||||
|
updatedAt: z.string()
|
||||||
|
})
|
||||||
|
|
||||||
|
export const tagSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
name: z.string().min(1),
|
||||||
|
slug: z.string().min(1),
|
||||||
|
description: z.string().default('')
|
||||||
|
})
|
||||||
99
server/utils/sample-content.js
Normal file
99
server/utils/sample-content.js
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { pageSchema, postSchema, tagSchema } from './content-schema'
|
||||||
|
|
||||||
|
const now = '2026-04-29T00:00:00.000Z'
|
||||||
|
|
||||||
|
const samplePosts = [
|
||||||
|
{
|
||||||
|
id: '11111111-1111-4111-8111-111111111111',
|
||||||
|
title: 'sori.studio를 직접 만들기 시작하며',
|
||||||
|
slug: 'hello-sori-studio',
|
||||||
|
content: '개인 블로그와 포털 역할을 한 공간에 담기 위한 첫 글입니다.',
|
||||||
|
excerpt: '블로그와 포털의 경계에 있는 개인 공간을 직접 구축하기 위한 첫 기록입니다.',
|
||||||
|
featuredImage: null,
|
||||||
|
status: 'published',
|
||||||
|
publishedAt: now,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
tags: ['note']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '22222222-2222-4222-8222-222222222222',
|
||||||
|
title: '글쓰기 도구는 왜 직접 만들게 되는가',
|
||||||
|
slug: 'custom-writing-tool',
|
||||||
|
content: '기존 도구를 거치며 남은 취향의 빈칸을 직접 채우는 과정입니다.',
|
||||||
|
excerpt: '네이버 블로그, 티스토리, 워드프레스, Ghost를 거쳐 남은 취향의 빈칸을 정리합니다.',
|
||||||
|
featuredImage: null,
|
||||||
|
status: 'published',
|
||||||
|
publishedAt: now,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
tags: ['dev']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const samplePages = [
|
||||||
|
{
|
||||||
|
id: '33333333-3333-4333-8333-333333333333',
|
||||||
|
title: 'About',
|
||||||
|
slug: 'about',
|
||||||
|
content: 'sori.studio 소개 페이지입니다.',
|
||||||
|
featuredImage: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const sampleTags = [
|
||||||
|
{
|
||||||
|
id: '44444444-4444-4444-8444-444444444444',
|
||||||
|
name: 'NOTE',
|
||||||
|
slug: 'note',
|
||||||
|
description: '생각과 기록을 모아두는 태그입니다.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '55555555-5555-4555-8555-555555555555',
|
||||||
|
name: 'DEV',
|
||||||
|
slug: 'dev',
|
||||||
|
description: '개발과 제작 과정을 기록하는 태그입니다.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 샘플 게시물 목록 조회
|
||||||
|
* @returns {Array<import('zod').infer<typeof postSchema>>} 샘플 게시물 목록
|
||||||
|
*/
|
||||||
|
export const getSamplePosts = () => samplePosts.map((post) => postSchema.parse(post))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 슬러그로 샘플 게시물 조회
|
||||||
|
* @param {string} slug - 게시물 슬러그
|
||||||
|
* @returns {import('zod').infer<typeof postSchema> | null} 샘플 게시물
|
||||||
|
*/
|
||||||
|
export const getSamplePostBySlug = (slug) => {
|
||||||
|
const post = samplePosts.find((item) => item.slug === slug)
|
||||||
|
|
||||||
|
return post ? postSchema.parse(post) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 샘플 고정 페이지 목록 조회
|
||||||
|
* @returns {Array<import('zod').infer<typeof pageSchema>>} 샘플 페이지 목록
|
||||||
|
*/
|
||||||
|
export const getSamplePages = () => samplePages.map((page) => pageSchema.parse(page))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 슬러그로 샘플 고정 페이지 조회
|
||||||
|
* @param {string} slug - 페이지 슬러그
|
||||||
|
* @returns {import('zod').infer<typeof pageSchema> | null} 샘플 페이지
|
||||||
|
*/
|
||||||
|
export const getSamplePageBySlug = (slug) => {
|
||||||
|
const page = samplePages.find((item) => item.slug === slug)
|
||||||
|
|
||||||
|
return page ? pageSchema.parse(page) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 샘플 태그 목록 조회
|
||||||
|
* @returns {Array<import('zod').infer<typeof tagSchema>>} 샘플 태그 목록
|
||||||
|
*/
|
||||||
|
export const getSampleTags = () => sampleTags.map((tag) => tagSchema.parse(tag))
|
||||||
25
tailwind.config.js
Normal file
25
tailwind.config.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
'./components/**/*.{vue,js}',
|
||||||
|
'./layouts/**/*.vue',
|
||||||
|
'./pages/**/*.vue',
|
||||||
|
'./app.vue',
|
||||||
|
'./error.vue'
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
pretendard: ['Pretendard', 'ui-sans-serif', 'system-ui', 'sans-serif']
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
ink: '#1f2328',
|
||||||
|
muted: '#6b7280',
|
||||||
|
line: '#e5e7eb',
|
||||||
|
paper: '#ffffff',
|
||||||
|
surface: '#f7f7f4'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: []
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user