5 Commits

Author SHA1 Message Date
237eb2990f 환경 변수 예시 정리 2026-04-29 15:30:56 +09:00
5ee6fcd54b PostgreSQL 데이터 계층 추가 2026-04-29 15:22:54 +09:00
cbf5ed6c8c 사이드바 메뉴 토글 추가 2026-04-29 15:08:04 +09:00
a3acd9320a 공개 화면 테마와 패널 구조 보정 2026-04-29 15:01:59 +09:00
37f6c38caa Nuxt 초기 세팅 추가 2026-04-29 14:54:44 +09:00
65 changed files with 13578 additions and 54 deletions

10
.dockerignore Normal file
View File

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

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

View File

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

138
assets/css/main.css Normal file
View File

@@ -0,0 +1,138 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--site-bg: #fbfbfa;
--site-panel: #f6f6f5;
--site-panel-strong: #ffffff;
--site-text: #111111;
--site-muted: #454545;
--site-soft: #6f7480;
--site-line: #e2e2e0;
--site-input: #f2f2f1;
--site-accent: #ff4f2e;
--site-accent-text: #ffffff;
--site-invert: #111111;
--site-invert-text: #ffffff;
}
@media (prefers-color-scheme: dark) {
:root {
--site-bg: #050505;
--site-panel: #080808;
--site-panel-strong: #0d0d0d;
--site-text: #f4f4f2;
--site-muted: #c7c7c2;
--site-soft: #8b8e96;
--site-line: #252525;
--site-input: #171717;
--site-accent: #ff4f2e;
--site-accent-text: #ffffff;
--site-invert: #f4f4f2;
--site-invert-text: #111111;
}
}
html {
font-family: Pretendard, ui-sans-serif, system-ui, sans-serif;
color: var(--site-text);
background: var(--site-bg);
}
body {
min-width: 320px;
margin: 0;
background: var(--site-bg);
}
}
@layer components {
.site-shell {
min-height: 100vh;
color: var(--site-text);
background: var(--site-bg);
}
.site-content-grid {
@apply mx-auto grid max-w-[1294px] grid-cols-1 px-4 lg:grid-cols-[287px_minmax(0,720px)_287px] lg:px-0;
min-height: calc(100vh - 57px);
background: var(--site-bg);
}
.site-content-grid--menu-closed {
@apply lg:grid-cols-[minmax(0,720px)_287px];
max-width: 1007px;
}
.site-section {
border-bottom: 1px solid var(--site-line);
background: var(--site-bg);
}
.site-section-header {
@apply px-6 py-8;
}
.site-section-body {
@apply px-6 py-4;
}
.post-prose {
@apply max-w-none text-[17px] leading-8;
color: var(--site-text);
}
.site-header {
height: 57px;
border-bottom: 1px solid var(--site-line);
background: var(--site-panel);
color: var(--site-text);
}
.site-main {
min-height: calc(100vh - 57px);
border-left: 1px solid var(--site-line);
border-right: 1px solid var(--site-line);
background: var(--site-bg);
}
.site-main--menu-closed {
border-left: 0;
}
.site-sidebar {
min-height: calc(100vh - 57px);
background: var(--site-panel);
color: var(--site-text);
}
.site-sidebar-section {
border-bottom: 1px solid var(--site-line);
}
.site-muted {
color: var(--site-muted);
}
.site-soft {
color: var(--site-soft);
}
.site-input {
border: 1px solid var(--site-line);
background: var(--site-input);
color: var(--site-text);
}
.site-button {
background: var(--site-invert);
color: var(--site-invert-text);
}
.site-accent-button {
background: var(--site-accent);
color: var(--site-accent-text);
}
}

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,105 @@
<template>
<aside class="left-sidebar site-sidebar hidden w-[287px] lg:flex lg:flex-col">
<div class="left-sidebar__scroll min-h-0 flex-1 overflow-y-auto">
<div class="left-sidebar__block site-sidebar-section py-3 pl-0 pr-3">
<nav class="left-sidebar__nav grid gap-1 text-[15px]">
<NuxtLink class="left-sidebar__nav-link flex items-center justify-between py-2 pl-3" to="/">
<span>Home pages</span>
<span></span>
</NuxtLink>
<NuxtLink class="left-sidebar__nav-link py-2 pl-3" to="/tags/note">
Tags
</NuxtLink>
<NuxtLink class="left-sidebar__nav-link py-2 pl-3" to="/pages/about">
Authors
</NuxtLink>
<NuxtLink class="left-sidebar__nav-link py-2 pl-3" to="/posts/hello-sori-studio">
Style
</NuxtLink>
<NuxtLink class="left-sidebar__nav-link flex items-center justify-between py-2 pl-3" to="/posts/custom-writing-tool">
<span>Post types</span>
<span></span>
</NuxtLink>
<NuxtLink class="left-sidebar__nav-link flex items-center justify-between py-2 pl-3" to="/pages/contact">
<span>Members</span>
<span></span>
</NuxtLink>
<NuxtLink class="left-sidebar__nav-link py-2 pl-3" to="/pages/projects">
Landing pages
</NuxtLink>
</nav>
</div>
<div class="left-sidebar__block site-sidebar-section py-5 pl-0 pr-3">
<div class="left-sidebar__section-title flex items-center justify-between text-xs font-semibold uppercase site-muted">
<span>Categories</span>
<span></span>
</div>
<div class="left-sidebar__category-grid mt-4 grid grid-cols-2 gap-x-6 gap-y-4 text-sm">
<NuxtLink class="left-sidebar__category flex items-center gap-3" to="/tags/books">
<span class="h-4 w-1 rounded-full bg-orange-500" /> Books
</NuxtLink>
<NuxtLink class="left-sidebar__category flex items-center gap-3" to="/tags/business">
<span class="h-4 w-1 rounded-full bg-indigo-500" /> Business
</NuxtLink>
<NuxtLink class="left-sidebar__category flex items-center gap-3" to="/tags/dev">
<span class="h-4 w-1 rounded-full bg-cyan-500" /> Tech
</NuxtLink>
<NuxtLink class="left-sidebar__category flex items-center gap-3" to="/tags/science">
<span class="h-4 w-1 rounded-full bg-teal-400" /> Science
</NuxtLink>
<NuxtLink class="left-sidebar__category flex items-center gap-3" to="/tags/design">
<span class="h-4 w-1 rounded-full bg-fuchsia-500" /> Design
</NuxtLink>
<NuxtLink class="left-sidebar__category flex items-center gap-3" to="/tags/music">
<span class="h-4 w-1 rounded-full bg-pink-500" /> Music
</NuxtLink>
<NuxtLink class="left-sidebar__category flex items-center gap-3" to="/tags/health">
<span class="h-4 w-1 rounded-full bg-green-500" /> Health
</NuxtLink>
<NuxtLink class="left-sidebar__category flex items-center gap-3" to="/tags/play">
<span class="h-4 w-1 rounded-full bg-violet-500" /> Gaming
</NuxtLink>
<NuxtLink class="left-sidebar__category flex items-center gap-3" to="/tags/travel">
<span class="h-4 w-1 rounded-full bg-purple-500" /> Travel
</NuxtLink>
<NuxtLink class="left-sidebar__category flex items-center gap-3" to="/tags/diy">
<span class="h-4 w-1 rounded-full bg-yellow-400" /> DIY
</NuxtLink>
</div>
</div>
<div class="left-sidebar__block site-sidebar-section py-5 pl-0 pr-3">
<div class="left-sidebar__section-title flex items-center justify-between text-xs font-semibold uppercase site-muted">
<span>Authors</span>
<span></span>
</div>
<div class="left-sidebar__authors mt-4 grid gap-4 text-sm">
<div class="left-sidebar__author flex items-center gap-3">
<span class="h-8 w-8 rounded-full bg-[#e7c49d]" />
<span><strong class="block">sori</strong><span class="site-soft">Editor</span></span>
</div>
<div class="left-sidebar__author flex items-center gap-3">
<span class="h-8 w-8 rounded-full bg-[#98b7d5]" />
<span><strong class="block">zenn</strong><span class="site-soft">Writer</span></span>
</div>
</div>
</div>
</div>
<footer class="left-sidebar__footer flex items-center justify-between px-1 py-4 text-xs">
<nav class="left-sidebar__footer-nav flex gap-4">
<NuxtLink to="/pages/links">
Portal
</NuxtLink>
<NuxtLink to="/pages/about">
Docs
</NuxtLink>
<NuxtLink to="/pages/projects">
Projects
</NuxtLink>
</nav>
<span class="left-sidebar__theme-dot"></span>
</footer>
</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,29 @@
<script setup>
defineProps({
post: {
type: Object,
required: true
}
})
</script>
<template>
<article class="post-card site-section">
<div class="post-card__body site-section-body flex gap-4">
<div class="post-card__thumb h-20 w-36 shrink-0 rounded-lg bg-[linear-gradient(135deg,#06333a,#f4a261)]" />
<div class="post-card__content min-w-0">
<h2 class="post-card__title text-base font-semibold leading-tight">
<NuxtLink class="post-card__title-link hover:opacity-70" :to="post.to">
{{ post.title }}
</NuxtLink>
</h2>
<p class="post-card__excerpt mt-2 text-sm leading-6 site-muted">
{{ post.excerpt }}
</p>
<p class="post-card__meta mt-2 text-xs site-muted">
{{ post.publishedAt }} / {{ post.tag }}
</p>
</div>
</div>
</article>
</template>

