로그인 상태를 헤더에서 즉시 인지하고 계정 관리를 이어갈 수 있도록 사용자 설정과 관리자 멤버 관측 기능을 연결했다. Co-authored-by: Cursor <cursoragent@cursor.com>
583 lines
33 KiB
Markdown
583 lines
33 KiB
Markdown
# sori.studio 기술 명세
|
|
|
|
## 프로젝트 개요
|
|
|
|
- **프로젝트명**: sori.studio
|
|
- **유형**: 커스텀 블로그/CMS
|
|
- **목표**: 개인 블로그 중심 운영, 기존 포털성 링크와 서비스 진입점은 블로그 내부 구조에 통합
|
|
- **참조**: Ghost(관리자 UX/글쓰기), Thred 테마(사용자 화면)
|
|
- **현재 상태**: Nuxt 3.21(SSR)·PostgreSQL 저장소 계층 구성 완료. Node가 SSR 번들의 `#internal/nuxt/paths`를 해석하도록 루트 `package.json` `imports`와 `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`. `lg`~`xl` 구간은 내부 `px-5`~`px-6`로 좌우 여백을 두고, 검색창은 뷰포트에 맞춰 `max-w`로 단계 축소한다(`2xl`에서 고정 470px). |
|
|
| Shell | `min-height: 100vh`, `flex` 세로 컬럼 |
|
|
| 그리드(데스크톱 `lg+`) | `items-start`, `column-gap`(`lg:gap-x-4`, `xl:gap-x-5`)으로 열 사이 간격. 중앙 열은 `minmax(0,1fr)`로 패딩이 있어도 가로 합이 넘치지 않게 함 — **문서(`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`로 본문 블록과 유사한 여백 |
|
|
| 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`로 관리
|
|
- Thred 참고 화면처럼 사이드바와 본문은 같은 화면 안에서 구분되는 패널과 라인으로 표현
|
|
|
|
### 홈 Featured (인덱스)
|
|
|
|
- 가로 카드 트랙은 `overflow-x-auto`와 `snap-x`/`snap-mandatory`로 슬라이드 느낌을 낸다.
|
|
- 모바일 터치: `touch-pan-x`, `-webkit-overflow-scrolling: touch`, `overscroll-x-contain`으로 가로 스크롤 우선·부모로의 스크롤 전파 완화.
|
|
- 헤더의 이전·다음 화살표는 `scrollLeft`와 최대 스크롤 거리로 양 끝에서 `disabled` 처리하며, `scroll` 이벤트와 `ResizeObserver`로 동기화한다.
|
|
|
|
### Post 페이지
|
|
|
|
- 게시물 화면은 레이아웃 그리드의 좌우 패딩을 기본으로 사용하고, 섹션 단위 `px-*`는 내부 래퍼로만 두어 패딩이 2중 적용되지 않게 한다.
|
|
- 댓글 시작 섹션의 구분선(`border-y`)은 패딩 없이 전체 폭으로 표시하고, 내용만 내부 래퍼에 패딩을 둔다.
|
|
- 댓글은 로그인 회원만 작성 가능하며, 대댓글은 1단까지만 허용한다.
|
|
- 공개 게시물 본문은 콘텐츠 타입별 컴포넌트로 분리해 추후 스타일 변경이 쉽도록 구성
|
|
- 제목 우측 공유 버튼을 누르면 게시물 공유 모달을 연다.
|
|
- 공유 모달은 게시물 썸네일/제목/요약 미리보기, X/Bluesky/Facebook/LinkedIn/Email 링크, 링크 복사 액션을 제공한다.
|
|
|
|
### 공개 목록·상세의 발행일 표시
|
|
|
|
- API의 ISO 8601 `publishedAt`를 공개 UI에서는 로컬 날짜 기준 `YYYY.MM.DD`로 표시한다.
|
|
- 변환은 `composables/formatPostDate.js`의 `formatPostDate`를 사용한다.
|
|
- `<time>`에는 표시용 문자열과 함께 가능한 경우 원본 시각을 `datetime` 속성으로 둔다.
|
|
|
|
### Page 페이지
|
|
|
|
- About, Projects, Links, Contact, 서비스 소개 페이지 등 고정 콘텐츠에 사용
|
|
- 기본 게시물 목록에는 노출하지 않음
|
|
- 헤더와 사이드바를 사용하지 않고 본문 중심 전체 화면으로 표시
|
|
- 진입 경로는 추후 메뉴/링크 설정을 통해 연결
|
|
|
|
### 공개 URL 구조
|
|
|
|
- `/posts` - 게시물 전체 목록
|
|
- `/post/:slug` - 개별 게시물 상세
|
|
- `/tags` - 태그 전체 목록
|
|
- `/tag/:slug` - 태그별 게시물 목록
|
|
- `/signup` - 회원가입(3단계: 환영/입력/이메일 확인)
|
|
- `/signin` - 로그인
|
|
- `/settings` - 회원 설정(썸네일, 닉네임, 비밀번호, 회원 탈퇴)
|
|
- 기존 `/posts/:slug`, `/tags/:slug` 상세 경로는 새 단수형 상세 경로로 리다이렉트한다.
|
|
|
|
### 공개 인증 화면(초기)
|
|
|
|
- 회원가입 화면은 AFFiNE 참고 다크 테마 3단계 플로우를 제공한다.
|
|
- 3단계는 인증 메일 발송 안내와 재전송 버튼(쿨다운)을 제공한다.
|
|
- 이메일 링크 확인 전에는 회원가입이 완료되지 않으며, 인증 완료 액션 이후 로그인 화면으로 이동한다.
|
|
- 로그인 화면은 동일한 다크 톤 폼 레이아웃을 사용한다.
|
|
- 로그인·회원가입(2단계) 비밀번호 입력은 `AuthPasswordVisibilityToggle` SVG(눈 열림/가림) 토글로 표시 여부를 바꾼다. 스크린 리더용 `aria-label`은 필드별 `field-name`으로 구분한다. 텍스트 입력은 `.auth-form-input`으로 글자색·캐럿 등을 보정한다.
|
|
- 인증 화면 상태 메시지는 오류/안내를 분리해 `aria-live`로 노출한다.
|
|
- 회원가입 1단계의 타이틀/설명은 `GET /api/site-settings`의 `title`, `description` 값을 우선 사용한다.
|
|
|
|
### 레이아웃 파일
|
|
|
|
```
|
|
layouts/
|
|
├── default.vue # 메인/목록 화면
|
|
├── post.vue # 게시물 화면
|
|
└── admin.vue # 관리자 화면
|
|
```
|
|
|
|
---
|
|
|
|
## 컴포넌트 구조
|
|
|
|
### 사이트 컴포넌트
|
|
|
|
```
|
|
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` (마커 컬러, 간격, 줄높이 통일)
|
|
- 인용구
|
|
- 기본: `> 한 줄` 또는 `>` 연속 여러 줄(멀티라인)
|
|
- 대체 스타일(Alternative): `>>>`로 시작해 `<<<`로 끝나는 블록
|
|
- 렌더링: `ProseBlockquote.vue` (`variant=default|alt`)
|
|
- 이미지
|
|
- 기본: ``
|
|
- 와이드/풀: `{width=wide|full}`
|
|
- 렌더링: `ProseImage.vue` (라운드/보더/패널 배경)
|
|
- 이미지 갤러리
|
|
- `:::gallery` ~ `:::` fenced block 내부에 이미지 마크다운 행을 여러 개 작성
|
|
- 렌더링: `ContentMarkdownRenderer.vue` (그리드 + 라이트박스)
|
|
- 카드류
|
|
- Callout: `:::callout` ~ `:::` (왼쪽 강조선은 `var(--site-accent)`)
|
|
- Toggle: `:::toggle 제목` ~ `:::`
|
|
- Bookmark: `:::bookmark` ~ `:::` (본문은 `url=`, `title=`, `description=`, `thumbnail=` 키값 또는 첫 줄 URL·이어지는 제목/설명 줄)
|
|
- Signup: `:::signup` ~ `:::` (선택: `title=`, `description=`, `button=`, `placeholder=`)
|
|
- Embed: `:::embed` ~ `:::` (YouTube·YouTube Shorts URL은 iframe, `twitter.com`·`x.com` 게시물 URL은 X 공식 embed iframe, 그 외는 외부 링크 카드)
|
|
- 렌더링: `ProseCallout.vue`, `ProseToggle.vue`, `ProseBookmark.vue`, `ProseSignup.vue`, `ProseEmbed.vue`
|
|
|
|
---
|
|
|
|
## 데이터베이스 구조
|
|
|
|
### 환경 분리 원칙
|
|
|
|
- 데이터베이스는 PostgreSQL을 기준으로 한다.
|
|
- 로컬 개발 환경과 NAS 운영 환경은 서로 다른 DB를 사용
|
|
- 로컬 개발 서버는 개발 DB만 연결
|
|
- NAS 배포 환경은 운영 DB만 연결
|
|
- 운영 DB 접속 정보는 로컬 기본 `.env`에 기록하지 않음
|
|
- DB 관리 도구는 CloudBeaver 등을 사용할 수 있도록 접속 정보를 환경별로 분리
|
|
|
|
### Posts (블로그 글)
|
|
|
|
| 필드 | 타입 | 설명 |
|
|
|------|------|------|
|
|
| id | UUID | Primary Key |
|
|
| title | String | 제목 |
|
|
| slug | String | URL 슬러그 |
|
|
| content | Text | 마크다운 콘텐츠 |
|
|
| excerpt | String | 요약 |
|
|
| featured_image | String nullable | 대표 이미지 |
|
|
| seo_title | String | SEO 제목 |
|
|
| seo_description | String | SEO 설명 |
|
|
| canonical_url | String | canonical URL |
|
|
| noindex | Boolean | 검색엔진 노출 제외 여부 |
|
|
| og_image | String nullable | OG 이미지 |
|
|
| status | Enum | published/draft/private |
|
|
| published_at | DateTime | 발행일 |
|
|
| created_at | DateTime | 생성일 |
|
|
| updated_at | DateTime | 수정일 |
|
|
|
|
### Users
|
|
|
|
| 필드 | 타입 | 설명 |
|
|
|------|------|------|
|
|
| id | UUID | Primary Key |
|
|
| username | String | 사용자명 |
|
|
| email | String | 로그인 이메일(유니크) |
|
|
| password_hash | String | bcrypt 해시 비밀번호 |
|
|
| avatar_url | String | 프로필 썸네일 URL |
|
|
| 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 | 수정일 |
|
|
|
|
### Pages (고정 페이지)
|
|
|
|
| 필드 | 타입 | 설명 |
|
|
|------|------|------|
|
|
| id | UUID | Primary Key |
|
|
| title | String | 제목 |
|
|
| slug | String | URL 슬러그 |
|
|
| content | Text | 마크다운 콘텐츠 |
|
|
| featured_image | String nullable | 대표 이미지 |
|
|
| created_at | DateTime | 생성일 |
|
|
| updated_at | DateTime | 수정일 |
|
|
|
|
### Tags
|
|
|
|
| 필드 | 타입 | 설명 |
|
|
|------|------|------|
|
|
| id | UUID | Primary Key |
|
|
| name | String | 태그명 |
|
|
| slug | String | URL 슬러그 |
|
|
| description | String | 설명 |
|
|
| sort_order | Integer | 사용자 화면 표시 순서 |
|
|
| color | String | 태그 색상 코드 |
|
|
| created_at | DateTime | 생성일 |
|
|
| updated_at | DateTime | 수정일 |
|
|
|
|
### SiteSettings
|
|
|
|
| 필드 | 타입 | 설명 |
|
|
|------|------|------|
|
|
| id | Integer | 단일 설정 레코드 ID, 항상 1 |
|
|
| title | String | 사이트 이름 |
|
|
| description | String | 사이트 설명 |
|
|
| site_url | String | 사이트 기본 URL |
|
|
| logo_text | String | 텍스트 로고 |
|
|
| copyright_text | String | 저작권 문구 |
|
|
| updated_at | DateTime | 수정일 |
|
|
|
|
### NavigationItems
|
|
|
|
| 필드 | 타입 | 설명 |
|
|
|------|------|------|
|
|
| id | UUID | Primary Key |
|
|
| label | String | 메뉴 표시 이름 |
|
|
| url | String | 내부 경로 또는 외부 URL |
|
|
| location | Enum | primary/footer |
|
|
| sort_order | Integer | 표시 순서 |
|
|
| is_visible | Boolean | 공개 화면 표시 여부 |
|
|
| created_at | DateTime | 생성일 |
|
|
| updated_at | DateTime | 수정일 |
|
|
|
|
### MediaMetadata
|
|
|
|
| 필드 | 타입 | 설명 |
|
|
|------|------|------|
|
|
| url | String | 업로드 미디어 URL |
|
|
| category | String | 관리자 미디어 폴더 경로 |
|
|
| created_at | DateTime | 생성일 |
|
|
| updated_at | DateTime | 수정일 |
|
|
|
|
### MediaFolders
|
|
|
|
| 필드 | 타입 | 설명 |
|
|
|------|------|------|
|
|
| path | String | 미디어 폴더 경로 |
|
|
| created_at | DateTime | 생성일 |
|
|
| updated_at | DateTime | 수정일 |
|
|
|
|
### PostTags (다대다)
|
|
|
|
| 필드 | 타입 | 설명 |
|
|
|------|------|------|
|
|
| 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/`)
|
|
|
|
- `GET /api/posts` - 게시물 목록
|
|
- `GET /api/posts/:slug` - 게시물 상세
|
|
- `GET /api/posts/:slug/comments` - 게시물 댓글 목록
|
|
- `POST /api/posts/:slug/comments` - 게시물 댓글 작성(회원 세션 필요, 대댓글 1단)
|
|
- `GET /api/pages` - 고정 페이지 목록
|
|
- `GET /api/pages/:slug` - 고정 페이지 상세
|
|
- `GET /api/tags` - 태그 목록
|
|
- `GET /api/search?q=` - 통합 검색(태그 `name`·`slug`, 게시물 `title`·`excerpt`·`content` 부분 일치, 각 최대 12건, 발행 게시물만)
|
|
- `GET /api/site-settings` - 공개 사이트 설정
|
|
- `GET /api/navigation` - 공개 네비게이션
|
|
- `POST /api/auth/signup` - 회원 가입
|
|
- `POST /api/auth/login` - 회원 로그인
|
|
- `GET /api/auth/me` - 현재 회원 세션 조회
|
|
- `POST /api/auth/logout` - 회원 로그아웃
|
|
- `GET /api/auth/profile` - 회원 설정 조회
|
|
- `PUT /api/auth/profile` - 회원 프로필 수정(닉네임, 썸네일)
|
|
- `GET /api/auth/check-username?username=` - 닉네임 중복 확인
|
|
- `PUT /api/auth/password` - 회원 비밀번호 변경
|
|
- `DELETE /api/auth/account` - 회원 탈퇴
|
|
|
|
### 관리자 API (`/admin/api/`)
|
|
|
|
- `POST /admin/api/auth/login` - 로그인
|
|
- `POST /admin/api/auth/logout` - 로그아웃
|
|
- `GET /admin/api/auth/me` - 현재 관리자 세션 조회
|
|
- `GET /admin/api/posts` - 글 목록
|
|
- `POST /admin/api/posts` - 글 작성
|
|
- `GET /admin/api/posts/:id` - 글 상세
|
|
- `PUT /admin/api/posts/:id` - 글 수정
|
|
- `DELETE /admin/api/posts/:id` - 글 삭제
|
|
- `GET /admin/api/pages` - 고정 페이지 목록
|
|
- `POST /admin/api/pages` - 고정 페이지 작성
|
|
- `GET /admin/api/pages/:id` - 고정 페이지 상세
|
|
- `PUT /admin/api/pages/:id` - 고정 페이지 수정
|
|
- `DELETE /admin/api/pages/:id` - 고정 페이지 삭제
|
|
- `GET /admin/api/media` - 업로드 미디어 목록
|
|
- `PUT /admin/api/media` - 업로드 미디어 파일명 변경 또는 단일/복수 미디어 폴더 변경
|
|
- `DELETE /admin/api/media` - 업로드 미디어 삭제
|
|
- `GET /admin/api/media-folders` - 미디어 폴더 목록
|
|
- `POST /admin/api/media-folders` - 미디어 폴더 생성
|
|
- `POST /admin/api/uploads` - 관리자 이미지 업로드
|
|
- `GET /admin/api/tags` - 태그 목록
|
|
- `POST /admin/api/tags` - 태그 생성
|
|
- `GET /admin/api/tags/:id` - 태그 상세
|
|
- `PUT /admin/api/tags/:id` - 태그 수정
|
|
- `DELETE /admin/api/tags/:id` - 태그 삭제
|
|
- `GET /admin/api/settings` - 사이트 설정 조회
|
|
- `PUT /admin/api/settings` - 사이트 설정 수정
|
|
- `GET /admin/api/navigation` - 네비게이션 항목 목록
|
|
- `PUT /admin/api/navigation` - 네비게이션 항목 일괄 저장
|
|
- `GET /admin/api/members` - 회원 목록(최근 접속, 접속 IP, 댓글 수 포함)
|
|
|
|
> 글 발행/초안/비공개 전환은 현재 `PUT /admin/api/posts/:id`의 `status` 값으로 처리한다.
|
|
> 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다.
|
|
> 태그 목록은 `sort_order ASC, name ASC` 기준으로 정렬한다.
|
|
> 태그 `color`는 `#RRGGBB` 형식이며 사용자 화면 태그 색상 표시와 배지 배경색에 사용한다.
|
|
|
|
### 관리자 글 편집
|
|
|
|
- 글 작성/수정 화면은 Ghost 스타일을 참고한 블록형 에디터를 사용한다.
|
|
- 저장 데이터는 기존 `content` 필드의 마크다운 문자열을 유지한다.
|
|
- `/` 입력 시 블록 선택 메뉴를 표시한다.
|
|
- `/` 명령 메뉴는 화면 하단 공간이 부족하면 현재 블록 위쪽으로 표시한다.
|
|
- `/` 명령 메뉴가 열린 블록 행은 아래 블록보다 위 stacking 순서로 표시해 메뉴와 본문 텍스트가 겹쳐 보이지 않게 한다.
|
|
- `/` 명령 메뉴가 열린 상태에서 Enter를 누르면 현재 강조된 메뉴 항목을 적용한다.
|
|
- `/` 명령 메뉴가 열린 상태에서 위/아래 방향키로 강조 항목을 이동한다.
|
|
- `/` 명령 메뉴 필터는 한글 조합 입력 완료와 방향키/Enter 입력 직전에 현재 DOM 텍스트를 기준으로 동기화한다.
|
|
- `/갤`처럼 필터 결과가 하나로 좁혀진 상태에서 Enter를 누르면 해당 블록 명령을 적용한다.
|
|
- 블록 메뉴는 문단, 제목 2, 제목 3, 이미지, 갤러리, 콜아웃, 토글, 임베드, 인용, 목록, 코드, 구분선을 제공한다.
|
|
- `#`, `##`, `###`, `>`, `-` 입력 후 공백을 누르거나 ` ``` `을 입력하면 현재 블록 타입을 즉시 변환한다.
|
|
- 한글 등 조합형 입력 중에는 단축 문법과 슬래시 메뉴 상태를 확정하지 않고 조합 종료 뒤 반영한다.
|
|
- 한글 등 조합형 입력 직후 확정된 텍스트로 슬래시 메뉴 필터와 Enter 블록 이동을 반영한다.
|
|
- 한글 등 조합형 입력 중 Shift+Enter가 들어오면 조합 완료 직후 줄바꿈을 예약 적용한다.
|
|
- Shift+Enter는 같은 텍스트 블록 안에 줄바꿈 문자를 직접 삽입하고 커서를 줄바꿈 뒤로 유지하며, Enter는 다음 문단 블록을 생성한다.
|
|
- 빈 문단에서 Enter를 누르면 다음 빈 문단 블록을 생성한다.
|
|
- 문단 간 기본 간격은 다음 블록의 `margin-top: 32px` 기준으로 조정한다.
|
|
- 블록 왼쪽 핸들은 hover/focus 상태에서 AFFiNE 참고 스타일의 세로 막대로 표시되며, hover 시 해당 블록 높이만큼 확장해 선택 범위를 드러낸다.
|
|
- 블록 왼쪽 핸들을 클릭하면 블록을 선택하고 Delete 또는 Backspace로 해당 블록을 삭제할 수 있다.
|
|
- 블록 왼쪽 핸들을 드래그하면 블록 순서를 이동할 수 있다.
|
|
- 블록 드래그 중에는 현재 포인터 위치 기준으로 대상 블록 위 또는 아래에 삽입선을 표시하고, 드롭 또는 드래그 종료 시 표시 위치와 같은 곳으로 이동한다.
|
|
- 빈 블록 placeholder는 현재 활성 블록 또는 첫 빈 블록에만 표시한다.
|
|
- 에디터 마지막에는 클릭 가능한 빈 문단 블록을 항상 유지하며, 해당 블록이 비어 있으면 저장 콘텐츠에는 포함하지 않는다.
|
|
- 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다.
|
|
- 글 작성/수정 화면은 페이지 제목 헤더를 별도로 두지 않고, 폼 상단의 Ghost 스타일 도구막대에서 목록 이동, 상태, 미리보기, 저장, 설정 패널 토글을 제공한다.
|
|
- 글 작성/수정 화면은 관리자 사이드바 네비게이션을 숨기고, 관리자 공통 내부 패딩 없이 전체 화면 편집 모드로 표시한다.
|
|
- 글 작성/수정 화면은 브라우저 문서가 아니라 에디터 작업 영역 내부에서만 세로 스크롤한다.
|
|
- 글 작성/수정 폼은 에디터 작업 영역과 우측 설정 패널을 먼저 좌우로 나누고, 에디터 작업 영역 안에 상단 도구막대와 본문 스크롤 영역을 배치한다.
|
|
- 본문 에디터는 데스크톱에서 좌우 32px 패딩을 포함한 804px 중앙 컬럼으로 배치해 실제 입력 영역 최대 폭을 740px로 유지한다.
|
|
- 게시물 설정은 데스크톱에서 420px 우측 패널로 표시하며, 도구막대 버튼으로 열고 닫을 수 있고 너비 전환 애니메이션을 적용한다.
|
|
- 공개 가능한 글의 보기 액션은 게시물 설정 패널의 Post URL 라벨 오른쪽에 표시한다.
|
|
- 글 삭제 액션은 게시물 설정 패널 하단의 빨간 outline 버튼으로 제공한다.
|
|
- 대표 이미지는 본문 제목 위의 에디터 흐름 안에서 추가, 변경, 삭제한다.
|
|
- 대표 이미지가 설정된 상태의 변경/삭제 액션은 실제 이미지 레이아웃을 바꾸지 않도록 이미지 hover 또는 focus 오버레이로 표시한다.
|
|
- 대표 이미지 선택 모달은 이미지 업로드 탭과 미디어 라이브러리 탭을 제공한다.
|
|
- 제목 입력에서 Enter를 누르면 본문 첫 블록으로 포커스를 이동한다.
|
|
- 새 글 작성처럼 본문이 비어 있는 경우에도 빈 문단 블록을 먼저 생성한다.
|
|
- 글 작성/수정 중인 입력값은 브라우저 `localStorage`에 자동 저장한다.
|
|
- 자동 저장 키는 `SORI_ADMIN_POST_AUTOSAVE:new` 또는 `SORI_ADMIN_POST_AUTOSAVE:{postId}` 형식을 사용한다.
|
|
- 자동 저장본이 있으면 작성 화면에서 복원 또는 삭제를 선택할 수 있다.
|
|
- 글 저장 성공 시 해당 자동 저장본은 삭제한다.
|
|
- 미리보기 버튼은 현재 작성 폼 값을 `SORI_ADMIN_POST_PREVIEW` 브라우저 저장소에 기록한 뒤 `/admin/posts/preview` 새 탭에서 렌더링한다.
|
|
- 미리보기 화면은 공개 게시물 상세와 같은 본문 렌더링 컴포넌트를 사용하되, 데이터베이스에는 임시 글을 저장하지 않는다.
|
|
- 글 저장/수정/삭제 진행과 성공/실패 상태는 화면 우측 상단 토스트로 표시한다.
|
|
- 발행 상태에서 발행 시각을 미래로 지정하면 예약 발행으로 저장한다.
|
|
- 예약 발행 글은 관리자 목록에서 예약 상태로 표시하되 공개 게시물 목록과 상세 API에는 발행 시각 이후부터 노출한다.
|
|
- 관리자 글 에디터의 실제 입력 텍스트 색상은 placeholder보다 진하게 표시한다.
|
|
- 관리자 작성 화면과 공개 본문은 같은 마크다운 렌더링 기준을 사용한다.
|
|
- 태그 입력은 배지형 입력으로 제공하며 Enter 또는 쉼표 입력 시 태그를 추가하고, 배지의 x 버튼으로 삭제한다.
|
|
- 대표 이미지는 URL 직접 입력이 아니라 미디어 선택 또는 이미지 업로드 탭으로 설정한다.
|
|
- 대표 이미지 선택 모달에서 미디어를 클릭하면 선택 상태만 표시하고, 하단 대표 이미지로 적용 버튼을 눌렀을 때 글 폼에 반영한다.
|
|
- 대표 이미지가 설정되면 관리자 글 폼에 썸네일과 hover 오버레이 삭제/변경 액션을 표시한다.
|
|
- 글 SEO 설정은 SEO 제목, SEO 설명, 검색엔진 노출 제외 여부를 저장한다.
|
|
- Canonical URL은 별도 입력을 받지 않고 기본 글 주소를 사용한다.
|
|
- 공개 게시물 상세 화면은 SEO 제목이 없으면 글 제목, SEO 설명이 없으면 요약을 메타 태그 기본값으로 사용한다.
|
|
- 검색엔진 노출 제외가 켜진 글은 robots 메타를 `noindex, nofollow`로 출력한다.
|
|
- 공개 상세 화면의 `og:image`와 Twitter large image 카드는 대표 이미지를 기본값으로 사용한다.
|
|
- 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `{width=wide}` 형식으로 저장한다.
|
|
- 이미지/갤러리 삽입 시 파일명은 alt 값으로 자동 입력하지 않는다.
|
|
- 이미지 블록 alt 입력은 이미지 hover 또는 focus 상태에서만 표시한다.
|
|
- 이미지 블록 표시 옵션은 `regular`, `wide`, `full` 값을 사용하며 `regular`는 width 옵션을 생략한다.
|
|
- 갤러리 블록은 `:::gallery` fenced block 안에 이미지 마크다운 행을 여러 개 저장한다.
|
|
- 공개 갤러리는 한 줄에 2~3개 이미지 그리드로 표시하고 클릭 시 라이트박스로 크게 확인한다.
|
|
- 이미지와 갤러리 블록은 기존 업로드 미디어 선택 또는 새 이미지 업로드를 제공한다.
|
|
- 콜아웃 블록은 `:::callout` fenced block 안에 본문을 저장한다.
|
|
- 토글 블록은 `:::toggle 제목` fenced block 안에 펼침 본문을 저장한다.
|
|
- 임베드 블록은 `:::embed` fenced block 안에 URL을 저장한다.
|
|
- YouTube 임베드 URL은 공개 화면에서 iframe으로 렌더링한다.
|
|
- Twitter/X 게시물 URL(`twitter.com`·`x.com`·`mobile.twitter.com`, 경로에 `status` 포함)은 `platform.twitter.com/embed/Tweet.html` iframe으로 렌더링하며, 테마는 `useThemeMode()`와 동기화한다.
|
|
- 그 외 URL은 외부 링크 텍스트 카드로 표시한다.
|
|
- 북마크 블록은 `:::bookmark` fenced block으로 저장할 수 있으며 공개 화면에서 Thred형 가로 카드로 렌더링한다.
|
|
- 회원가입(뉴스레터) CTA는 `:::signup` fenced block으로 저장할 수 있으며 실제 폼 연동은 후속 작업으로 분리한다.
|
|
|
|
### 관리자 페이지 편집
|
|
|
|
- 고정 페이지 작성/수정 화면은 게시물과 같은 블록형 에디터를 사용한다.
|
|
- 고정 페이지는 제목, 슬러그, 본문, 대표 이미지만 저장한다.
|
|
- 고정 페이지는 게시물 목록과 태그 목록에 노출하지 않는다.
|
|
- 고정 페이지 공개 보기 경로는 `/pages/:slug`를 사용한다.
|
|
|
|
### 사이트 설정
|
|
|
|
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
|
|
- 관리자는 사이트 이름, 설명, 사이트 URL, 텍스트 로고, 저작권 문구를 수정할 수 있다.
|
|
- 공개 헤더와 오른쪽 사이드바는 공개 사이트 설정 API 값을 사용한다.
|
|
- DB 연결이 없는 환경에서는 환경 변수와 기본값 기반 설정을 사용한다.
|
|
|
|
### 메뉴/네비게이션
|
|
|
|
- 네비게이션은 `navigation_items` 테이블로 관리한다.
|
|
- 관리자는 메뉴 라벨, URL, 위치, 순서, 표시 여부를 수정할 수 있다.
|
|
- 공개 왼쪽 사이드바의 상단 메뉴는 `primary` 위치 항목을 사용한다.
|
|
- 공개 왼쪽 사이드바 하단 메뉴는 `footer` 위치 항목을 사용한다.
|
|
- URL은 `/`로 시작하는 내부 경로 또는 `http://`, `https://` 외부 URL을 허용한다.
|
|
|
|
### 관리자 인증
|
|
|
|
- 초기 관리자 인증은 `ADMIN_EMAIL`, `ADMIN_PASSWORD` 환경 변수를 사용
|
|
- 로그인 성공 시 httpOnly 세션 쿠키를 `/admin` 경로에 설정
|
|
- 관리자 페이지 접근은 `/admin/api/auth/me` 확인 후 허용
|
|
- 세션 토큰은 `ADMIN_PASSWORD` 기반 HMAC 서명으로 검증
|
|
|
|
### 회원 인증
|
|
|
|
- 회원 인증은 `users` 테이블의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다.
|
|
- 로그인 성공 시 httpOnly 세션 쿠키(`sori_member_session`)를 `/` 경로에 설정한다.
|
|
- 회원 API(`POST /api/auth/signup`, `POST /api/auth/login`, `GET /api/auth/me`, `POST /api/auth/logout`)로 세션을 관리한다.
|
|
- 회원 세션 서명은 `MEMBER_SESSION_SECRET`을 우선 사용하고, 값이 없으면 개발 편의를 위해 `ADMIN_PASSWORD`를 fallback으로 사용한다.
|
|
|
|
---
|
|
|
|
## 미디어 관리
|
|
|
|
### 업로드 경로 규칙
|
|
|
|
```
|
|
/uploads/posts/YYYY/MM/filename.webp
|
|
/uploads/pages/YYYY/MM/filename.webp
|
|
/uploads/system/logo.png
|
|
/uploads/system/favicon.png
|
|
```
|
|
|
|
- 관리자 이미지 업로드 API는 `image/jpeg`, `image/png`, `image/webp`, `image/gif`만 허용한다.
|
|
- 업로드 파일 크기 제한은 `MAX_FILE_SIZE` 환경 변수를 따른다.
|
|
- 로컬 개발 업로드 파일은 `public/uploads/posts/YYYY/MM/` 아래 저장하고 `/uploads/posts/YYYY/MM/filename` URL로 제공한다.
|
|
- 관리자 미디어 화면은 왼쪽 폴더 트리와 오른쪽 고밀도 썸네일 갤러리, 검색, 파일명 변경, 개별 삭제를 제공한다.
|
|
- 관리자는 폴더 트리에서 새 폴더와 하위 폴더를 만들 수 있다.
|
|
- 미디어는 Ctrl/Command 클릭으로 복수 선택하고 Shift 클릭으로 범위 선택할 수 있다.
|
|
- 선택한 미디어를 폴더로 드래그하면 해당 미디어들의 폴더 경로가 일괄 변경된다.
|
|
- 미디어 파일 경로, 사용 현황, 용량 등 세부 정보는 썸네일 더블 클릭 시 열리는 상세 모달에서 표시한다.
|
|
- 미디어 폴더는 실제 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별 경로 메타데이터로 저장한다.
|
|
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
|
|
- 미디어 사용 현황은 게시물/페이지의 대표 이미지와 본문 내 URL을 기준으로 표시한다.
|
|
- 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다.
|
|
- 향후 미디어 라이브러리는 프로필/사이트 설정 이미지 사용처 추적을 제공한다.
|
|
|
|
---
|
|
|
|
## 환경 변수 (.env)
|
|
|
|
### 공통 키
|
|
|
|
```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
|
|
|
|
# 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
|
|
```
|
|
|
|
### 환경 파일 기준
|
|
|
|
| 파일 | 용도 | 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.58
|
|
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
|
|
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정
|