92 KiB
sori.studio 기술 명세
프로젝트 개요
- 프로젝트명: sori.studio
- 유형: 커스텀 블로그/CMS
- 목표: 개인 블로그 중심 운영, 기존 포털성 링크와 서비스 진입점은 블로그 내부 구조에 통합
- 참조: Ghost(관리자 UX/글쓰기), Thred 테마(사용자 화면)
- 현재 상태: Nuxt 3.21(SSR)·PostgreSQL 저장소 계층 구성 완료. Node가 SSR 번들의
#internal/nuxt/paths를 해석하도록 루트package.jsonimports와modules/nuxt-ssr-paths-write.mjs(.nuxt/paths.mjs디스크 기록)을 둔다. - 원격 저장소: https://git.sori.studio/zenn/sori.studio.git
- 스타일: Tailwind 엔트리는
assets/css/main.css한 곳(nuxt.config의tailwindcss.cssPath)이며,tailwind.config.js의content가 Vue·composables·modules·plugins를 스캔한다.
화면 구조
메인 화면 (3단 레이아웃)
| 요소 | 크기/속성 |
|---|---|
| Header | 높이 57px, sticky top-0, shrink-0. 내부는 grid-cols-3로 좌(브랜드·메뉴) / 중앙(검색, md+에서만 표시) / 우(사용자 메뉴) 배치해 검색 패널을 가운데 열에 정렬한다. 검색 버튼은 중앙 열 안에서 max-w-[min(470px,100%)]로 폭을 제한한다. |
| Shell | min-height: 100vh, flex 세로 컬럼 |
그리드(데스크톱 lg+) |
items-start, 3열 그리드(287px / minmax(0,1fr) / 287px)를 사용하고 열 간 column-gap은 두지 않는다(gap-x-0). 경계선은 사이드바 보더로만 구분해 이중 패딩처럼 보이는 여백을 방지한다. 긴 본문은 문서(html/body) 스크롤로 처리한다. |
그리드(모바일 lg 미만) |
단일 세로 흐름: 본문 → 오른쪽 사이드 순. 왼쪽 사이드는 레이아웃 흐름에서 분리된 고정 슬라이드 패널로 표시 |
| Left Aside | 너비 287px, sticky top-[57px], h-[calc(100vh-57px)]와 max-h 동일(뷰포트 기준 고정 높이), 내부 상단은 .site-sidebar-scroll(스크롤바 숨김), 하단 푸터 shrink-0·상단 보더로 스크롤 영역과 구분, 푸터 좌우는 px-4~sm:px-5로 본문 블록과 유사한 여백. 푸터의 API footer 링크 영역은 flex-wrap·min-w-0 flex-1로 항목이 많을 때 줄바꿈되어 패널 밖으로 넘치지 않는다 |
| Left Aside(모바일) | fixed 좌측 패널, 열림 시 translate-x-0, 닫힘 시 화면 밖으로 이동. 열린 동안 백드롭과 html.site-mobile-nav-open으로 문서 스크롤 잠금 |
| Main | 중앙 열 안에서 max-width: 720px·justify-self: start, 별도 overflow-y 없음. 공개 페이지의 가로 패딩은 레이아웃 그리드(public-layout__grid)의 px-* 한 번만 사용하고, 본문 섹션의 px-*는 두지 않는다. |
| Right Aside | Left와 동일 패턴(스티키·고정 높이·내부 무스크롤바 스크롤·하단 카피라이트) |
| Right Aside(모바일) | 본문 아래에 전체 너비로 배치, 상단 보더로 본문과 구분, 좌우 안전 여백(px-4) 적용 |
메뉴 토글
- 헤더 좌측 아이콘은 브랜드 마크가 아니라 왼쪽 사이드바 열기/닫기 버튼
- 메뉴 상태는 Nuxt/Vue 상태로 관리
- 브라우저에서는
localStorage.MENU_STATE에open또는closed저장 - 닫힘 상태에서는 왼쪽 사이드바 폭을 0으로 줄이는 전환 애니메이션을 적용
lg미만에서는 왼쪽 사이드바를 화면 좌측 고정 슬라이드 패널로 표시하고, 열린 동안 백드롭을 탭하면closeMenu로 닫는다.Escape키는 통합 검색 모달이 열려 있으면 최우선으로 닫고, 그다음 사용자 드롭다운, 이어서 모바일에서만 좌측 슬라이드 메뉴를 닫는다./키는INPUT·TEXTAREA·SELECT·contenteditable에 포커스가 없고Ctrl/Meta/Alt와 함께 눌리지 않을 때 통합 검색 모달을 연다. 헤더 검색 영역(md+) 클릭으로도 동일하게 연다.- 헤더 우측 사용자 아이콘 버튼은 로그인 상태면 회원 아바타/닉네임과 설정·로그아웃 메뉴를, 비로그인 상태면 Anonymous·Sign up·Sign in 메뉴를 표시한다.
공개 화면 색상
- 라이트/다크 모드는 CSS 변수로 관리
- 기본 배경, 패널, 라인, 텍스트, 보조 텍스트, 입력, 강조 버튼 색상을 분리
- 라이트 모드 기본 배경은
#fcfcfc로 통일하고 패널 구분은 보더로 처리 - 시스템 다크 모드는
prefers-color-scheme: dark기준으로 우선 대응 - 사용자 수동 테마 전환은
html[data-theme]와localStorage.SITE_THEME로 관리한다. 첫 페인트 전lib/site-theme-init.js인라인 스크립트가 테마를 적용해 시스템 다크·저장 라이트 불일치 시 깜빡임을 줄인다. 공개 페이지 로딩 중에는#site-splash에 캐시된 로고 이미지 URL(SITE_BRAND_LOGO_URL, localStorage) 또는 사이트 제목(NUXT_PUBLIC_SITE_TITLE)을 잠깐 표시하고, 앱 마운트 후site-app-ready로 숨긴다.site_settings.logo_text(기본井)는 이미지 로고가 없을 때 헤더·사이드바에 쓰는 짧은 기호이며 localStorage·스플래시와는 별개다. - Thred 참고 화면처럼 사이드바와 본문은 같은 화면 안에서 라인으로 구분한다. 사이드바 자체 배경은 라이트/다크 모두 기본 화면 배경(
--site-bg)과 통일하고, 내부 카드형 요소만 패널 배경을 사용한다.
홈 Featured (인덱스)
- 가로 카드 트랙은
overflow-x-auto와snap-x/snap-mandatory로 슬라이드 느낌을 낸다. - Featured 영역은 추천 글(
isFeatured=true)이 1개 이상 있을 때만 표시한다. - Featured 글에 대표 이미지가 없으면 목록 썸네일과 동일하게 카드 안에 게시물 제목을 표시하는 placeholder를 사용한다.
- 모바일 터치:
touch-pan-x,-webkit-overflow-scrolling: touch,overscroll-x-contain으로 가로 스크롤 우선·부모로의 스크롤 전파 완화. - 헤더의 이전·다음 화살표는
scrollLeft와 최대 스크롤 거리로 양 끝에서disabled처리하며,scroll이벤트와ResizeObserver로 동기화한다.
홈 Latest 피드
- 기본 보기 방식은
compact이며, Default 선택 시에도compact로 복원한다. compact는 썸네일을 포함한 짧은 행 형태,list는 텍스트 중심 목록 형태,cards는 카드 그리드 형태로 표시한다.- Latest 섹션은 게시물이 적어도 보기 방식 선택 메뉴가 아래쪽에서 잘리지 않도록 최소 높이를 둔다.
Post 페이지
- 게시물 화면은 레이아웃 그리드의 좌우 패딩을 기본으로 사용하고, 섹션 단위
px-*는 내부 래퍼로만 두어 패딩이 2중 적용되지 않게 한다. - 댓글 시작 섹션의 구분선(
border-y)은 패딩 없이 전체 폭으로 표시하고, 내용만 내부 래퍼에 패딩을 둔다. - 본문 끝과 댓글 섹션 시작 사이 간격은
48px(mt-12)로 유지한다. - 댓글은 로그인 회원만 작성 가능하며, 대댓글은 1단까지만 허용한다.
- 댓글은 작성자 썸네일(없으면 이니셜)과 좋아요 수를 표시한다.
- 댓글 시간은 24시간 이내일 때 상대 시간(분/시간 전), 이후에는 날짜로 표시한다.
- 댓글 정렬은
Best(좋아요 우선),Latest,Oldest를 제공한다. - 댓글 아바타 이미지 로드 실패 시 이니셜 아바타로 즉시 대체한다.
- 공개 게시물 본문은 콘텐츠 타입별 컴포넌트로 분리해 추후 스타일 변경이 쉽도록 구성
- 제목 우측 공유 버튼을 누르면 게시물 공유 모달을 연다.
- 로그인 회원 ID가 게시물
author_id와 같으면 제목 우측 공유 버튼 옆에 수정 아이콘을 표시하며, 클릭 시/admin/posts/:id편집 화면을 새 탭으로 연다. - 공유 모달은 게시물 썸네일/제목/요약 미리보기, X/Bluesky/Facebook/LinkedIn/Email 링크, 링크 복사 액션을 제공한다.
- 공유·SEO 설명은 SEO 설명이 있으면 우선 사용하고, 없으면 게시물 요약, 요약도 없으면 본문에서 마크다운 기호를 제거한 짧은 텍스트를 사용한다.
- 홈 Latest·게시물 목록·태그 목록의 카드 설명도 동일하게 요약이 비어 있으면 본문에서
createPostSummary로 짧은 텍스트를 만든다. 목록용 설명은 문자열에 수동 말줄임을 붙이지 않고post-summary-clamp전용 클래스가 실제 표시 줄 끝에서 말줄임을 처리한다.
공개 목록·상세의 발행일 표시
- API의 ISO 8601
publishedAt를 공개 UI에서는 로컬 날짜 기준YYYY.MM.DD로 표시한다. - 변환은
composables/formatPostDate.js의formatPostDate를 사용한다. - 관리자 목록·수정일 보조 라벨은
formatPostDateTime(YYYY.MM.DD 오전/오후 HH:MM)을 사용한다. 발행 후 수정 여부는wasPostUpdatedAfterPublish로 판별하며,site_settings.show_post_updated_at이 true일 때만 관리자 글 목록에 「수정: …」를 노출한다. 공개 게시글 상세에는 수정 시각을 표시하지 않는다. <time>에는 표시용 문자열과 함께 가능한 경우 원본 시각을datetime속성으로 둔다.
Page 페이지
- About, Projects, Links, Contact, 서비스 소개 페이지 등 고정 콘텐츠에 사용
- 기본 게시물 목록에는 노출하지 않음
- 헤더와 사이드바를 사용하지 않고 본문 중심 전체 화면으로 표시
- 페이지는
renderMode로 렌더링 방식을 구분한다. 기본값은html_document이며 관리자에서 붙여넣은 전체 HTML 문서를 공개/pages/:slug요청에서text/html원문으로 응답한다.markdown은 관리자 UI에서일반 텍스트로 표시하며 기존 Markdown 콘텐츠 렌더러를 사용한다. - HTML 문서 모드는 관리자만 저장하는 신뢰 콘텐츠를 전제로 하며,
<head>,<style>,<body>를 포함한 단일 랜딩 페이지 용도로 사용한다. - 진입 경로는 추후 메뉴/링크 설정을 통해 연결
공개 URL 구조
/posts- 게시물 전체 목록/post/:slug- 개별 게시물 상세/tags- 태그 전체 목록/tag/:slug- 태그별 게시물 목록/tag/:slug화면의 헤더와 게시물 목록은 공개 목록 공통 섹션 패딩(site-section-header,site-section-body)을 사용해 메인 컬럼 좌우 여백을 다른 목록 화면과 맞춘다./signup- 회원가입(3단계: 환영/입력/이메일 확인)/signin- 로그인/settings- 회원 설정(썸네일 미리보기/변경/제거, 닉네임, 비밀번호, 회원 탈퇴)- 기존
/posts/:slug,/tags/:slug상세 경로는 새 단수형 상세 경로로 리다이렉트한다.
공개 인증 화면(초기)
- 회원가입 화면은 AFFiNE 참고 다크 테마 3단계 플로우를 제공한다.
- 3단계는 인증 메일 발송 안내와 재전송 버튼(쿨다운)을 제공한다.
- 이메일 링크 확인 전에는 회원가입이 완료되지 않으며, 인증 완료 액션 이후 로그인 화면으로 이동한다.
- 로그인 화면은 동일한 다크 톤 폼 레이아웃을 사용한다.
- 관리자 로그인 화면도 같은 다크 톤 폼 레이아웃을 사용하되, 일반 로그인과 구분되도록 폼을 화면 오른쪽에 배치하고 내부 타이틀·설명·필드·버튼도 오른쪽 정렬한다.
- 로그인·회원가입(2단계)·관리자 로그인 비밀번호 입력은
AuthPasswordVisibilityToggleSVG(눈 열림/가림) 토글로 표시 여부를 바꾼다. 스크린 리더용aria-label은 필드별field-name으로 구분한다. 텍스트 입력은.auth-form-input으로 글자색·캐럿 등을 보정한다. - 인증 화면 상태 메시지는 오류/안내를 분리해
aria-live로 노출한다. - 회원가입 1단계의 타이틀/설명은
GET /api/site-settings의title,description값을 우선 사용한다. - 회원 세션 쿠키 서명에는
MEMBER_SESSION_SECRET만 사용하며, 관리자 비밀번호를 fallback으로 쓰지 않는다.
레이아웃 파일
layouts/
├── default.vue # 메인/목록 화면
├── post.vue # 게시물 화면
└── admin.vue # 관리자 화면
관리자 레이아웃
- 관리자 사이드바는 밝은 Ghost형 톤을 기준으로 하며, 메뉴 행은 아이콘+라벨 구조를 사용한다.
- 관리자 로그인(
/admin/login)을 제외한 관리자 화면은 공개 사이트 라이트/다크 테마와 분리된 라이트 UI로 고정한다.admin-layout스코프에서 공개 테마 CSS 변수를 재정의하고, 글쓰기 화면을 제외한 일반 관리자 화면에만 폼 컨트롤color-scheme을 라이트 값으로 재정의해 사용자 페이지 다크모드가 관리자 입력·테이블·패널 색상에 영향을 주지 않게 한다. - 관리자 사이드바는 데스크톱에서 320px 너비를 사용한다.
- 관리자 우측 캔버스는 기본
min-h-screen과bg-paper를 유지해 콘텐츠 높이가 짧아도 배경이 화면 전체를 채운다. 일반 관리자 화면은 레이아웃 레벨에서 넉넉한 외부 여백을 둔다. - 대시보드 메뉴는 관리자 기본 페이지(
/admin)로 이동하는 활성 링크로 표시한다. - 게시글 메뉴 라벨은
게시글로 표시하고, 우측+아이콘은/admin/posts/new로 바로 이동한다. - 관리자 글 목록의 태그 컬럼은 게시물 태그 배열의 첫 번째 항목만 대표 태그로 표시하며, 배지는 태그 고유 색상을 반영한다.
- 관리자 글쓰기의 Tags 입력은 배지형 다중 입력을 유지하며, 오른쪽 트리거로 메인 태그를 드롭다운에서 추가할 수 있다. 입력 중에는 기존 태그 이름·슬러그 부분 일치 결과를 추천하고, 방향키와 Enter로 선택할 수 있다. 선택된 태그 배지는 태그 고유 색상을 반영한다.
- 메뉴 관리 항목은
네비게이션으로 표시한다. - 게시글·페이지·태그·미디어·네비게이션·멤버·설정 메뉴는 전용 SVG 아이콘을 사용한다.
- 멤버 메뉴는 우측에 현재 총 멤버 수를 표시한다.
- 로그아웃은 사이드바 상단 메뉴가 아니라 하단 사용자 썸네일 드롭다운 안에서 제공한다. 하단에는 사용자 썸네일 트리거와 설정 아이콘을 둔다.
컴포넌트 구조
사이트 컴포넌트
components/site/
├── SiteHeader.vue # 상단 헤더
├── SiteSearchModal.vue # 통합 검색 모달(`/`·헤더 검색 영역, Tags·Posts 결과)
├── LeftSidebar.vue # 왼쪽 사이드바
├── RightSidebar.vue # 오른쪽 사이드바
├── MainColumn.vue # 메인 컬럼
├── PostCard.vue # 게시물 카드
└── TagHeader.vue # 태그 헤더
콘텐츠 렌더러
components/content/
├── ContentRenderer.vue # 콘텐츠 렌더러
├── ProseHeading.vue # h1~h6
├── ProseImage.vue # 이미지 (Regular/Wide/Full-width)
├── ProseList.vue # Ordered/Unordered List
├── ProseBlockquote.vue # 인용구
├── ProseButton.vue # 버튼 (Left-aligned/Centered)
├── ProseCallout.vue # Callout 카드
├── ProseToggle.vue # Toggle 카드
├── ProseVideo.vue # Video 카드
├── ProseAudio.vue # Audio 카드
├── ProseFile.vue # File 카드
├── ProseProduct.vue # Product 카드
├── ProseBookmark.vue # 북마크 카드(썸네일·제목·도메인)
├── ProseSignup.vue # 회원가입/뉴스레터 CTA 카드
├── ProseHeaderCard.vue # Header 카드 (Simple/Wide/Full-width/Split)
└── ProseEmbed.vue # Embeds (YouTube iframe, Twitter/X iframe, 기타 링크)
공개 본문 스타일 가이드(Thred 기준)
- 리스트
- Unordered:
- 항목 - Ordered:
1. 항목 - 렌더링:
ProseList.vue(마커 컬러는 글쓰기 화면과 같은 파란 계열, 간격, 줄높이 통일)
- Unordered:
- 인용구
- 기본:
> 한 줄또는>연속 여러 줄(멀티라인) - 대체 스타일(Alternative):
>>>로 시작해<<<로 끝나는 블록 - 렌더링:
ProseBlockquote.vue(variant=default|alt, 기본 인용은 다크모드에서도 밝은 배경 위 어두운 텍스트 유지)
- 기본:
- 이미지
- 기본:
— 이미지 아래 캡션 없음 - 캡션(표시용):
— 따옴표 안 문자열만ProseImagefigcaption으로 표시 - 파일명을 캡션으로 사용 토글: URL 파일명을 캡션으로 저장·표시(
). 레거시도 동일하게 해석 - 와이드/풀:
{width=wide|full}또는 캡션·width 조합 - 렌더링:
ProseImage.vue(라운드/보더/패널 배경)
- 기본:
- 이미지 갤러리
:::gallery~:::fenced block 내부에 이미지 마크다운 행을 여러 개 작성- 렌더링:
ContentMarkdownRenderer.vue(최대 3개 단위 행 + 라이트박스, Esc 닫기·←/→ 이전·다음) - 갤러리 행은 1개일 때 전체 폭, 2~3개일 때 행 전체 폭을 나눠 쓰며 이미지 로드 후 자연 비율(가로/세로)에 따라 셀 너비를 조정한다.
- 비디오·오디오·파일 카드
- 비디오:
:::video~:::(url,title,poster,caption키값 또는 URL 단독 줄) - 오디오:
:::audio~:::(url,title,description) - 파일:
:::file~:::(url,title,description,name,size) — 다운로드 링크 카드 - 렌더링:
ProseVideo.vue,ProseAudio.vue,ProseFile.vue - 관리자 슬래시:
/video,/audio,/file로 빈 템플릿 삽입 후 URL·메타 수정
- 비디오:
- 관리자 미디어 화면은 미디어 라이브러리 탭에서 전체·이미지·영상·음악·파일 종류 필터와 미사용 필터를 제공한다. 미사용은 게시물·페이지·사이트 설정·회원 프로필에서 참조되지 않는 항목을 의미한다. 비디오 항목은 브라우저에서 초반 프레임을 캔버스로 추출해 목록 썸네일로 표시하고, 추출 실패 시
videoplaceholder를 유지한다. - 문단과 줄바꿈
- 관리자 Markdown-first 에디터에서 Enter는 새 문단(마크다운 한 줄)만 사용한다. Shift+Enter·문단 내 hard break는 지원하지 않는다.
- 공개 본문 렌더러는 마크다운 한 줄을 한 문단으로 렌더링한다(레거시 줄끝
\\/공백 2개 표식은 표시 시 제거). - 내용 없는 빈 줄과 레거시 빈 문단 마커(
<!--sori:blank-paragraph-->)는 spacer 블록으로 렌더링해 작성자가 비운 줄 수만큼 공백을 보존한다. - 문단 하단 기본 간격은 10px(
mb-2.5) 기준이며, 문단 글자 크기는ContentMarkdownRenderer문단에text-base(16px·1rem)만 지정하고 행간은 Tailwind·브라우저 기본에 맡긴다. - 제목은
ProseHeading에서 단계별 크기·굵기를 적용하고, 첫 제목(first:)을 제외한 상단 추가 여백은 컴포넌트 스타일로만 조정한다.
- 카드류
- Callout:
:::callout~:::(왼쪽 강조선은var(--site-accent)) - Toggle:
:::toggle 제목~::: - Bookmark:
:::bookmark~:::(본문은url=,title=,description=,thumbnail=키값 또는 첫 줄 URL·이어지는 제목/설명 줄) - Signup:
:::signup~:::(선택:title=,description=,button=,placeholder=) - Embed: 단독
http(s)URL 한 줄(기존:::embed~:::도 렌더링 호환). YouTube·YouTube Shorts URL은 iframe,twitter.com·x.com게시물 URL은 X 공식 embed iframe, 그 외는 외부 링크 카드 - 렌더링:
ProseCallout.vue,ProseToggle.vue,ProseBookmark.vue,ProseSignup.vue,ProseEmbed.vue
- Callout:
데이터베이스 구조
환경 분리 원칙
- 데이터베이스는 PostgreSQL을 기준으로 한다.
- 로컬 개발 환경과 NAS 운영 환경은 서로 다른 DB를 사용
- 로컬 개발 서버는 개발 DB만 연결
- NAS 배포 환경은 운영 DB만 연결
- 운영 환경(
NODE_ENV=production)에서는DATABASE_URL누락 시 샘플 콘텐츠로 대체하지 않고 서버 오류로 즉시 실패 - Docker Compose는 전용 브리지 네트워크를 사용하며 기본 subnet은
DOCKER_SUBNET(10.250.50.0/24)으로 관리 - 운영 DB 접속 정보는 로컬 기본
.env에 기록하지 않음 - DB 관리 도구는 CloudBeaver 등을 사용할 수 있도록 접속 정보를 환경별로 분리
마이그레이션 적용 이력
schema_migrations테이블은 적용 완료된 SQL 파일명을file_name기준으로 기록한다.npm run db:migrate:dev와npm run db:migrate:prod는db/migrations/*.sql중schema_migrations에 없는 파일만 순서대로 실행한다.sh scripts/migrate-production-db.sh status는 npm이 없는 NAS 호스트에서도 운영 DB의 적용/대기 파일 목록을 출력한다.- 기존 운영 DB에
posts테이블은 있지만schema_migrations가 없으면sh scripts/migrate-production-db.sh migrate는 데이터 보호를 위해 001부터 자동 실행하지 않고 중단한다. - 기존 운영 DB가 현재 코드 기준으로 이미 최신이면
sh scripts/migrate-production-db.sh baseline으로 현재 마이그레이션 파일들을 실행 없이 적용 완료로 기록한 뒤 이후 새 파일만 적용한다.
Posts (블로그 글)
| 필드 | 타입 | 설명 |
|---|---|---|
| id | UUID | Primary Key |
| title | String | 제목 |
| slug | String | URL 슬러그 |
| content | Text | Markdown 콘텐츠 |
| excerpt | String | 요약 |
| featured_image | String nullable | 대표 이미지 |
| is_featured | Boolean | 홈 Featured 및 목록 번개 표시용 추천 글 여부 |
| seo_title | String | SEO 제목 |
| seo_description | String | SEO 설명 |
| canonical_url | String | canonical URL |
| noindex | Boolean | 검색엔진 노출 제외 여부 |
| og_image | String nullable | OG 이미지 |
| status | Enum | published / draft / members / private(예약은 published + 미래 published_at) |
| published_at | DateTime | 발행일 |
| created_at | DateTime | 생성일 |
| updated_at | DateTime | 수정일 |
API 응답의 게시물 객체는
isFeatured와commentCount를 함께 반환한다.commentCount는published상태 댓글 수를 기준으로 한다. 공개 게시물 목록·상세는published상태만 기본 노출하며,members상태는 VIP 이상 등급(vip/admin/owner) 회원에게만 노출한다.private와draft는 공개 화면에서 노출하지 않는다.
Users
| 필드 | 타입 | 설명 |
|---|---|---|
| id | UUID | Primary Key |
| username | String | 사용자명 |
| String | 로그인 이메일(유니크) | |
| password_hash | String | bcrypt 해시 비밀번호 |
| avatar_url | String | 프로필 썸네일 URL |
| is_admin | Boolean | 관리자 권한 여부 |
| user_role | Enum | 권한 단계(owner/admin/vip/member) |
| last_seen_at | DateTime nullable | 마지막 접속 시각 |
| last_seen_ip | String | 마지막 접속 IP |
| created_at | DateTime | 생성일 |
| updated_at | DateTime | 수정일 |
Comments
| 필드 | 타입 | 설명 |
|---|---|---|
| id | UUID | Primary Key |
| post_id | UUID | FK → Posts |
| user_id | UUID | FK → Users |
| parent_id | UUID nullable | FK → Comments, 대댓글 1단 |
| body | Text | 댓글 본문 |
| status | Enum | published/pending/blocked |
| created_at | DateTime | 생성일 |
| updated_at | DateTime | 수정일 |
CommentLikes
| 필드 | 타입 | 설명 |
|---|---|---|
| comment_id | UUID | FK → Comments |
| user_id | UUID | FK → Users |
| created_at | DateTime | 생성일 |
Pages (고정 페이지)
| 필드 | 타입 | 설명 |
|---|---|---|
| id | UUID | Primary Key |
| title | String | 제목 |
| slug | String | URL 슬러그 |
| content | Text | HTML 문서 원문 또는 일반 텍스트 콘텐츠 |
| render_mode | String | 렌더링 방식(html_document, markdown) |
| featured_image | String nullable | 레거시 컬럼, 관리자 페이지 작성 UI에서는 사용하지 않음 |
| status | Enum | published / draft / private |
| created_at | DateTime | 생성일 |
| updated_at | DateTime | 수정일 |
Tags
| 필드 | 타입 | 설명 |
|---|---|---|
| id | UUID | Primary Key |
| name | String | 태그명 |
| slug | String | URL 슬러그 |
| description | String | 설명 |
| sort_order | Integer | 메인 태그 정렬 순서 |
| color | String | 태그 색상 코드 |
| tag_type | Enum | 태그 유형(managed/general) |
| created_at | DateTime | 생성일 |
| updated_at | DateTime | 수정일 |
SiteSettings
| 필드 | 타입 | 설명 |
|---|---|---|
| id | Integer | 단일 설정 레코드 ID, 항상 1 |
| title | String | 사이트 이름 |
| description | String | 사이트 설명 |
| site_url | String | 사이트 기본 URL |
| logo_text | String | 레거시 텍스트 로고 fallback |
| logo_url | String | 공개 로고 이미지 URL |
| favicon_url | String | 파비콘 이미지 URL |
| home_cover_dark_image_url | String | 다크모드 홈 커버 이미지 URL |
| copyright_text | String | 저작권 문구 |
| updated_at | DateTime | 수정일 |
NavigationItems
| 필드 | 타입 | 설명 |
|---|---|---|
| id | UUID | Primary Key |
| label | String | 메뉴 표시 이름 |
| url | String | 내부 경로 또는 외부 URL |
| location | Enum | primary / footer / recommended |
| sort_order | Integer | 표시 순서 |
| is_visible | Boolean | 공개 화면 표시 여부 |
| created_at | DateTime | 생성일 |
| updated_at | DateTime | 수정일 |
MediaMetadata
| 필드 | 타입 | 설명 |
|---|---|---|
| url | String | 업로드 미디어 URL |
| category | String | 논리 폴더 경로(게시물 업로드 이미지는 미분류, 회원 아바타는 예약값 썸네일 등) |
| created_at | DateTime | 생성일 |
| updated_at | DateTime | 수정일 |
MediaFolders
| 필드 | 타입 | 설명 |
|---|---|---|
| path | String | 미디어 폴더 경로 |
| created_at | DateTime | 생성일 |
| updated_at | DateTime | 수정일 |
PostTags (다대다)
| 필드 | 타입 | 설명 |
|---|---|---|
| post_id | UUID | FK → Posts |
| tag_id | UUID | FK → Tags |
| created_at | DateTime | 생성일 |
Analytics (자체 최소 통계)
마이그레이션
030_analytics_daily_stats.sql. 원문 IP·User-Agent·쿠키 ID는 저장하지 않는다. 서버는date + IP + User-Agent + secret으로 일 단위visitor_hash만 생성·저장한다.
| 테이블 | 필드 | 설명 |
|---|---|---|
| site_analytics_daily | day, page_views, visitors, engaged_views, total_engaged_seconds | 사이트 일별 페이지뷰·방문자·체류 집계 |
| post_analytics_daily | day, post_id, views, reads, visitors, engaged_views, total_engaged_seconds, scroll_25~100 | 게시물 일별 조회·읽음·스크롤 구간 |
| page_analytics_daily | day, page_id, views, visitors, engaged_views, total_engaged_seconds, scroll_25~100 | 페이지 일별 조회·방문자·스크롤 구간 |
| analytics_daily_visitors | day, scope(site/post/page), post_id?, page_id?, visitor_hash |
일별 방문자 해시 등록(중복 방문 제거용) |
| analytics_active_sessions | session_hash, user_id?, path, post_id?, post_slug, page_id?, page_slug, duration_seconds, max_scroll_ratio, last_seen_at | 실시간 접속 세션(TTL 90초) |
- 추적 대상: 공개 경로만.
/admin,/signin,/signup,/forgot-password,/settings는 제외. - 봇 User-Agent는 서버에서 무시.
- 게시물
reads는 클라이언트에서 15초 이상 체류·50% 이상 스크롤 후 별도 전송. POST /api/analytics/heartbeat는 20초 간격으로 체류시간·스크롤·현재 경로를 전송한다. 로그인 사용자는 서버 세션으로user_id를 연결한다.- 관리자 대시보드는
GET /admin/api/analytics/realtime으로 현재 접속자 목록(닉네임·아바타·게시물 제목·접속 유지시간)을 조회한다. - 관리자 대시보드 통계 추이는
trends데이터를 3개 막대 차트(방문자수·평균 체류시간·50% 스크롤 도달)로 표시한다. 7일은 일자별로 표시하고, 30일 이상은 선택 기간에 따라 7일·14일·30일 단위로 묶어 카드 폭을 넘지 않게 한다. 막대 hover/focus 시 기간과 정확한 값을 툴팁으로 표시하며, 표(table)나 외부 차트 라이브러리는 사용하지 않는다. - 관리자 차트는 최대 365일 범위를 조회한다.
site_analytics_daily,post_analytics_daily는 사이트 전체 방문자와 게시물별 조회수 누적 원본이므로 자동 삭제하지 않는다.analytics_daily_visitors는 일별 중복 방문 제거용이며, 수집·조회 흐름에서 32일보다 오래된 행을 주기적으로 삭제한다.analytics_active_sessions는 현재 접속자 목록용이며, 90초보다 오래된 행을 삭제한다.
API 구조
현재 API는 Nuxt
server/api내부에 샘플 데이터 기반으로 구현되어 있다. DB 연결 후 같은 응답 구조를 유지하되 저장소만 교체한다.
백엔드 구성
- 별도
backend/앱을 두지 않고 Nuxt/Nitro 서버 기능을 사용 - 공개 API는
server/api에 작성 - 서버 공통 스키마와 샘플 데이터는
server/utils에 작성 - PostgreSQL 연결과 조회 로직은
server/repositories에 작성 DATABASE_URL이 없으면 샘플 데이터 저장소를 사용- 초기 단계에서는 같은 앱 배포로 관리 비용을 낮춤
- 독립 API 서버가 필요해지는 시점에만 백엔드 분리를 재검토
공개 API (/api/)
GET /api/posts- 게시물 목록GET /api/posts/:slug- 게시물 상세GET /api/posts/:slug/comments- 게시물 댓글 목록POST /api/posts/:slug/comments- 게시물 댓글 작성(회원 세션 필요, 대댓글 1단)POST /api/posts/:slug/comments/:commentId/like- 댓글 좋아요 토글(회원 세션 필요)GET /api/pages- 고정 페이지 목록GET /api/pages/:slug- 고정 페이지 상세GET /api/tags- 태그 목록GET /api/search?q=- 통합 검색(태그name·slug, 게시물title·excerpt·content부분 일치, 각 최대 12건, 발행 게시물만)GET /api/site-settings- 공개 사이트 설정(어나운스 바·홈 커버 등 포함)POST /api/analytics/pageview- 공개 방문·게시물·페이지 조회/읽음 집계. 본문:path(필수),postSlug(게시물일 때),pageSlug(페이지일 때),read(읽음 이벤트). 발행된 게시물과 공개 페이지만 개별 집계한다. 응답{ ok: true }. HTML 문서 모드 페이지는 Nuxt 클라이언트 플러그인을 거치지 않으므로 서버 미들웨어가 GET 요청 시 페이지 조회를 직접 기록한다.POST /api/analytics/heartbeat- 실시간 세션·체류·스크롤 집계. 본문:path,postSlug,pageSlug,clientSessionId,durationSeconds(최대 1800),maxScrollRatio(0~1). 로그인 시 서버가 회원 세션으로 사용자 연결.GET /api/navigation- 공개 네비게이션(primary는 트리·footer·recommended는 평면, 상세는 위 메뉴/네비게이션 절)POST /api/auth/signup- 회원 가입. 본문:username,email,password, 선택emailOtp(6자리 숫자).GET /api/auth/bootstrap-status의emailOtpConfigured가 true이고needsAdminSetup이 false일 때(서버에 Resend·pepper 설정됨)emailOtp가 필수이며, 직전에POST /api/auth/email-otp/request로 같은 이메일·purpose: "signup"발송분과 일치해야 한다. 최초 관리자 부트스트랩(needsAdminSetup: true)에서는emailOtp없이 가입 가능. 이메일은 저장 시 소문자로 정규화한다.GET /api/auth/bootstrap-status- 최초 관리자 등록 필요 여부(hasUsers,needsAdminSetup) 및 이메일 OTP 사용 가능 여부emailOtpConfigured(서버RESEND_API_KEY·RESEND_FROM_EMAIL및 pepper: 비어 있지 않은EMAIL_OTP_PEPPER또는MEMBER_SESSION_SECRET)를 반환한다. pepper는 OTP 6자리를 DB에 해시해 둘 때만 쓰이는 서버 비밀(긴 난문자열 권장)이다.POST /api/auth/email-otp/request- 본문:email,purpose("signup"|"password_reset"). Resend로 6자리 OTP 메일을 보낸다. 미설정 시 503.signup은 이미 가입된 이메일이면 409, 최초 관리자 단계에서는 400.password_reset은 존재하지 않는 이메일도 동일한 성공 메시지를 반환하며(이메일 미발송), 요청 빈도 제한을 위해 DB에 더미 챌린지를 남길 수 있다. 55초 쿨다운·시간당 5회 한도. 실제 메일 발송이 실패하면 방금 생성한 챌린지는 즉시 삭제한다. 발송 성공 후 같은 이메일·용도의 이전 pending 챌린지는 무효화한다. DB 테이블email_otp_challenges(마이그레이션018_email_otp_challenges.sql) 필요.POST /api/auth/password-reset/confirm- 본문:email,code(6자리),newPassword(8~32자).password_resetOTP 검증·소진 후 해당 이메일 회원 비밀번호를 갱신한다.POST /api/auth/login- 회원 로그인GET /api/auth/me- 현재 회원 세션 조회(id,username,email,avatarUrl,isAdmin,role)POST /api/auth/logout- 회원 로그아웃GET /api/auth/profile- 회원 설정 조회PUT /api/auth/profile- 회원 프로필 수정(닉네임,avatarUrl). 이전 값이/uploads/members/avatars/URL이고 새 값과 달라지면removeManagedAvatarAsset으로 메타만 끊고 디스크 파일은 유지한다(DELETE /api/auth/avatar와 동일한 자산 정리 규칙).POST /api/auth/avatar- 회원 썸네일 이미지 업로드DELETE /api/auth/avatar- 회원 썸네일 제거GET /api/auth/check-username?username=- 닉네임 중복 확인PUT /api/auth/password- 회원 비밀번호 변경DELETE /api/auth/account- 회원 탈퇴. 마지막owner계정은 삭제할 수 없으며, 탈퇴 성공 시 회원 세션과 관리자 세션 쿠키를 함께 정리한다.
회원 썸네일 이미지는
/uploads/members/avatars/YYYY/MM경로로 저장하며, 업로드 시 WebP로 변환하고AVATAR_MIN_WIDTH/AVATAR_MIN_HEIGHT최소 해상도 검증 후 중앙 기준 1:1 정사각형으로 크롭한다. 최종 저장 크기는AVATAR_MAX_WIDTH,AVATAR_MAX_HEIGHT중 작은 값을 사용해N x N정사각형으로 맞춘다.AVATAR_WEBP_QUALITY는 1~100 범위로 보정하며, 최대 해상도 설정이 최소 해상도보다 작으면 서버에서 최소값 이상으로 자동 보정한다. 회원 썸네일을 새로 업로드하거나 제거·탈퇴할 때, 이전 썸네일 URL에 대한media_metadata행은removeManagedAvatarAsset으로 제거하되 디스크 파일은 삭제하지 않는다. 관리자 미디어 썸네일 탭에서 미사용 파일을 확인·삭제한다. 관리자 미디어 화면의 썸네일 탭에서만 회원 썸네일을 목록·검색하며,GET /admin/api/media응답에avatarOwner(닉네임·이메일·마지막 접속·IP)가 포함될 수 있다.
업로드 파일 제공
GET /uploads/**- 런타임 업로드 파일 제공. 운영 Docker에서는 빌드 산출물.output/public이 아니라public/uploads볼륨의 실제 파일을 읽어 로고·게시물 이미지·회원 썸네일을 즉시 제공한다.
관리자 API (/admin/api/)
POST /admin/api/auth/login- 로그인POST /admin/api/auth/logout- 로그아웃GET /admin/api/auth/me- 현재 관리자 세션 조회GET /admin/api/analytics/summary?days=30- 통계 요약(오늘/7일 방문, 30일 조회, 현재 접속자, 평균 체류, 50% 스크롤 도달, 일자별trends).days는 대시보드에서 7/30/90/180/365로 전환한다.GET /admin/api/analytics/posts?days=30&limit=5- 기간 내 인기 게시물(조회·읽음·평균 체류·스크롤 구간)GET /admin/api/analytics/pages?days=30&limit=5- 기간 내 인기 페이지(조회·방문자·평균 체류·스크롤 구간)GET /admin/api/analytics/realtime?limit=20- 현재 접속자 요약·목록(로그인 사용자 닉네임·아바타 포함)GET /admin/api/posts- 글 목록POST /admin/api/posts- 글 작성GET /admin/api/posts/:id- 글 상세PUT /admin/api/posts/:id- 글 수정DELETE /admin/api/posts/:id- 글 삭제GET /admin/api/pages- 고정 페이지 목록POST /admin/api/pages- 고정 페이지 작성GET /admin/api/pages/:id- 고정 페이지 상세PUT /admin/api/pages/:id- 고정 페이지 수정DELETE /admin/api/pages/:id- 고정 페이지 삭제GET /admin/api/media- 업로드 미디어 목록(게시물용 이미지와 회원 아바타 포함; 회원 아바타에는avatarOwner요약이 붙을 수 있음)PUT /admin/api/media- 업로드 미디어 파일명 변경 또는 단일/복수 미디어 폴더 변경(회원 아바타 URL은 논리 폴더썸네일만 허용, 일반 미디어를썸네일로 옮기는 것은 거부; 썸네일 파일명 변경은users.avatar_url이 해당 URL을 참조할 때만 거부)DELETE /admin/api/media- 업로드 미디어 삭제(게시물·페이지에서 사용 중이면 거부;/members/avatars/URL은users.avatar_url이 해당 URL을 참조할 때만 거부)GET /admin/api/media-folders- 미디어 폴더 목록POST /admin/api/media-folders- 미디어 폴더 생성DELETE /admin/api/media-folders- 미디어 폴더 삭제(해당 경로·하위 경로로 분류된media_metadata는미분류로 되돌림)POST /admin/api/uploads- 관리자 게시물·페이지용 이미지 업로드(저장 파일명은 원본명 기반·동일 월 폴더 내 충돌 시이름-2등 넘버링,/uploads/posts/YYYY/MM저장)POST /admin/api/member-avatar- 관리자 새 회원 생성 전 썸네일 사전 업로드(/uploads/members/avatars/YYYY/MM, WebP 변환·중앙 1:1 크롭)GET /admin/api/tags- 태그 목록(옵션:tagType,q,limit)POST /admin/api/tags- 태그 생성GET /admin/api/tags/:id- 태그 상세PUT /admin/api/tags/:id- 태그 수정DELETE /admin/api/tags/:id- 태그 삭제PUT /admin/api/tags/reorder- 메인 태그 순서 일괄 저장GET /admin/api/settings- 사이트 설정 조회(showPostUpdatedAt, 라이트·다크 홈 커버, 어나운스 바 필드 포함)PUT /admin/api/settings- 사이트 설정 수정(showPostUpdatedAt, 라이트·다크 홈 커버, 어나운스 바,signupBlockedUsernames포함).announcementEnabled가 true이면announcementText필수. 배경색은#15171a·#ffffff·#ff4f2e만 허용.POST /admin/api/settings/home-cover- 메인 화면 커버 이미지 파일만 업로드(720px WebP,{ homeCoverImageUrl }반환). 라이트·다크 어느 슬롯에 반영할지는 클라이언트 폼에서 결정하며,site_settings반영은PUT저장 시 함께 처리한다.POST /admin/api/settings/logo- 로고·파비콘 파일만 업로드({ logoUrl, faviconUrl }반환).site_settings반영은 기타 설정 저장 시PUT으로 처리한다.GET /api/site-settings- 공개 사이트 설정 조회(showPostUpdatedAt, 라이트·다크 홈 커버·어나운스 바 필드 포함)GET /admin/api/navigation- 네비게이션 항목 목록PUT /admin/api/navigation- 네비게이션 항목 일괄 저장GET /admin/api/members- 회원 목록(권한 코드, 최근 접속, 접속 IP, 댓글 수 포함)POST /admin/api/members- 관리자 회원 생성. 본문:username,email, 선택avatarUrl,labels,note. 생성된 회원은member권한이며 초기 비밀번호는 임의 해시로 저장한다.GET /admin/api/members/:id- 관리자 회원 상세(썸네일, 이름, 이메일, 레이블, 관리자 노트, 활동 요약 포함)PUT /admin/api/members/:id- 관리자 회원 기본 정보 수정. 본문:username,email, 선택avatarUrl,labels,note. 이전 값이 회원 전용 썸네일 URL이고 새 값과 달라지면media_metadata연결을 분리한다.POST /admin/api/members/:id/avatar- 관리자 회원 썸네일 업로드 및 즉시 반영(/uploads/members/avatars/YYYY/MM, WebP 변환·중앙 1:1 크롭)PUT /admin/api/members/:id/role- 회원 권한 변경(owner/admin/vip/member)
글 발행/초안 전환은
PUT /admin/api/posts/:id의status와published_at으로 처리한다. 예약 글은 별도 enum이 아니라published와 미래 시각의published_at조합이다. 게시물 상태는draft,published,members,private를 사용한다.members는 VIP 이상 등급 회원에게만 공개한다. 로그인은 댓글 작성을 위한 기본 회원 기능이며, 멤버십 글 공개 기준으로 사용하지 않는다.private는 관리자 편집 화면에서는 보이지만 공개 사용자 화면에서는 노출하지 않는다. 관리자 글 목록 맨 오른쪽 관리 열은 more vert(⋮) 버튼으로 행 메뉴를 연다. 메뉴에서 게시글 추천·추천 제거·게시글 삭제를 선택한다(사이드바 사용자 메뉴와 같은 팝오버 스타일). 관리자 글 목록 상단은 좌측에 제목·총 N개(추천 M개·필터 시 표시 K개) 요약, 우측에 상태·태그·추천(전체/추천만)·정렬 필터와 «새 글» 버튼을 한 줄(좁은 화면에서는 줄바꿈)로 두며 필터는 «새 글» 바로 왼쪽에 배치한다. 관리자 글 목록 표 첫 열은is_featured(추천 글)일 때만 금색 별 아이콘을 표시한다. 추천 여부는 글 편집 설정의 «추천 글» 토글로 지정한다. 관리자 글 목록의 날짜 열은 발행일(published_at, 시·분 포함)이며,showPostUpdatedAt이 true이고 발행 후 수정이 있으면 아래에수정: …보조 줄을 표시한다. 관리자 글 목록 기본 정렬(최신순·오래된순)은 발행일 기준이며,published_at이 없는 초안 등은 수정일(updated_at)로 대체한다. API(listAdminPosts)와 화면 필터 정렬 모두 동일 규칙을 쓴다. 태그 삭제 시post_tags연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다. 공개GET /api/tags는managed(메인 태그)만 반환한다. 관리자 태그 목록 응답은 각 태그의postCount,lastUsedAt,updatedAt을 포함한다. 관리자 태그 목록은managed우선,sort_order ASC, 최근 사용/수정 DESC, name ASC기준으로 정렬한다. 메인 태그 순서 저장은 드래그 드롭 직후 자동으로 실행되며, 드래그 순서를 받아sort_order를 순차 값으로 다시 저장한다. 메인 태그 순서 저장 중에는 추가 드래그를 잠시 막고 저장 상태를 표시한다. 관리자 태그 추가 화면에서 직접 생성한 태그는 기본적으로general(일반 태그)로 생성하고, 태그 관리 화면의 일반 태그 목록에 바로 표시한다. 게시물 작성에서 새로 생기는 태그는 기본적으로general(일반 태그)로 생성한다. 메인 태그는 목록에서일반 태그로 변경액션으로 강등하며, 일반 태그는 배지형 전체 목록에서 확인·필터·최근 사용순·많이 사용순·이름순 정렬·메인 전환·삭제를 수행한다. 태그 관리 화면에서 순서 저장·메인 전환·강등·삭제 등의 성공·실패 피드백은 우측 상단 토스트로 표시한다. 태그color는#RRGGBB형식이며 사용자 화면 태그 색상 표시와 배지 배경색에 사용한다.
관리자 글 편집
- 글 작성/수정 화면은 Markdown-first 에디터(
AdminMarkdownEditor)를 사용한다. - 작성 모드 textarea 왼쪽 바깥에 논리 줄 번호 거터(
\\n기준 줄 수, 빈 본문은 1줄)를 absolute 영역으로 두고 textarea와 거터의 세로 스크롤을 동기화한다. 거터 스크롤바는 숨긴다. 한 논리 줄이 화면에서 여러 줄로 줄바꿈될 때는 옵시디언·CodeMirror처럼 시각 줄마다 번호가 늘지 않으며, 논리 줄 단위로만 맞춘다. - 저장 데이터는 기존
content필드의 마크다운 문자열을 그대로 유지한다. - 관리자 게시물/페이지 저장 API는 레거시 블록 배열·객체 본문 값이 들어와도 마크다운 문자열로 정규화한 뒤 저장한다.
- 본문 작성 모드는 textarea 기반으로 범위 선택, 다중 문단 복사/붙여넣기, 외부 마크다운 붙여넣기를 브라우저 기본 동작에 가깝게 처리한다.
- 본문 작성 모드에서 Enter·Shift+Enter 모두 브라우저 기본 줄바꿈(한 줄)으로 동작한다. 문단 구분은 빈 줄로 한다.
- 클립보드에
text/html이 있으면 제목, 문단, 목록, 인용, 코드, 링크, 굵게, 기울임, 이미지를 기본 마크다운 조각으로 변환해 삽입한다. - 본문 미리보기 모드는 공개 본문과 같은
ContentMarkdownRenderer를 사용하며, 툴바와 카드형 패널 외곽을 숨겨 본문만 표시한다. - 툴바는 제목 1/2/3, 굵게, 기울임, 인라인 코드, 인용, 목록, 코드 블록, 구분선 삽입을 제공한다.
Cmd/Ctrl+B,Cmd/Ctrl+I는 현재 선택 텍스트에 각각 굵게, 기울임 마크다운을 적용한다.Cmd/Ctrl+E는 작성 모드와 미리보기 모드를 전환하며, 미리보기에서 작성 모드로 돌아올 때 기존 커서 위치와 textarea 스크롤을 복원한다.- 관리자 미리보기 패널은 공개 렌더러를 쓰되 밝은 관리자 배경 기준의 본문 색상 변수를 별도로 지정한다.
- 관리자 미리보기에서
ContentMarkdownRenderer에interactive를 켠다. 갤러리 이미지는 드래그로 순서를 바꿀 수 있으며, 드래그 중 다른 셀 위에 올리면 해당 셀에 주황 테두리와 「여기로 이동」 표시로 드롭 위치를 보여 준 뒤gallery-reorder로 마크다운을 갱신한다. - 라이브 모드 단일 이미지 블록은 드래그 가능하다.
이미지 줄과 단독 이미지 URL 줄 모두 같은 이미지 블록으로 다룬다. 다른 이미지 블록 위에 드롭하면 두 줄을:::galleryfenced block 한 개로 병합하며(merge-images-to-gallery), 문서 순서를 유지해 위쪽 이미지가 먼저 들어간다. 자동 인접 병합은 하지 않는다. - 라이브 모드 갤러리 블록은 이미지 블록과 같은 선택형 카드로 취급한다. Tab/클릭으로 포커스할 수 있고, 포커스 상태에서 방향키 위/아래 이동을 지원한다. 갤러리 이미지 hover/focus 시 개별 편집/삭제 버튼을 제공하며, 편집 버튼은 해당 이미지 줄 기준으로 갤러리 블록 설정 패널을 연다.
- 라이브 모드 단일 이미지 블록을 기존 갤러리 이미지 셀에 드롭하면 해당 셀 뒤에 이미지를 추가하고 원래 단일 이미지 줄은 제거한다(
insert-image-to-gallery). - 라이브 모드 갤러리 이미지를 블록 사이 얇은 삽입선(또는 문서 맨 아래 삽입선)에 드롭하면 해당 위치에 단일 이미지 마크다운 줄을 삽입하고 갤러리에서 제거한다(
extract-gallery-image). 갤러리에 이미지가 1장만 남으면 갤러리 블록을 단일 이미지 줄로 바꾸고, 0장이면 갤러리 블록을 제거한다. ProseImage는 URL이 비어 있거나 로드에 실패해도 최소 높이 placeholder와 「이미지를 불러올 수 없음」 안내를 표시해 라이브 모드에서 블록 선택·편집이 가능하다.- 인용(
>) 블록은 첫 인용 줄에> [!bg=yellow]또는> {bg=yellow}옵션 줄을 두면 해당 줄은 숨기고 블록 배경을 바꾼다. 지원 배경 프리셋은 콜아웃과 같은gray,blue,green,yellow,red,purple,pink이며, 옵션이 없으면 기존 pink 계열 기본 인용 스타일을 쓴다. - 관리자 라이브 모드(미리보기) 인라인 편집: 문단·빈 줄·제목·인용·목록·코드 블록·콜아웃·토글을 렌더 스타일 그대로 contenteditable로 수정한다. blur·문단 이동(방향키) 시 편집 영역의
<strong>·<em>등을**·*마크다운으로 다시 직렬화해 저장한다. Enter·Shift+Enter 모두 다음 문단(블록) 분리. 문단 안/로 슬래시 명령 메뉴(/image+Enter 이미지 삽입 등). 소스(작성) 모드 textarea에서도 동일한/슬래시 메뉴를 사용하며, 상단 마크다운 툴바는 두지 않는다. 슬래시 기본 제목은 h2·h3·h4만 표시하며, 본문 h1은/h1검색 시에만 삽입한다(게시물 제목 필드가 페이지의 유일한 h1). 콜아웃 옵션은 첫 줄:::callout emoji=💡 bg=blue처럼emoji·bg(gray|blue|green|yellow|red|purple|pink)로 지정하며, 라이브 모드에서는 아이콘 클릭으로 모달에서 편집한다(이모지 7종 프리셋·배경색 스와치, 직접 입력 없음). 코드 블록은```언어·nolinenos(줄 번호 숨김)를 지원한다. 라이브·공개 모두ProseCodeBlock(#15171a,px-4 py-3,text-sm leading-6)으로 동일하게 표시한다. 라이브 모드 호버·포커스 시 Language 입력·줄번호 토글이 보인다. 공개 화면에는 언어 라벨 옆 복사 버튼으로 본문을 클립보드에 넣는다. 본문 하단 클릭으로 새 문단을 추가한다. - 이미지 파일을 붙여넣거나 드롭하면 관리자 업로드 API로 저장한 뒤 현재 커서 위치에 이미지 또는 갤러리 마크다운을 삽입한다.
- 툴바
이미지·갤러리는 미디어 모달을 연다. 모달 기본 탭은 미디어 라이브러리이며 업로드 탭에서 드래그·파일 선택 후 즉시 삽입한다. - 미디어 라이브러리에서 단일 이미지를 선택하면
형식으로 삽입한다. - 미디어 라이브러리에서 여러 이미지를 선택하면
:::galleryfenced block으로 삽입한다. - 작성 모드에서 커서가 이미지 마크다운 줄,
:::gallery, 단독 URL 임베드 줄 또는 기존:::embed블록 안에 있고 textarea(또는 블록 패널)에 포커스가 있으면 게시물 설정 사이드바(420px) 위에 블록 설정 패널(AdminEditorBlockPanel)이 오른쪽에서 슬라이드 인한다. 본문·패널 바깥을 클릭하면 슬라이드 아웃한다. 갤러리 이미지 추가 미디어 모달을 여는 동안에는 활성 갤러리 컨텍스트와 패널 상태를 유지한다. - 블록 설정 패널: 이미지·갤러리(캡션, 파일명을 캡션으로 사용 토글·기본 끔, URL, 갤러리 순서·삭제·추가), 임베드(URL).
AdminMarkdownEditor는block-panel이벤트로 상태를AdminPostForm에 전달한다. - 미디어 라이브러리 갤러리 다중 선택 시 선택 항목은 주황(
#ff7a00) 굵은 테두리로 표시한다. - 옵시디언식 토큰 숨김/백스페이스 복원 Live Preview는 후속 단계로 둔다.
- 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다.
- 글 작성/수정 화면은 페이지 제목 헤더를 별도로 두지 않고, 폼 상단의 Ghost 스타일 도구막대에서 목록 이동, 상태 문구, Preview, 상태별 주요 액션(Publish / Update·Unpublish / Update·Unschedule), 설정 패널 토글을 제공한다.
- 도구막대 상태 문구는 영어로 표시한다. 초안: 편집 중
Draft, 저장(수동·서버 자동 저장) 진행 중Saving..., 서버 기준과 동일할 때Draft - Saved(신규 작성에서도 첫POST저장 후에는Draft - Saved를 사용할 수 있다). 즉시 발행: 공개 URL이 있으면Published ↗를 링크로, 없으면 동일 문구만 표시한다. 예약 발행:Scheduled를#2BBA3C·보통 굵기로 표시하고 마우스 오버 시 영문 한 줄로 예약 시각을title툴팁에 보여 준다. 멤버십은Members, 비공개는Private로 표시한다. - 초안(예약 발행 제외)은 입력 변경 후 약 1.2초 디바운스로 서버 자동 저장을 호출한다(슬러그가 유효할 때만). 제목이 비어 있으면 DB/API 저장 시에만
(제목 없음)플레이스홀더를 쓰고, 관리자 폼·목록 API 응답의title은 빈 문자열로 내려 준다. 임시 슬러그(d+24자리 hex)는 제목을 직접 수정하기 전까지 제목 입력에 따라 슬러그가 따라가며, 사용자가 슬러그를 직접 고친 뒤에는 자동 연동하지 않는다. 신규 작성 화면 마운트 시 슬러그가 비어 있으면 임시 슬러그를 채운다. 기존 글은PUT /admin/api/posts/:id, 신규 작성은 첫 저장 시POST /admin/api/posts로 행을 만든 뒤replace로/admin/posts/:id편집 화면으로 옮긴다. 이미 발행·예약으로 서버에 반영된 글은 사이드바 등으로 폼만 초안처럼 바뀌어도 자동 저장하지 않으며,Update를 눌렀을 때만PUT으로 반영한다(툴바의Publish/Update/Unpublish/Unschedule분기도 서버에 반영된 게시 형태를 기준으로 한다). 다른 화면으로 나가기 직전에는 디바운스 대기 중인 초안 변경이 있으면 타이머를 취소하고 한 번에POST또는PUT으로 플러시한다. Publish는 서버에 아직 초안으로만 저장된 글에만 표시되며 클릭 시 전체 화면 발행 모달을 연다. 초안에서 연 모달의 기본 선택은 발행·지금 바로이다. 모달 본문은 뷰포트 세로 중앙에 가깝게 배치하고, 상단에는 제목·닫기만 둔다(도구막대의Preview버튼은 두지 않는다). 모달에서는 상태(발행/초안)와 발행 시점(즉시/예약)을 최종 선택한 뒤 확정하며, 예약 시각은 날짜·시간(KST 표기) 입력을 분리한다. 모달에서는 행마다 접힌 상태로 현재 선택 요약만 보이고, 행을 눌러 펼친 뒤 버튼으로 값을 고른다. 게시 상태 행에는 Ghost와 같은 종이비행기 아이콘·펼침 화살표 SVG를 쓰고, 발행 시점 행에는 시계 아이콘·동일 화살표를 쓴다. 발행이 아닌 상태에서는 발행 시점 행 자체를 숨긴다.Update는 발행·예약·멤버십·비공개 글에 표시되며, 마지막 저장 이후 변경이 있을 때만 활성화된다(활성 텍스트#394047, 비활성#8E9CAC).Publish·활성화된Update·Unpublish·Unschedule에는 호버 시 배경#f1f3f4를 적용한다.Unpublish·Unschedule클릭 시 Ghost형 전체 화면 확인 화면을 연다. 발행·예약 시각 요약과 발행 취소하고 초안으로 되돌리기 →(또는 예약 취소 문구) 링크를 눌렀을 때만status를 초안으로 되돌리고published_at을 비운 뒤PUT으로 저장한다.- 글 작성/수정 화면은 관리자 사이드바 네비게이션을 숨기고, 관리자 공통 내부 패딩 없이 전체 화면 편집 모드로 표시한다.
- 글 작성/수정 화면은 브라우저 문서가 아니라 에디터 작업 영역 내부에서만 세로 스크롤한다.
- 글 작성/수정 폼은 에디터 작업 영역과 우측 설정 패널을 먼저 좌우로 나누고, 에디터 작업 영역 안에 상단 도구막대와 본문 스크롤 영역을 배치한다.
- 본문 에디터는 데스크톱에서 좌우 32px 패딩을 포함한 804px 중앙 컬럼으로 배치해 실제 입력 영역 최대 폭을 740px로 유지한다.
- 게시물 설정은 데스크톱에서 420px 우측 패널로 표시하며, 도구막대 버튼으로 열고 닫을 수 있고 너비 전환 애니메이션을 적용한다.
- 공개 가능한 글의 보기 액션은 게시물 설정 패널의 Post URL 라벨 오른쪽에 표시한다.
- 글 삭제 액션은 게시물 설정 패널 하단에 기본 중립 톤 버튼으로 제공하고 hover 시 위험 색상으로 강조한다.
- 대표 이미지는 본문 제목 위의 에디터 흐름 안에서 추가, 변경, 삭제한다.
- 대표 이미지가 설정된 상태의 변경/삭제 액션은 실제 이미지 레이아웃을 바꾸지 않도록 이미지 hover 또는 focus 오버레이로 표시한다.
- 대표 이미지 선택 모달은 이미지 업로드 탭과 미디어 라이브러리 탭을 제공한다.
- 제목 입력에서 Enter를 누르면 본문 첫 블록으로 포커스를 이동한다.
- 새 글 작성처럼 본문이 비어 있는 경우에도 빈 문단 블록을 먼저 생성한다.
- 미리보기 버튼은 현재 작성 폼 값을
SORI_ADMIN_POST_PREVIEW브라우저 저장소에 기록한 뒤/admin/posts/preview새 탭에서 렌더링한다. - 미리보기 화면은 공개 게시물 상세와 같은 본문 렌더링 컴포넌트를 사용하되, 데이터베이스에는 임시 글을 저장하지 않는다.
- 미리보기 본문·헤로 영역은 공개 상세와 동일하게 중앙
max-w-[720px]컬럼과px-4 sm:px-5수평 패딩을 적용한다. - 글 저장/수정/삭제 진행과 성공/실패 상태는 화면 우측 상단 토스트로 표시한다.
- 발행 상태에서 발행 시각을 미래로 지정하면 예약 발행으로 저장한다.
- 예약 발행 글은 관리자 목록에서 예약 상태로 표시하되 공개 게시물 목록과 상세 API에는 발행 시각 이후부터 노출한다.
- 관리자 글 에디터의 실제 입력 텍스트 색상은 placeholder보다 진하게 표시한다.
- 관리자 작성 화면과 공개 본문은 같은 마크다운 렌더링 기준을 사용한다.
- 태그 입력은 배지형 입력으로 제공하며 Enter 또는 쉼표 입력 시 태그를 추가하고, 배지의 x 버튼으로 삭제한다.
- 태그 배지 삭제 버튼은 SVG 닫기 아이콘으로 표시한다.
- 태그 토큰은 게시물 URL용
toSlug(한글 로마자화)와 분리하여 한글을 유지하고, 공백은 하이픈으로만 정리하며a-z0-9가-힣및 하이픈만 허용한다. - 제목 입력에서 한글 IME 조합 중 Enter는 조합 확정으로만 처리하고 본문 에디터 포커스 이동을 실행하지 않는다.
- 관리자 게시글 목록의 태그는 쉼표 구분 문자열이 아니라 읽기 전용 배지 목록으로 표시한다.
- 대표 이미지는 URL 직접 입력이 아니라 미디어 선택 또는 이미지 업로드 탭으로 설정한다.
- 대표 이미지 선택 모달에서 미디어를 클릭하면 선택 상태만 표시하고, 하단 대표 이미지로 적용 버튼을 눌렀을 때 글 폼에 반영한다.
- 대표 이미지가 설정되면 관리자 글 폼에 썸네일과 hover 오버레이 삭제/변경 액션을 표시한다.
- 글 SEO 메타(
seo_title,seo_description)는 별도 입력 없이 저장 시 글 제목·요약과 동일하게 기록한다. - 관리자 폼에서는 검색엔진 노출 제외(
noindex)만 설정할 수 있다. - Canonical URL은 별도 입력을 받지 않고 기본 글 주소를 사용한다.
- 공개 게시물 상세 화면은 SEO 제목이 없으면 글 제목, SEO 설명이 없으면 요약을 메타 태그 기본값으로 사용한다.
- 저장된 태그가 없는 게시물에는 상세 메타 행·홈 Latest·
/posts목록 카드에 태그 배지나 더미 라벨을 표시하지 않는다. - 검색엔진 노출 제외가 켜진 글은 robots 메타를
noindex, nofollow로 출력한다. - 공개 상세 화면의
og:image와 Twitter large image 카드는 대표 이미지를 기본값으로 사용한다. - 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고
또는 파일명 캡션 토글 시형식으로 저장한다. 단독 이미지 파일 URL(jpg,png,webp,gif,avif,svg) 한 줄은 임베드가 아니라 이미지 블록으로 렌더링한다. - 이미지/갤러리 삽입 시 캡션은 기본 비우며, 블록 설정 패널에서 파일명을 캡션으로 사용 토글로 이미지 아래에 URL 파일명을 표시한다.
- 라이브 모드 이미지 블록은 hover/focus 시 우측 상단에
편집·삭제버튼을 표시한다.편집은 기존 오른쪽 이미지 설정 패널을 열어 이미지 URL·캡션·파일명 캡션 사용 여부를 수정한다. - 이미지 블록 표시 옵션은
regular,wide,full값을 사용하며regular는 width 옵션을 생략한다. - 갤러리 블록은
:::galleryfenced block 안에 이미지 마크다운 행을 여러 개 저장한다. - 관리자 갤러리 미디어 선택은 여러 이미지를 선택한 뒤 확인 버튼으로 적용한다.
- 관리자 갤러리 블록은 이미지 1개일 때 1열, 2개일 때 2열, 3개 이상일 때 3열로 표시한다.
- 관리자 갤러리 블록의 이미지 순서는 드래그 앤 드롭으로 변경하며, 드래그 중 삽입 위치를 이미지 사이 라인으로 표시한다.
- 공개 갤러리는 한 줄에 2~3개 이미지 그리드로 표시하고 클릭 시 라이트박스로 크게 확인한다.
- 이미지와 갤러리 블록은 기존 업로드 미디어 선택 또는 새 이미지 업로드를 제공한다.
- 관리자 미디어 업로드 API는 이미지(
jpg,png,webp,gif), 비디오(mp4,webm,mov), 오디오(mp3,wav,ogg,m4a), 파일(pdf,zip,txt,csv,docx,xlsx,pptx)을 지원한다. - 콜아웃 블록은
:::calloutfenced block 안에 본문을 저장한다. - 콜아웃 블록은 선언부 옵션으로
emoji,bg를 저장할 수 있다. 예::::callout emoji=💡 bg=blue emoji=none이면 공개 렌더러에서 이모지를 숨긴다.- 콜아웃 배경 프리셋은
gray,blue,green,yellow,red,purple,pink를 지원한다. - 토글 블록은
:::toggle 제목fenced block 안에 펼침 본문을 저장한다. 라이브 모드에서는 제목·본문을 인라인 편집하며, chevron으로 펼침·접힘 시 본문이 애니메이션된다. - 임베드 블록은 이미지 파일 URL을 제외한 단독
http(s)URL 한 줄을 기본 저장 형식으로 사용한다. - 기존
:::embedfenced block은 이전 콘텐츠 호환을 위해 계속 파싱·렌더링한다. - 관리자 Markdown-first 에디터의 라이브/스타일 모드에서 임베드 블록은 URL 입력 카드 없이 즉시 실제 임베드 프리뷰로 표시된다. 임베드·비디오·오디오·파일 프리뷰 카드는 hover/focus 시 우측 상단 삭제 버튼을 표시한다. 블록 래퍼에 포커스한 상태에서
Backspace·Delete·Ctrl/Cmd+Shift+K로 삭제하고,Enter로 아래 빈 줄을 추가하며,ArrowUp·ArrowDown은 브라우저 스크롤 대신 이전/다음 편집 줄로 이동한다. - 라이브/스타일 모드에서 제목 블록 Enter는 현재 제목 내용을 저장한 뒤 바로 아래 빈 문단을 추가하고, 원문 마크다운 편집 상태로 전환하지 않는다.
- 게시물 작성 화면 상단 제목 입력 후 Enter는 현재 에디터 모드를 유지한 채 본문 첫 줄(마크다운 첫 줄이 제목이면 그 다음 줄)로 포커스를 옮긴다.
- 소스 모드 라인 번호는 논리 줄 수를 표시하되, 긴 문장 자동 줄바꿈으로 textarea의 한 줄 높이가 늘어나면 라인 번호 칸도 같은 높이로 맞춘다.
- 소스 모드에서 라이브 모드로 전환하면 현재 textarea 커서 줄과 줄 안 오프셋을 기준으로 대응하는 라이브 편집 블록에 포커스를 두고, 대상 줄이 화면 중앙에 가깝게 보이도록 스크롤한다.
- 라이브 모드에서 소스 모드로 전환하면 현재 포커스된 블록 또는 화면 상단에 가까운 원본 줄을 기준으로 textarea 커서와 스크롤 위치를 복원한다.
- YouTube 임베드 URL은 공개 화면에서 본문 폭 기준 16:9 iframe으로 렌더링한다.
- Twitter/X 게시물 URL(
twitter.com·x.com·mobile.twitter.com, 경로에status포함)은platform.twitter.com/embed/Tweet.htmliframe으로 렌더링하며, 테마는useThemeMode()와 동기화한다. X 공식 iframe의 내부 최대 폭 때문에 공개 화면에서는 카드 폭을 좁혀 중앙 정렬한다. - Mastodon 공개 게시물 URL(
/@user/id,/users/user/statuses/id)은{원본 URL}/embediframe으로 렌더링한다. iframe 로드 후 Mastodon 공식 embed 방식과 같은postMessage높이 요청을 보내 응답 높이를 반영한다. 인스턴스가 embed를 차단하거나 지원하지 않으면 브라우저 iframe 정책에 따라 표시되지 않을 수 있다. - 그 외 URL은 외부 링크 텍스트 카드로 표시한다.
- 비디오 블록은
:::videofenced block 안에url,title,poster,caption값을 저장하며 공개 화면에서 가로형 비디오 카드로 렌더링한다. 관리자/video슬래시 명령은 비디오 미디어 선택·업로드 모달을 열고 선택 파일 URL을 자동으로 채운다. - 오디오 블록은
:::audiofenced block 안에url,title,description값을 저장하며 공개 화면에서 아이콘+플레이어 카드로 렌더링한다. 관리자/audio슬래시 명령은 오디오 미디어 선택·업로드 모달을 열고 선택 파일 URL을 자동으로 채운다. - 파일 블록은
:::filefenced block 안에url,title,description,name,size값을 저장하며 공개 화면에서 다운로드 카드로 렌더링한다. 관리자/file슬래시 명령은 문서 파일 선택·업로드 모달을 열고 URL·파일명·크기를 자동으로 채운다. - 북마크 블록은
:::bookmarkfenced block으로 저장할 수 있으며 공개 화면에서 Thred형 가로 카드로 렌더링한다. - 회원가입(뉴스레터) CTA는
:::signupfenced block으로 저장할 수 있으며 실제 폼 연동은 후속 작업으로 분리한다.
관리자 페이지 편집
- 고정 페이지 작성/수정 화면은 게시글 작성 화면과 같은 전체 화면 에디터 구조를 사용한다. 상단 툴바에 목록 이동, 저장 상태, 저장 버튼, 설정 패널 토글을 두고 오른쪽 설정 패널은 접고 펼칠 수 있다.
- 고정 페이지 작성/수정 화면의 기본 모드는 HTML 문서 모드이며,
markdown모드는일반 텍스트로 표시한다. - 고정 페이지 HTML 문서 모드는 전체 HTML 붙여넣기용 textarea를 사용하고, 공개 URL에서 Nuxt 레이아웃 없이 원문 HTML로 응답한다.
- 페이지 슬러그는 게시글처럼 한글 제목을 영문으로 로마자화해 자동 생성한다.
- 페이지 상태, 페이지 형식, Page URL, HTML 자산 업로드, 삭제 액션은 오른쪽 설정 패널에서 관리한다.
- 페이지 형식 선택은 HTML 문서/일반 텍스트 모드와 무관하게 항상 표시한다. HTML 자산 업로드는 HTML 문서 모드에서만 표시한다.
- HTML 자산 업로드는 기존 관리자 업로드 API(
/admin/api/uploads)를 사용하며, 성공한 파일 URL을 HTML textarea 현재 커서 위치에 삽입한다. 업로드 파일은 현재 에디터 업로드 정책에 따라/uploads/posts/YYYY/MM/아래 저장되고 미디어 라이브러리 논리 폴더는미분류로 기록된다. - 고정 페이지는 제목, 슬러그, 렌더링 방식, 본문을 저장한다. 대표 이미지는 페이지 작성 UI에서 사용하지 않는다.
- 고정 페이지는 게시물 목록과 태그 목록에 노출하지 않는다.
- 고정 페이지 공개 보기 경로는
/pages/:slug를 사용한다. - 고정 페이지 상태는
published,draft,private를 사용한다. 공개 목록·상세·HTML 문서 미들웨어는published상태만 응답하고,draft와private는 공개 URL에서 찾을 수 없는 페이지로 처리한다.
사이트 설정
- 관리자 사이트 설정 UI는
/admin/settings에서 제공한다. Ghost Admin과 유사하게 전체 화면으로 표시하며, 좌측 내비와 우측 본문을 한 덩어리로 중앙 정렬(max-w래퍼, 본문 카드 영역은 약 760px 상한)하고, 페이지 배경은 밝은 회색·본문 열은 흰색으로 구분한다. 우측 본문을 스크롤하면 현재 보이는 구역에 맞춰 좌측 메뉴 활성 배경을 갱신한다. 우측 상단 고정 닫기 버튼과Escape키로 설정 화면을 닫으며, 브라우저 히스토리가 있으면뒤로 가기, 없으면/admin으로 이동한다. 타임존·게시물 Import/Export는 현재 메뉴·안내 카드만 제공하고 저장 API는 연결하지 않는다. 스팸 필터는 가입 금지 닉네임을 저장한다. 블로그 제목·설명 카드는 기본적으로 사이트 이름·설명을 읽기 전용으로 보여 주고,편집을 눌렀을 때만 입력 필드·미리보기·저장/취소가 나타난다. 제목·설명 편집 중Escape는 설정 닫기 대신 편집 취소로 동작한다. POST 설정·어나운스 바는 읽기 모드에서도 토글 UI를 비활성화 상태로 보여 준다. - 사이트 설정은
site_settings테이블의 단일 레코드로 관리한다. - 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다.
- 메인 화면(
home_cover_image_url,home_cover_dark_image_url,home_cover_title,home_cover_text): 홈(/) 상단 720px 커버 배너. 라이트 이미지는 기본 커버이며, 다크 이미지가 있으면 시스템 다크모드 또는html[data-theme='dark']에서 다크 이미지를 표시한다. 다크 이미지가 없으면 라이트 이미지를 그대로 사용한다. 이미지가 있을 때만HomeHero를 표시하며, 제목·짧은 본문은 이미지 왼쪽 하단 그라데이션 오버레이로 겹친다. 오버레이 본문은 textarea에서 입력한 줄바꿈(\n)을 저장·표시하며,HomeHero본문은whitespace-pre-line으로 여러 줄을 렌더링한다. 관리자 UI에서는 커버 파일 업로드·제목·본문을 편집한 뒤 저장 한 번에PUT /admin/api/settings로 반영한다. 읽기·편집 미리보기는 실제HomeHero컴포넌트를 사용해 긴 본문도 공개 화면과 같은 오버레이 폭(max-w-[32rem])과 줄바꿈으로 확인한다. 파일 업로드 API는 디스크에만 올리고 URL을 돌려준다(가로 720px WebP,/uploads/system/home-cover-YYYYMM-random.webp). - POST 설정(
show_post_updated_at): 발행 후 본문·메타 수정이 있을 때 관리자 글 목록에 수정 시각 보조 줄을 표시할지 여부. 공개 게시글 상세에는 수정 시각을 표시하지 않는다. - 가입 금지 닉네임(
signup_blocked_usernames, JSON 문자열): 회원가입·회원 프로필 닉네임 변경 시 닉네임에 목록 단어가 포함되면 거부한다(대소문자 무시, 부분 일치). 안내 문구는{단어}은 사용할 수 없는 단어입니다.형식이다. 기본값:admin,master,zenn,sori,sori.studio. - 어나운스 바(
announcement_enabled,announcement_text,announcement_url,announcement_background_color):announcement_enabled가 true이고 문구가 비어 있지 않으면 공개 레이아웃(default·post) 헤더 위에 전체 너비 배너를 표시한다.announcement_url이 있으면 문구를 링크로 감싼다(내부/…또는http(s)://). 배경색은#15171a·#ffffff·#ff4f2e중 하나. 방문자는 이번 방문 동안 닫기(X,sessionStorage) 또는 7일간 보지 않기(localStorage, 만료 시각 저장)를 선택할 수 있다. 공지 내용이 바뀌어updatedAt이 달라지면 다시 노출된다. - 로고 이미지는 1:1 비율로 저장하며
/admin/api/settings/logo업로드 시/uploads/system/logo-YYYYMM-random.webp와/uploads/system/favicon-YYYYMM-random.png를 고유 파일명으로 함께 생성한다. 업로드 API는 파일 URL만 반환하고, 실제logo_url·favicon_urlDB 반영은 기타 설정 카드의 저장 버튼에서 처리한다. 같은 URL 덮어쓰기로 인한 브라우저·운영 정적 캐시 문제를 피하기 위해 로고 교체마다 새 URL을 저장한다. - 공개 헤더와 오른쪽 사이드바는 공개 사이트 설정 API 값을 사용한다.
- 공개 헤더와 오른쪽 사이드바는
logo_url이 있으면 이미지 로고를 표시하고, 없으면logo_textfallback을 쓴다.favicon_url은 head의 icon 링크로 연결한다. - DB 연결이 없는 환경에서는 환경 변수와 기본값 기반 설정을 사용한다.
메뉴/네비게이션
- 네비게이션은
navigation_items테이블로 관리한다. - 컬럼:
parent_id(nullable, self FK,ON DELETE CASCADE),is_folder(boolean, 자식이 있는 상단 항목이면 저장 시 서버가 true로 설정),location(primary|footer|recommended),sort_order,label,url,is_visible(저장 시 항상 true로 정리).(location, label, url)유니크 제약은 제거되었다. - 동일
location·parent_id·label·url조합은 중복 저장하지 않는다. 반복 마이그레이션으로 생긴 중복은019_dedupe_navigation_items.sql이 정리하고, 표현식 유니크 인덱스로 재발을 막는다. GET /api/navigation응답:primary는 트리(각 노드에children배열이 있을 수 있음, 리프는children없음). 노드 필드:id,label,url,isFolder,isVisible. 트리 조립 시 동일id는 한 번만 반영하고, DB 평면 행에parent_id가 있어 자식으로 붙은 항목은 루트에 두지 않는다. 상단은 루트 직속 자식만 트리에 붙이며(부모의 부모가 있는 행은 자식으로 무시),footer·recommended는 평면 배열(parent_id없음)이다.recommended는id,label,url,descriptionText,thumbnailUrl,isVisible을 내려준다.PUT /admin/api/navigation요청 본문의 각 항목은 반드시id(UUID)를 포함한다. 저장 시sort_order는primary·footer·recommended각각 위치별 트리(또는 평면 루트) DFS 순으로 다시 부여한다.parent_id는primary에서만 허용되며 상단은 한 단계(루트→자식)만 허용한다.footer·recommended항목은 항상 루트다.is_visible·is_folder는 요청값과 무관하게 서버에서 항상 표시·자식 유무 기준 폴더로 덮어쓴다.- URL은
/로 시작하는 내부 경로,http(s)://외부 URL, 폴더 전용 자리 표시#를 허용한다. - 관리자 메뉴 화면은 상단 네비게이션·하단 네비게이션·추천 사이트 탭으로 구분한다. 편집 UI는 태그 관리 메인 태그와 같은 테이블·
cursor-move행 드래그 패턴을 쓰며, 드래그는 입력·버튼 위가 아닌 행 여백·개요 열에서 시작한다. 상단은buildNavigationEditorTree결과를flattenNavigationEditorWrappers로 한 테이블에 평면 표시하고, 같은 행의 위 1/3·가운데·아래 1/3에 드롭하면 각각 대상 앞(동일 부모)·하위(대상의 자식, 루트 행에만)·뒤(동일 부모)로 이동한다. 상단은 루트→자식 한 단계만 허용하며(하위 행에는 가운데 드롭이 형제 앞·뒤로 대체됨, 이미 하위가 있는 항목은 다른 항목의 하위로 넣을 수 없음), 드래그 중 하이라이트는 형제 앞·뒤를 파란 가로 끝선·연한 파란 배경, 하위 편입을 앰버 링·연한 앰버 배경으로 구분하고, 개요 열에앞에 끼움/뒤에 끼움/하위로 넣기짧은 문구를 덧붙인다. 개요 열 표기는1,2.1,2.2,3처럼 깊이별 계층 번호로 보이며, 라벨 열은 깊이만큼padding-left로 들여쓴다. 자기 자신·자기 하위로의 편입은 거부한다. 하단·추천 사이트는 평면 목록만 드래그 정렬한다. 추천 사이트는 공개 홈 오른쪽 사이드바 Recommended 영역에 카드로 노출되며, 대체 텍스트가 있으면 URL 대신 대체 텍스트를 표시하고 썸네일 URL이 있으면 Google Favicon 프록시 대신 썸네일을 표시한다. 썸네일이 없고https://URL이면 클라이언트에서 호스트를 뽑아 Google Favicon 프록시(https://www.google.com/s2/favicons?domain=…) 이미지로 표시할 수 있다(내부 경로만 있으면 아이콘 생략). parent_id/is_folder컬럼이 DB에 없을 때 저장은 실패한다.npm run db:migrate:dev로017_navigation_hierarchy.sql을 적용해야 한다(저장 API는 해당 경우 한국어 안내 메시지를 반환할 수 있다).- 공개 왼쪽 사이드바 상단은
SidebarPrimaryNavList로 렌더링한다. 하위가 있는 노드는 한 줄button으로, 행 전체(이름·왼쪽 세로 데코·chevron) 클릭으로 접기/펼친다. 부모·리프 모두 왼쪽 장식은 기본 세로 막대(--site-line), 호버 시에만 리프와 동일하게 작은 원형으로 전환한다. 내부 경로(/로 시작,//·http(s)제외)이고 현재route.path와 정규화한 경로가 같으면 장식 색을 **--site-accent(브랜드 오렌지)**로 둔다. 외부 URL은 비교하지 않는다. 펼침 상태는localStorage키sori-primary-nav-expanded에 저장된다. 상단·리프 링크·부모 버튼 행은 **w-full**로site-sidebar-nav-row호버 배경이 가로 전체를 쓴다(라이트#F7F4EF, 다크는site-panel-hover와 동일한color-mix패턴). - 관리자 메뉴 관리 화면에서 저장 API로 보내는 직렬화 결과가 서버에서 불러온 직후와 동일하면
메뉴 저장버튼이 비활성화된다.
관리자 인증
- 관리자 인증은
users.is_admin=true회원의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다. - DB에 사용자가 없으면
/signup에서 관리자 등록 모드(관리자 이름/이메일/비밀번호/비밀번호 확인)로 첫 계정을 생성한다. - DB에 owner/admin 계정이 없는 최초 상태에서
/admin/login에ADMIN_EMAIL/ADMIN_PASSWORD와 같은 값을 입력하면 첫 owner 계정을 DB에 생성한 뒤 로그인한다. 같은 이메일의 일반 회원이 이미 있으면 해당 회원을 owner로 승격하고 비밀번호를ADMIN_PASSWORD기준 bcrypt 해시로 갱신한다. 이미 owner/admin 계정이 있으면 환경 변수 계정으로 우회 로그인하지 않고 DB의 관리자 계정만 사용한다. - 최초 owner 생성 시
is_admin=true,user_role=owner를 자동 부여한다. 최초 관리자 판정은 동시 요청에서 중복 owner가 생기지 않도록users테이블 잠금 안에서 처리한다. - 관리자 로그인 성공 시 httpOnly 세션 쿠키(
sori_admin_session)를/경로에 설정한다. Secure는 실제 HTTPS 요청(x-forwarded-proto포함)일 때만 사용한다. /admin/login로그인 제출 버튼은 제출 중일 때만 비활성화한다. 빈 값은 브라우저required와 서버 검증으로 처리하며, 자동완성 값은 제출 직전 동기화한다.- 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정하고
users.previous_last_seen_at/previous_last_seen_ip에 직전 로그인 값을 보존한 뒤last_seen_at/last_seen_ip를 현재 로그인으로 갱신한다. 요청 IP는 프록시 헤더(x-forwarded-for)를 포함해 기록한다. - 관리자 설정 화면은 사이트 자체 설정만 관리한다. 관리자 프로필과 비밀번호는 멤버 편집 화면의 계정 작업에서 처리한다.
- 관리자 사이드바 하단 사용자 메뉴의
내 프로필은/admin/members/:id멤버 편집 화면으로 이동한다. - 관리자 멤버 목록은 Ghost형 테이블 기준으로 닉네임 아래 이메일, 가입일 아래 최근 활동을 함께 표시한다. 상태 열은 멤버 등급을 먼저 표시하고, 비활성 회원만 작은 보조 상태로 표시한다.
- 관리자 멤버 목록은 멤버 검색, 멤버 추가 버튼, 조건 필터를 제공한다. 필터는 이름·이메일·레이블 텍스트 조건, 활성/비활성 상태 조건, 최근 접속·가입일 날짜 조건을 지원하며 여러 조건은 AND로 적용한다.
- 관리자 멤버 목록은 뉴스레터 지표 대신 댓글 작성 개수를 표시한다.
- 관리자 멤버 상세/추가 화면은 Ghost형 편집 화면을 기준으로 썸네일, 이름, 이메일, 레이블, 관리자 노트를 편집한다. 본문은 3분할 그리드로 요약 영역 1fr, 입력 영역 2fr 비율을 사용한다. 썸네일은 URL 입력 대신 요약 영역의 원형 썸네일 hover 액션으로 등록·변경·제거한다. 기존 회원 상세는 멤버 등급 선택, 가입 정보와 활동 요약을 표시하고, 신규 회원 화면은 활동 영역을 표시하지 않는다. 멤버 등급은 셀렉트 변경 즉시 저장하지 않고 저장 버튼을 눌렀을 때 기본 정보와 함께 반영한다. 기존 회원 상세의 설정 메뉴는 관리자 직접 비밀번호 변경(
PUT /admin/api/members/:id/password)과 멤버 삭제(DELETE /admin/api/members/:id)를 제공한다. - 관리자 멤버 추가/수정 화면은 저장되지 않은 변경사항이 있을 때 내부 라우트 이동을 공통 확인 모달로 막는다. 관리자 게시글 작성/수정 화면은 서버에 이미 즉시 발행 또는 예약으로 저장된 글에서 미저장 변경이 있을 때만 동일 방식으로 내부 이동을 막고, 초안(서버 기준)은 서버 자동 저장과 라우트 이탈 직전 플러시로 처리하여 해당 경우에는 모달을 쓰지 않는다. 브라우저 새로고침·탭 닫기는 브라우저 기본
beforeunload확인을 사용한다. 게시글 화면에서 이탈을 승인하면 레거시 키SORI_ADMIN_POST_AUTOSAVE:*가 있으면 삭제한다. users.member_labels는 관리자용 문자열 배열이며, 이후 사용자별 칭호/분류 용도로 확장한다.users.member_note는 관리자에게만 보이는 500자 이하 메모다.- 관리자 멤버 권한은
소유자(owner),관리자(admin),VIP(vip),멤버(member)단계를 사용한다. VIP는 관리자 권한이 없지만 멤버십 게시물을 볼 수 있는 등급이며, 상세 화면에서 변경한다. 권한 변경은 소유자와 관리자만 가능하다. 관리자는 다른 관리자·소유자의 권한을 변경할 수 없고, 소유자·관리자 등급을 부여할 수 없다. 소유자는 모든 등급을 관리할 수 있으나 본인 권한을 직접 낮출 수 없고, 시스템에는 최소 1명의 소유자가 항상 남아야 한다. - 관리자 페이지 접근은
/admin/api/auth/me확인 후 허용한다. - 관리자 세션 토큰은
ADMIN_PASSWORD기반 HMAC 서명으로 검증한다. /admin/api/auth/login,/admin/api/auth/logout을 제외한 관리자 API 요청은 서버 미들웨어에서 세션 사용자 ID의 현재 DB 권한이owner또는admin인지 다시 확인한다. 권한 변경이나 계정 삭제 이후 남아 있는 기존 관리자 쿠키는 이 단계에서 차단한다.
게시물 작성자
posts.author_id는 게시물을 만든 회원 ID이며users.id를 참조한다(ON DELETE SET NULL).- 관리자 게시물 생성 시 현재 관리자 세션의
userId를author_id로 저장한다. - 기존 게시물은 마이그레이션
032_add_post_author.sql에서 owner/admin 계정이 정확히 1개일 때만 해당 계정으로author_id를 채운다. 여러 관리자 계정이 있으면 임의 배정을 피하기 위해 자동 backfill하지 않는다. - 공개 게시글 상세의 편집 아이콘 노출은 관리자 여부가 아니라 현재 로그인 회원 ID와
posts.author_id일치 여부를 기준으로 한다.
회원 인증
- 회원 인증은
users테이블의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다. /signin로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.- 로그인 성공 시 httpOnly 세션 쿠키(
sori_member_session)를/경로에 설정한다. - 회원 API(
POST /api/auth/signup,POST /api/auth/login,GET /api/auth/me,GET/PUT /api/auth/profile,POST /api/auth/logout,GET /api/auth/bootstrap-status,POST /api/auth/email-otp/request,POST /api/auth/password-reset/confirm)로 세션·이메일 OTP를 관리한다. - 회원 로그인 성공 시
previous_last_seen_at/previous_last_seen_ip에 직전 로그인 값을 보존한 뒤last_seen_at/last_seen_ip를 현재 로그인으로 갱신한다. 요청 IP는 프록시 헤더(x-forwarded-for)를 포함해 기록한다./api/auth/me는 세션 확인만 수행하고 로그인 이력을 갱신하지 않는다. - 사용자 설정 화면은 공개 본문 폭에 맞춰 프로필 요약을 상단에 두고, 프로필 입력과 활동 정보를 하단에 배치한다. 비밀번호 변경과 회원 탈퇴는 설정 버튼의 모달 액션으로만 노출한다. 활동 정보의
마지막 로그인은 현재 로그인 이전에 저장된previous_last_seen_at을 표시한다. - 첫 회원가입 시 관리자 세션도 함께 설정되어 관리자 화면(
/admin)으로 바로 진입할 수 있다. - 회원 세션 서명은
MEMBER_SESSION_SECRET만 사용하며, 값이 없으면 서버 오류로 실패한다. - Docker 운영 서버 환경 변수는 이미지 빌드 시점
runtimeConfig보다 컨테이너 런타임process.env값을 우선한다.
미디어 관리
업로드 경로 규칙
/uploads/posts/YYYY/MM/filename.webp
/uploads/pages/YYYY/MM/filename.webp
/uploads/members/avatars/YYYY/MM/filename.webp
/uploads/system/logo-YYYYMM-random.webp
/uploads/system/favicon-YYYYMM-random.png
/uploads/system/home-cover-YYYYMM-random.webp
-
관리자 에디터 이미지 업로드 API는 디스크상
public/uploads/posts/YYYY/MM/에 저장하지만, DB가 있을 때media_metadata에는 논리 폴더 **미분류**로 기록한다. 메타가 없을 때도 서버 목록에서는posts/...경로를 **미분류**로 표시한다(디스크 1단계posts와 이중 표기하지 않음). 저장 파일명은 업로드 원본 파일명(안전 문자만 유지)을 우선 쓰고, 같은 월 디렉터리에 동일 이름이 있으면이름-2,이름-3식으로 넘버링한다. -
미분류는 예약된 논리 폴더이며, 사용자가 폴더를 지정하지 않았거나 폴더 삭제 후 되돌림 등으로media_metadata.category가미분류로 저장된 항목이 여기에 모인다. -
회원 썸네일은
public/uploads/members/avatars/YYYY/MM/에 WebP로 저장되며, 저장 파일명은 원본명 기반(동일 폴더 충돌 시-2넘버링)이다. 업로드 시media_metadata.category는 논리 폴더 **썸네일**로 저장한다.썸네일은 예약 폴더로media_folders에 수동 생성·삭제할 수 없다. -
사이트 로고·파비콘·홈 커버는
public/uploads/system/에 저장되며, 업로드 시media_metadata.category는 논리 폴더 **시스템**으로 저장한다. 현재 사이트 설정의logo_url,favicon_url,home_cover_image_url,home_cover_dark_image_url이 가리키는 파일은 사용 중인 미디어로 표시하고 파일명 변경·삭제를 차단한다. -
레거시 메타 값
posts,회원/썸네일은 마이그레이션016_media_category_normalize.sql및 서버 정규화로 각각미분류,썸네일에 맞춘다. -
관리자 미디어 업로드 API는 이미지·비디오·오디오·문서 확장자를 허용한다(에디터 슬래시·미디어 모달과 동일 목록).
-
업로드 파일 크기 제한은 종류별 환경 변수를 따른다. 이미지·아바타·로고 등은
MAX_FILE_SIZE(기본 10MB), 비디오는MAX_VIDEO_FILE_SIZE(기본 200MB), 오디오는MAX_AUDIO_FILE_SIZE(기본 50MB), 문서·ZIP 등은MAX_DOCUMENT_FILE_SIZE(기본 50MB). -
로컬 개발 업로드 파일은
public/uploads/posts/YYYY/MM/아래 저장하고/uploads/posts/YYYY/MM/filenameURL로 제공한다. -
관리자 미디어 화면 상단에 미디어 라이브러리 탭과 프로필 이미지 탭을 두어, 라이브러리 탭에서는 게시물·기타 이미지만, 프로필 이미지 탭에서는
/members/avatars/파일만 검색·탐색한다. -
미디어 라이브러리 탭은 왼쪽 폴더 트리와 오른쪽 고밀도 썸네일 갤러리, 검색, 파일명 변경, 개별 삭제를 제공한다.
-
관리자는 폴더 추가 버튼으로 모달에서 새 폴더·하위 폴더 이름을 입력해 만들 수 있으며,
미분류·썸네일(및 그 하위)을 제외한 폴더는 목록에서 삭제할 수 있다. 폴더 삭제 시 해당 경로 및 하위 경로로 분류돼 있던 미디어 메타는 모두미분류로 되돌린다. -
썸네일 본문(이미지·파일명) 한 번 클릭 시 상세(미리보기) 모달이 열리고, 썸네일 좌측 상단 선택 토글로 개별 선택한다. Shift+클릭으로 범위 선택이 가능하다.
-
미디어 라이브러리 탭에서만 선택한 미디어를 폴더로 드래그하면 해당 미디어들의 폴더 경로가 일괄 변경된다.
-
관리자 미디어 화면 검색은 저장 파일명과 게시물·페이지 사용처 제목만 대상으로 한다(URL 경로·논리 폴더명 문자열은 검색에 쓰지 않는다).
-
미디어 파일 경로, 사용 현황(라이브러리), 연결 회원(프로필 이미지 탭), 용량 등 세부 정보는 상세 모달에서 표시한다.
-
API 실패·클라이언트 검증 실패 등 사용자 피드백은 본문 상단 고정 배너가 아니라
useAdminToast우측 상단 토스트로 표시해 모달에 가리지 않는다. -
상세 모달의 다운로드는 공개
/uploads/...URL을download속성으로 브라우저에 내려받는다(썸네일·게시물 이미지 공통). -
미디어 폴더는 실제 파일 경로를 옮기지 않고
media_metadata테이블에 URL별 경로 메타데이터로 저장한다. -
글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
-
미디어 사용 현황은 게시물/페이지의 대표 이미지·본문 내 URL과 사이트 설정의 로고·파비콘·홈 커버 URL을 기준으로 표시한다.
-
사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다.
-
회원 프로필 썸네일 파일은
users.avatar_url이 해당 URL을 가리킬 때만 관리자 화면에서 파일명 변경·삭제를 차단한다. 프로필에서 교체·해제된 파일은 디스크에 남으며 프로필 이미지 탭에서 정리할 수 있다.
환경 변수 (.env)
공통 키
# 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
MEMBER_SESSION_SECRET=replace-with-random-password
ANALYTICS_HASH_SECRET=replace-with-random-password
# Upload
UPLOAD_DIR=/uploads
MAX_FILE_SIZE=10485760
MAX_VIDEO_FILE_SIZE=209715200
MAX_AUDIO_FILE_SIZE=52428800
MAX_DOCUMENT_FILE_SIZE=52428800
AVATAR_MIN_WIDTH=96
AVATAR_MIN_HEIGHT=96
AVATAR_MAX_WIDTH=512
AVATAR_MAX_HEIGHT=512
AVATAR_WEBP_QUALITY=82
# Site
NUXT_PUBLIC_SITE_URL=https://sori.studio
NUXT_PUBLIC_SITE_TITLE=sori.studio
# Server
APP_PORT=43118
환경 파일 기준
| 파일 | 용도 | DB |
|---|---|---|
.env.development |
로컬 개발, Git 제외 | 개발 DB |
.env.production |
NAS 운영, Git 제외 | 운영 DB |
.env.example |
공유 예시, Git 포함 | 실제 접속 정보 없음 |
.env.example에는 실제 이메일, 비밀번호, 토큰, 운영 서버 주소를 기록하지 않음.env.development와.env.production의 DB 비밀번호와 관리자 비밀번호는 서로 다른 랜덤 값을 사용- 로컬 개발
DATABASE_URL은 호스트 기준127.0.0.1:43119를 사용 - NAS Docker 내부
DATABASE_URL은 서비스명 기준sori-studio-db:5432를 사용 - 로컬 DB 확인은 PostgreSQL 클라이언트에서
127.0.0.1:43119로 접속하거나docker exec sori-studio-db psql명령으로 확인한다.
포트 기준
| 용도 | 포트 |
|---|---|
| 로컬 개발 서버 | 43117 |
| NAS Docker 외부 포트 | 43118 |
| 컨테이너 내부 포트 | 3000 |
| PostgreSQL 외부 포트 | 43119 |
버전 관리
- 현재 버전: v0.0.85
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정