View File

@@ -0,0 +1,73 @@
<template>
<aside class="right-sidebar site-sidebar hidden w-[287px] lg:flex lg:flex-col">
<div class="right-sidebar__scroll min-h-0 flex-1 overflow-y-auto">
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
<div class="right-sidebar__profile flex items-center gap-3">
<div class="right-sidebar__logo grid h-12 w-12 place-items-center rounded-2xl bg-[var(--site-invert)] text-2xl font-bold text-[var(--site-invert-text)]">
</div>
<div>
<p class="right-sidebar__title font-semibold">
sori.studio
</p>
<p class="right-sidebar__description text-sm site-muted">
Thoughts, stories and ideas.
</p>
</div>
</div>
<form class="right-sidebar__subscribe mt-4 flex gap-2">
<input class="right-sidebar__input min-w-0 flex-1 rounded-lg px-3 py-2 text-sm site-input" placeholder="Your email">
<button class="right-sidebar__button rounded-lg px-4 py-2 text-sm font-semibold site-button" type="button">
Subscribe
</button>
</form>
</div>
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
<div class="right-sidebar__row flex items-center justify-between">
<p class="right-sidebar__eyebrow text-xs font-semibold uppercase site-muted">
Follow
</p>
<div class="right-sidebar__social flex gap-3 text-sm">
<span>f</span>
<span>𝕏</span>
<span>rss</span>
</div>
</div>
</div>
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
<div class="right-sidebar__row flex items-center justify-between">
<p class="right-sidebar__eyebrow text-xs font-semibold uppercase site-muted">
Recommended
</p>
<span></span>
</div>
<div class="right-sidebar__links mt-4 grid gap-3 text-sm">
<NuxtLink class="right-sidebar__link font-semibold" to="/posts/hello-sori-studio">
sori.studio 글과 방향
</NuxtLink>
<NuxtLink class="right-sidebar__link font-semibold" to="/pages/projects">
Projects and services
</NuxtLink>
<NuxtLink class="right-sidebar__link font-semibold" to="/pages/links">
Links and portal
</NuxtLink>
</div>
</div>
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
<p class="right-sidebar__about text-sm leading-6 site-muted">
sori.studio는 글과 프로젝트 링크를 곳에 쌓아두는 개인 블로그/CMS입니다.
</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 sori.studio
</NuxtLink>
</div>
</div>
<footer class="right-sidebar__footer py-4 pl-5 pr-0 text-xs site-muted">
©2026 sori.studio
</footer>
</aside>
</template>

