Nuxt 초기 세팅 추가

This commit is contained in:
2026-04-29 14:54:44 +09:00
parent efc7955415
commit 37f6c38caa
60 changed files with 12698 additions and 34 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
node_modules
.nuxt
.output
dist
coverage
.git
.env
.env.development
.env.production
*.log

18
.env.example Normal file
View 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

View File

@@ -157,6 +157,7 @@
- 기존 API 호출 패턴을 따른다. - 기존 API 호출 패턴을 따른다.
- 응답 구조 변경 시 docs/spec.md를 반드시 갱신한다. - 응답 구조 변경 시 docs/spec.md를 반드시 갱신한다.
- 하드코딩된 값 사용을 금지한다. - 하드코딩된 값 사용을 금지한다.
- 초기 백엔드는 별도 앱으로 분리하지 않고 Nuxt `server/api`를 사용한다.
- 개발 환경과 운영 환경의 데이터베이스 연결 문자열을 혼용하지 않는다. - 개발 환경과 운영 환경의 데이터베이스 연결 문자열을 혼용하지 않는다.
- 운영 DB는 로컬 개발 서버에서 직접 사용하지 않는다. - 운영 DB는 로컬 개발 서버에서 직접 사용하지 않는다.

28
Dockerfile Normal file
View 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
View File

@@ -0,0 +1,5 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>

42
assets/css/main.css Normal file
View 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;
}
}

View File

@@ -0,0 +1,5 @@
<template>
<article class="content-renderer post-prose">
<slot />
</article>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<div class="prose-audio my-8 border border-line bg-surface p-5">
<slot />
</div>
</template>

View 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>

View 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>

View File

@@ -0,0 +1,5 @@
<template>
<aside class="prose-callout my-8 border border-line bg-surface p-5">
<slot />
</aside>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<div class="prose-embed my-8 border border-line bg-paper p-5">
<slot />
</div>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<div class="prose-file my-8 border border-line bg-paper p-5">
<slot />
</div>
</template>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,5 @@
<template>
<div class="prose-product my-8 border border-line bg-surface p-5">
<slot />
</div>
</template>

View 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>

View File

@@ -0,0 +1,5 @@
<template>
<div class="prose-video my-8 aspect-video bg-ink text-white">
<slot />
</div>
</template>

View 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>

View File

@@ -0,0 +1,5 @@
<template>
<div class="main-column w-full lg:w-[720px]">
<slot />
</div>
</template>

View 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>

View 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>

View 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>

View 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
View 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

View File

@@ -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 초기 방향 정리.

View File

@@ -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 설정 시)
--- ---

View File

@@ -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
### 초기 제품 방향 결정 ### 초기 제품 방향 결정

View File

@@ -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 컨테이너 실행 초안 |

View File

@@ -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
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가 - 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정 - 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정

View File

@@ -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 작성

View File

@@ -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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

20
package.json Normal file
View 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
View 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>

View 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>

View 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>

View 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
View 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>

View 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>

View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,7 @@
import { getSamplePages } from '../utils/sample-content'
/**
* 공개 고정 페이지 목록 API
* @returns {Array} 고정 페이지 목록
*/
export default defineEventHandler(() => getSamplePages())

View 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
View File

@@ -0,0 +1,7 @@
import { getSamplePosts } from '../utils/sample-content'
/**
* 공개 게시물 목록 API
* @returns {Array} 게시물 목록
*/
export default defineEventHandler(() => getSamplePosts())

View 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
View File

@@ -0,0 +1,7 @@
import { getSampleTags } from '../utils/sample-content'
/**
* 공개 태그 목록 API
* @returns {Array} 태그 목록
*/
export default defineEventHandler(() => getSampleTags())

View 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('')
})

View 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
View 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: []
}