View File

@@ -0,0 +1,56 @@
<script setup>
const { menuOpen, toggleMenu } = useMenuState()
</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>
sori.studio
</NuxtLink>
<div class="site-header__search hidden h-9 w-[470px] items-center rounded-lg px-3 text-sm md:flex site-input">
<span class="site-header__search-icon mr-2 text-lg leading-none"></span>
<span class="site-header__search-text site-soft">Search</span>
<span class="site-header__search-key ml-auto rounded-md px-2 text-xs site-soft site-input">/</span>
</div>
<nav class="site-header__nav flex items-center gap-3 text-sm">
<NuxtLink class="site-header__buy site-accent-button rounded-lg px-4 py-2 font-semibold" to="/pages/about">
Subscribe
</NuxtLink>
<NuxtLink class="site-header__nav-link hover:text-ink" to="/pages/about">
Account
</NuxtLink>
</nav>
</div>
</header>
</template>

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>

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

View File

@@ -0,0 +1,47 @@
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 '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
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);

View File

@@ -0,0 +1,65 @@
INSERT INTO tags (id, name, slug, description)
VALUES
('44444444-4444-4444-8444-444444444444', 'NOTE', 'note', '생각과 기록을 모아두는 태그입니다.'),
('55555555-5555-4555-8555-555555555555', 'DEV', 'dev', '개발과 제작 과정을 기록하는 태그입니다.')
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;

34
docker-compose.yml Normal file
View File

@@ -0,0 +1,34 @@
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
depends_on:
- sori-studio-db
restart: unless-stopped
sori-studio-db:
image: postgres:16-alpine
container_name: sori-studio-db
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:

View File

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

View File

@@ -1,6 +1,6 @@
# 배포 가이드
> 현재 프로젝트는 코드 스캐폴딩 상태다. 아래 내용은 Nuxt 앱 생성 후 적용할 기본 배포 방향이다.
> 현재 프로젝트는 Nuxt 3 초기 스캐폴딩 상태다. Docker 설정은 초안이며 운영 DB 확정 후 NAS에서 검증한다.
## 빌드 유형
@@ -32,17 +32,20 @@ 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
```
### 확인 주소
- 개발 서버: http://localhost:3000
- 관리자: http://localhost:3000/admin
- 개발 서버: http://127.0.0.1:43117
- 관리자: http://127.0.0.1:43117/admin
---
@@ -69,11 +72,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 +94,10 @@ docker run -d -p 3000:3000 sori.studio:latest
### 포트
- HTTP: 3000
- 로컬 개발: 43117
- NAS Docker 외부: 43118
- 컨테이너 내부: 3000
- PostgreSQL 외부: 43119
- HTTPS: 3001 (SSL 설정 시)
---
@@ -98,9 +106,14 @@ 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로 생성
## 사용자 액션 필요 항목

View File

@@ -1,5 +1,51 @@
# 의사결정 이력
## 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
### 초기 제품 방향 결정

View File

@@ -61,3 +61,37 @@
| pages/posts/[slug].vue | 블로그 글 상세 |
| pages/tags/[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 | 샘플 콘텐츠 저장소 |
| server/repositories/postgres-client.js | PostgreSQL 클라이언트 |
| server/repositories/content-repository.js | 콘텐츠 조회 저장소 |
## 데이터베이스
| 파일 | 기능 |
|------|------|
| db/migrations/001_initial_schema.sql | PostgreSQL 초기 테이블 스키마 |
| db/migrations/002_seed_development.sql | 개발용 샘플 데이터 |
## 설정/배포
| 파일 | 기능 |
|------|------|
| package.json | Nuxt 실행 스크립트와 의존성 |
| nuxt.config.js | Nuxt 앱 설정 |
| tailwind.config.js | Tailwind 테마 설정 |
| assets/css/main.css | 전역 스타일 |
| composables/useMenuState.js | 좌측 메뉴 열림 상태 관리 |
| .env.example | 환경 변수 예시 |
| Dockerfile | NAS 운영 이미지 빌드 |
| docker-compose.yml | NAS 컨테이너 실행 초안 |

View File

@@ -6,7 +6,7 @@
- **유형**: 커스텀 블로그/CMS
- **목표**: 개인 블로그 중심 운영, 기존 포털성 링크와 서비스 진입점은 블로그 내부 구조에 통합
- **참조**: Ghost(관리자 UX/글쓰기), Thred 테마(사용자 화면)
- **현재 상태**: 코드 스캐폴딩 전 문서 기준점
- **현재 상태**: Nuxt 3 초기 스캐폴딩과 PostgreSQL 저장소 계층 구성 완료
- **원격 저장소**: https://git.sori.studio/zenn/sori.studio.git
---
@@ -18,9 +18,23 @@
| 요소 | 크기/속성 |
|------|-----------|
| Header | 높이 57px |
| Left Aside | 너비 287px, 패딩 12px 12px 12px 0 |
| Left Aside | 너비 287px, 최소 높이 calc(100vh - 57px), 패딩 12px 12px 12px 0 |
| Main | 너비 720px, 패딩 32px 24px (헤더), 16px 24px (섹션) |
| Right Aside | 너비 287px, 패딩 20px 0 20px 20px |
| Right Aside | 너비 287px, 최소 높이 calc(100vh - 57px), 패딩 20px 0 20px 20px |
### 메뉴 토글
- 헤더 좌측 아이콘은 브랜드 마크가 아니라 왼쪽 사이드바 열기/닫기 버튼
- 메뉴 상태는 Nuxt/Vue 상태로 관리
- 브라우저에서는 `localStorage.MENU_STATE``open` 또는 `closed` 저장
- 닫힘 상태에서는 왼쪽 사이드바를 숨기고 중앙/오른쪽 컬럼만 표시
### 공개 화면 색상
- 라이트/다크 모드는 CSS 변수로 관리
- 기본 배경, 패널, 라인, 텍스트, 보조 텍스트, 입력, 강조 버튼 색상을 분리
- 시스템 다크 모드는 `prefers-color-scheme: dark` 기준으로 우선 대응
- Thred 참고 화면처럼 사이드바와 본문은 같은 화면 안에서 구분되는 패널과 라인으로 표현
### Post 페이지
@@ -85,6 +99,7 @@ components/content/
### 환경 분리 원칙
- 데이터베이스는 PostgreSQL을 기준으로 한다.
- 로컬 개발 환경과 NAS 운영 환경은 서로 다른 DB를 사용
- 로컬 개발 서버는 개발 DB만 연결
- NAS 배포 환경은 운영 DB만 연결
@@ -100,7 +115,7 @@ components/content/
| slug | String | URL 슬러그 |
| content | Text | 마크다운 콘텐츠 |
| excerpt | String | 요약 |
| featured_image | String | 대표 이미지 |
| featured_image | String nullable | 대표 이미지 |
| status | Enum | published/draft/private |
| published_at | DateTime | 발행일 |
| created_at | DateTime | 생성일 |
@@ -114,7 +129,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 +141,8 @@ components/content/
| name | String | 태그명 |
| slug | String | URL 슬러그 |
| description | String | 설명 |
| created_at | DateTime | 생성일 |
| updated_at | DateTime | 수정일 |
### PostTags (다대다)
@@ -133,12 +150,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/`)
@@ -181,34 +209,55 @@ components/content/
```env
# Database
DATABASE_URL=
DATABASE_NAME=
DATABASE_URL=postgres://sori_studio:replace-with-random-password@sori-studio-db:5432/sori_studio
DATABASE_NAME=sori_studio
POSTGRES_DB=sori_studio
POSTGRES_USER=sori_studio
POSTGRES_PASSWORD=replace-with-random-password
DB_PORT=43119
# Auth
ADMIN_EMAIL=
ADMIN_PASSWORD=
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=replace-with-random-password
# Upload
UPLOAD_DIR=/uploads
MAX_FILE_SIZE=10485760
# Site
SITE_URL=https://sori.studio
SITE_TITLE=sori.studio
NUXT_PUBLIC_SITE_URL=https://sori.studio
NUXT_PUBLIC_SITE_TITLE=sori.studio
# Server
APP_PORT=43118
```
### 환경 파일 기준
| 파일 | 용도 | DB |
|------|------|----|
| `.env.development` | 로컬 개발 | 개발 DB |
| `.env.production` | NAS 운영 | 운영 DB |
| `.env.example` | 공유 예시 | 실제 접속 정보 없음 |
| `.env.development` | 로컬 개발, Git 제외 | 개발 DB |
| `.env.production` | NAS 운영, Git 제외 | 운영 DB |
| `.env.example` | 공유 예시, Git 포함 | 실제 접속 정보 없음 |
- `.env.example`에는 실제 이메일, 비밀번호, 토큰, 운영 서버 주소를 기록하지 않음
- `.env.development``.env.production`의 DB 비밀번호와 관리자 비밀번호는 서로 다른 랜덤 값을 사용
- 로컬 개발 `DATABASE_URL`은 호스트 기준 `127.0.0.1:43119`를 사용
- NAS Docker 내부 `DATABASE_URL`은 서비스명 기준 `sori-studio-db:5432`를 사용
### 포트 기준
| 용도 | 포트 |
|------|------|
| 로컬 개발 서버 | 43117 |
| NAS Docker 외부 포트 | 43118 |
| 컨테이너 내부 포트 | 3000 |
| PostgreSQL 외부 포트 | 43119 |
---
## 버전 관리
- 현재 버전: v0.0.1
- 현재 버전: v0.0.6
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정

View File

@@ -26,44 +26,41 @@
## 프론트엔드 개발
- [ ] SiteHeader.vue 구현 (57px 높이)
- [ ] LeftSidebar.vue 구현 (287px, 패딩 12px 12px 12px 0)
- [ ] RightSidebar.vue 구현 (287px, 패딩 20px 0 20px 20px)
- [ ] MainColumn.vue 구현 (720px)
- [ ] PostCard.vue 구현
- [ ] TagHeader.vue 구현
- [ ] 공개 화면 모바일 사이드바/네비게이션 방식 결정
- [ ] Thred 참고 화면 기준 시각 QA
- [ ] 사이드바 토글 애니메이션 세부 조정
- [ ] 게시물 카드 실제 데이터 연동
- [ ] 태그 페이지 실제 데이터 연동
- [ ] 고정 페이지 실제 데이터 연동
## 콘텐츠 스타일 구현
- [ ] ProseHeading (h1~h6)
- [ ] ProseList (Ordered/Unordered)
- [ ] ProseBlockquote
- [ ] ProseImage (Regular/Wide/Full-width)
- [ ] ProseButton (Left-aligned/Centered)
- [ ] ProseCallout
- [ ] ProseToggle
- [ ] ProseVideo
- [ ] ProseAudio
- [ ] ProseFile
- [ ] ProseProduct
- [ ] ProseHeaderCard (Simple/Wide/Full-width/Split)
- [ ] ProseEmbed (YouTube, Twitter)
- [ ] ProseHeading 실제 스타일 세부 조정
- [ ] ProseList 실제 스타일 세부 조정
- [ ] ProseBlockquote 실제 스타일 세부 조정
- [ ] ProseImage Regular/Wide/Full-width 동작 검증
- [ ] ProseButton Left/Center 정렬 검증
- [ ] ProseCallout 실제 스타일 세부 조정
- [ ] ProseToggle 실제 스타일 세부 조정
- [ ] ProseVideo 실제 임베드 렌더링 연결
- [ ] ProseAudio 실제 오디오 렌더링 연결
- [ ] ProseFile 실제 파일 데이터 연결
- [ ] ProseProduct 실제 상품 카드 데이터 연결
- [ ] ProseHeaderCard Simple/Wide/Full-width/Split 변형 구현
- [ ] ProseEmbed YouTube, Twitter 실제 렌더링 연결
## 데이터베이스
- [ ] Posts 테이블 설계
- [ ] Pages 테이블 설계
- [ ] Tags 테이블 설계
- [ ] PostTags 테이블 설계
- [ ] 로컬 개발 DB 연결 설정 작성
- [ ] NAS 운영 DB 연결 설정 작성
- [ ] PostgreSQL 마이그레이션 실행 스크립트 작성
- [ ] 로컬 개발 DB 컨테이너 실행 가이드 작성
- [ ] NAS 운영 DB 연결 설정 실제 값 작성
- [ ] 개발 DB와 운영 DB 분리 검증 절차 작성
- [ ] CloudBeaver 등 DB 관리 도구 연결 방식
- [ ] CloudBeaver PostgreSQL 연결 방식
- [ ] 이전에 원격에 올라간 관리자 비밀번호가 실제 사용 값이면 즉시 폐기 및 변경
## 배포
- [ ] UGREEN NAS Docker 배포 가이드 작성
- [ ] 로컬 개발 환경 가이드 작성
- [ ] Dockerfile 작성
- [ ] docker-compose.yml 작성
- [ ] .env.example 작성
- [ ] Docker 빌드 검증
- [ ] `.env.production` 작성 후 `docker compose --env-file .env.production config` 검증
- [ ] NAS 운영 환경 변수 작성

View File

@@ -1,5 +1,53 @@
# 업데이트 이력
## 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 방향 정리.

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>

16
layouts/default.vue Normal file
View File

@@ -0,0 +1,16 @@
<script setup>
const { menuOpen } = useMenuState()
</script>
<template>
<div class="site-shell public-layout">
<SiteHeader />
<div class="site-content-grid public-layout__grid" :class="{ 'site-content-grid--menu-closed': !menuOpen }">
<LeftSidebar v-show="menuOpen" />
<main class="site-main w-full lg:w-[720px]" :class="{ 'site-main--menu-closed': !menuOpen }">
<slot />
</main>
<RightSidebar />
</div>
</div>
</template>

5
layouts/page.vue Normal file
View File

@@ -0,0 +1,5 @@
<template>
<main class="page-layout min-h-screen bg-paper text-ink">
<slot />
</main>
</template>

16
layouts/post.vue Normal file
View File

@@ -0,0 +1,16 @@
<script setup>
const { menuOpen } = useMenuState()
</script>
<template>
<div class="site-shell post-layout">
<SiteHeader />
<div class="site-content-grid post-layout__grid" :class="{ 'site-content-grid--menu-closed': !menuOpen }">
<LeftSidebar v-show="menuOpen" />
<main class="site-main post-main w-full px-5 py-8 lg:w-[720px]" :class="{ 'site-main--menu-closed': !menuOpen }">
<slot />
</main>
<RightSidebar />
</div>
</div>
</template>

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

11659
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.6",
"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",
"postgres": "^3.4.9",
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"zod": "^3.24.2"
}
}

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>

81
pages/index.vue Normal file
View File

@@ -0,0 +1,81 @@
<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 text-center">
<h1 class="home-hero__title mx-auto max-w-[620px] text-3xl font-semibold leading-tight tracking-normal md:text-[28px]">
Ideas <em>published</em> for meaningful conversation, discussed and shaped by the community
</h1>
<p class="home-hero__description mx-auto mt-3 max-w-[500px] text-base leading-7 site-muted">
글을 쌓고, 프로젝트와 링크를 연결하고, 오래 쓰기 좋은 개인 블로그를 직접 구축합니다.
</p>
<form class="home-hero__subscribe mx-auto mt-5 flex max-w-[345px] gap-2">
<input class="home-hero__input min-w-0 flex-1 rounded-lg px-3 py-2 text-sm site-input" placeholder="Your email">
<button class="home-hero__button rounded-lg px-5 py-2 text-sm font-semibold site-button" type="button">
Subscribe
</button>
</form>
</div>
</section>
<section class="home-featured site-section">
<div class="home-featured__header site-section-body flex items-center justify-between">
<h2 class="home-featured__title text-sm font-semibold uppercase site-muted">
Featured
</h2>
<div class="home-featured__controls flex gap-4">
<span></span>
<span></span>
</div>
</div>
<div class="home-featured__items grid grid-cols-1 gap-4 px-6 pb-6 md:grid-cols-3">
<article class="home-featured__card h-[146px] rounded-lg bg-[linear-gradient(135deg,#071b22,#0f827c)] p-4 text-white">
<h3 class="mt-20 text-sm font-semibold leading-tight">
Essential tools and techniques for getting started
</h3>
</article>
<article class="home-featured__card h-[146px] rounded-lg bg-[linear-gradient(135deg,#182434,#d4b06b)] p-4 text-white">
<h3 class="mt-20 text-sm font-semibold leading-tight">
Setting up your first home server from scratch
</h3>
</article>
<article class="home-featured__card h-[146px] rounded-lg bg-[linear-gradient(135deg,#141414,#8a5a44)] p-4 text-white">
<h3 class="mt-20 text-sm font-semibold leading-tight">
Writing notes that stay useful over time
</h3>
</article>
</div>
</section>
<section class="home-latest site-section">
<div class="home-latest__header site-section-body flex items-center justify-between">
<h2 class="home-latest__title text-sm font-semibold uppercase site-muted">
Latest
</h2>
<button class="home-latest__view rounded-lg px-3 py-2 text-sm site-input" type="button">
목록
</button>
</div>
</section>
<PostCard v-for="post in 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 { listPages } from '../repositories/content-repository'
/**
* 공개 고정 페이지 목록 API
* @returns {Array} 고정 페이지 목록
*/
export default defineEventHandler(() => listPages())

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

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

View 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/tags.get.js Normal file
View File

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

View File

@@ -0,0 +1,170 @@
import {
getSamplePageBySlug,
getSamplePages,
getSamplePostBySlug,
getSamplePosts,
getSampleTags
} from '../utils/sample-content'
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,
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
})
/**
* 공개 게시물 목록 조회
* @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'
GROUP BY posts.id
ORDER BY posts.published_at DESC NULLS LAST, posts.created_at DESC
`
return rows.map(mapPostRow)
}
/**
* 공개 게시물 상세 조회
* @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'
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)
}
/**
* 공개 고정 페이지 상세 조회
* @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 name ASC
`
return rows.map(mapTagRow)
}

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

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