Files
sori.studio/docs/spec.md
zenn bd0e2ad120 관리자 블록 에디터 범위 선택 보완 및 복사 시 네이티브 우선(v1.0.8)
블록 범위가 있어도 contenteditable 비접힘 선택·textarea/input 선택 시 copy 가로채기 생략.
문서·버전 v1.0.8 반영.
2026-05-14 14:42:08 +09:00

58 KiB

sori.studio 기술 명세

프로젝트 개요

  • 프로젝트명: sori.studio
  • 유형: 커스텀 블로그/CMS
  • 목표: 개인 블로그 중심 운영, 기존 포털성 링크와 서비스 진입점은 블로그 내부 구조에 통합
  • 참조: Ghost(관리자 UX/글쓰기), Thred 테마(사용자 화면)
  • 현재 상태: Nuxt 3.21(SSR)·PostgreSQL 저장소 계층 구성 완료. Node가 SSR 번들의 #internal/nuxt/paths를 해석하도록 루트 package.json importsmodules/nuxt-ssr-paths-write.mjs(.nuxt/paths.mjs 디스크 기록)을 둔다.
  • 원격 저장소: https://git.sori.studio/zenn/sori.studio.git
  • 스타일: Tailwind 엔트리는 assets/css/main.css 한 곳(nuxt.configtailwindcss.cssPath)이며, tailwind.config.jscontent가 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_STATEopen 또는 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 참고 화면처럼 사이드바와 본문은 같은 화면 안에서 구분되는 패널과 라인으로 표현
  • 가로 카드 트랙은 overflow-x-autosnap-x/snap-mandatory로 슬라이드 느낌을 낸다.
  • 모바일 터치: touch-pan-x, -webkit-overflow-scrolling: touch, overscroll-x-contain으로 가로 스크롤 우선·부모로의 스크롤 전파 완화.
  • 헤더의 이전·다음 화살표는 scrollLeft와 최대 스크롤 거리로 양 끝에서 disabled 처리하며, scroll 이벤트와 ResizeObserver로 동기화한다.

Post 페이지

  • 게시물 화면은 레이아웃 그리드의 좌우 패딩을 기본으로 사용하고, 섹션 단위 px-*는 내부 래퍼로만 두어 패딩이 2중 적용되지 않게 한다.
  • 댓글 시작 섹션의 구분선(border-y)은 패딩 없이 전체 폭으로 표시하고, 내용만 내부 래퍼에 패딩을 둔다.
  • 본문 끝과 댓글 섹션 시작 사이 간격은 48px(mt-12)로 유지한다.
  • 댓글은 로그인 회원만 작성 가능하며, 대댓글은 1단까지만 허용한다.
  • 댓글은 작성자 썸네일(없으면 이니셜)과 좋아요 수를 표시한다.
  • 댓글 시간은 24시간 이내일 때 상대 시간(분/시간 전), 이후에는 날짜로 표시한다.
  • 댓글 정렬은 Best(좋아요 우선), Latest, Oldest를 제공한다.
  • 댓글 아바타 이미지 로드 실패 시 이니셜 아바타로 즉시 대체한다.
  • 공개 게시물 본문은 콘텐츠 타입별 컴포넌트로 분리해 추후 스타일 변경이 쉽도록 구성
  • 제목 우측 공유 버튼을 누르면 게시물 공유 모달을 연다.
  • 공유 모달은 게시물 썸네일/제목/요약 미리보기, X/Bluesky/Facebook/LinkedIn/Email 링크, 링크 복사 액션을 제공한다.

공개 목록·상세의 발행일 표시

  • API의 ISO 8601 publishedAt를 공개 UI에서는 로컬 날짜 기준 YYYY.MM.DD로 표시한다.
  • 변환은 composables/formatPostDate.jsformatPostDate를 사용한다.
  • <time>에는 표시용 문자열과 함께 가능한 경우 원본 시각을 datetime 속성으로 둔다.

Page 페이지

  • About, Projects, Links, Contact, 서비스 소개 페이지 등 고정 콘텐츠에 사용
  • 기본 게시물 목록에는 노출하지 않음
  • 헤더와 사이드바를 사용하지 않고 본문 중심 전체 화면으로 표시
  • 진입 경로는 추후 메뉴/링크 설정을 통해 연결

공개 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단계) 비밀번호 입력은 AuthPasswordVisibilityToggle SVG(눈 열림/가림) 토글로 표시 여부를 바꾼다. 스크린 리더용 aria-label은 필드별 field-name으로 구분한다. 텍스트 입력은 .auth-form-input으로 글자색·캐럿 등을 보정한다.
  • 인증 화면 상태 메시지는 오류/안내를 분리해 aria-live로 노출한다.
  • 회원가입 1단계의 타이틀/설명은 GET /api/site-settingstitle, description 값을 우선 사용한다.
  • 회원 세션 쿠키 서명에는 MEMBER_SESSION_SECRET만 사용하며, 관리자 비밀번호를 fallback으로 쓰지 않는다.

레이아웃 파일

layouts/
├── default.vue    # 메인/목록 화면
├── post.vue       # 게시물 화면
└── admin.vue      # 관리자 화면

관리자 레이아웃

  • 관리자 사이드바는 밝은 Ghost형 톤을 기준으로 하며, 메뉴 행은 아이콘+라벨 구조를 사용한다.
  • 관리자 사이드바는 데스크톱에서 320px 너비를 사용한다.
  • 관리자 우측 캔버스는 기본 min-h-screenbg-paper를 유지해 콘텐츠 높이가 짧아도 배경이 화면 전체를 채운다. 일반 관리자 화면은 레이아웃 레벨에서 넉넉한 외부 여백을 둔다.
  • 게시글 메뉴 라벨은 게시글로 표시하고, 우측 + 아이콘은 /admin/posts/new로 바로 이동한다.
  • 메뉴 관리 항목은 네비게이션으로 표시한다.
  • 게시글·페이지·태그·미디어·네비게이션·멤버·설정 메뉴는 전용 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 (마커 컬러, 간격, 줄높이 통일)
  • 인용구
    • 기본: > 한 줄 또는 > 연속 여러 줄(멀티라인)
    • 대체 스타일(Alternative): >>>로 시작해 <<<로 끝나는 블록
    • 렌더링: ProseBlockquote.vue (variant=default|alt)
  • 이미지
    • 기본: ![alt](url)
    • 와이드/풀: ![alt](url){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만 연결
  • 운영 환경(NODE_ENV=production)에서는 DATABASE_URL 누락 시 샘플 콘텐츠로 대체하지 않고 서버 오류로 즉시 실패
  • Docker Compose는 전용 브리지 네트워크를 사용하며 기본 subnet은 DOCKER_SUBNET(10.250.50.0/24)으로 관리
  • 운영 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
is_admin Boolean 관리자 권한 여부
user_role Enum 권한 단계(owner/admin/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 마크다운 콘텐츠
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 태그 색상 코드
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
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단)
  • 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 - 공개 사이트 설정
  • GET /api/navigation - 공개 네비게이션(primary는 트리·footer는 평면, 상세는 위 메뉴/네비게이션 절)
  • POST /api/auth/signup - 회원 가입. 본문: username, email, password, 선택 emailOtp(6자리 숫자). GET /api/auth/bootstrap-statusemailOtpConfigured가 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_reset OTP 검증·소진 후 해당 이메일 회원 비밀번호를 갱신한다.
  • POST /api/auth/login - 회원 로그인
  • GET /api/auth/me - 현재 회원 세션 조회
  • 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)가 포함될 수 있다.

관리자 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 - 업로드 미디어 목록(게시물용 이미지와 회원 아바타 포함; 회원 아바타에는 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 등 넘버링)
  • 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 - 사이트 설정 조회
  • PUT /admin/api/settings - 사이트 설정 수정
  • 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
  • PUT /admin/api/members/:id/role - 회원 권한 변경(owner/admin/member)

글 발행/초안/비공개 전환은 현재 PUT /admin/api/posts/:idstatus 값으로 처리한다. 관리자 글 목록의 삭제 액션은 휴지통 아이콘으로 표시하며, 기본은 낮은 강조로 두고 호버 시에만 색·불투명도를 올린다. 태그 삭제 시 post_tags 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다. 공개 GET /api/tagsmanaged(메인 태그)만 반환한다. 관리자 태그 목록은 managed 우선, sort_order ASC, name ASC 기준으로 정렬한다. 메인 태그 순서 저장은 드래그 순서를 받아 sort_order를 순차 값으로 다시 저장한다. 메인 태그 순서가 서버에서 불러온 순서와 같을 때는 정렬 저장 버튼이 비활성화된다. 게시물 작성에서 새로 생기는 태그는 기본적으로 general(일반 태그)로 생성한다. 메인 태그는 목록에서 일반 태그로 변경 액션으로 강등하며, 일반 태그 삭제는 검색 결과에서만 수행한다. 태그 관리 화면에서 순서 저장·메인 전환·강등·삭제 등의 성공·실패 피드백은 우측 상단 토스트로 표시한다. 태그 color#RRGGBB 형식이며 사용자 화면 태그 색상 표시와 배지 배경색에 사용한다.

관리자 글 편집

  • 글 작성/수정 화면은 Ghost 스타일을 참고한 블록형 에디터를 사용한다. 텍스트 블록마다 별도 contenteditable을 쓰므로, 브라우저는 편집 호스트 경계를 넘는 드래그 선택을 허용하지 않는다(한 블록 안에서만 연속 선택).
  • Cmd/Ctrl+A(Mac은 Cmd, Windows/Linux는 Ctrl)는 현재 블록만 전체 선택되는 대신, 저장 형식인 전체 본문 마크다운을 클립보드에 복사하고 짧은 안내 문구를 표시한다. 다른 편집기·파일로 옮길 때 사용한다.
  • 여러 줄이거나 제목·인용·목록·펜스 코드·콜아웃/갤러리 등 마크다운으로 인식되는 한 줄을 텍스트 블록에 붙여넣으면, 기본 한 블록 삽입 대신 parseMarkdownToBlocks로 나눈 여러 블록을 현재 커서 위치에 끼워 넣는다. 클립보드에 파일이 있으면 기본 붙여넣기(이미지 등)를 유지한다.
  • 블록 단위 범위 선택: 각 행 왼쪽(핸들 오른쪽) 좁은 레인에서 포인터 드래그로 시작·끝 블록을 지정하거나, Shift+클릭으로 끝 블록을 지정한다. 텍스트 블록에서 **Shift+↑/↓**는 경계에 있을 때 범위를 시작하거나, 이미 범위가 있으면 포커스 쪽 끝 블록 인덱스를 한 칸씩 늘리거나 줄인다. Escape로 범위를 해제한다. 범위가 있을 때 Cmd/Ctrl+C 또는 복사(copy)는 text/plain선택 구간만 마크다운으로 넣는다. 다만 한 블록의 contenteditable 안에서 비접힘 텍스트 선택이 있거나 textarea/input에 선택 구간이 있으면 복사는 브라우저 기본 동작(선택된 문자열 등)을 따른다. 범위가 있을 때 Cmd/Ctrl+A는 전체가 아니라 선택 구간 마크다운을 클립보드에 복사한다. 블록 삭제·드래그 순서 변경·마크다운 분할 붙여넣기 등으로 editorBlocks 순서가 바뀌면 범위 선택은 자동으로 해제된다.
  • 저장 데이터는 기존 content 필드의 마크다운 문자열을 유지한다.
  • / 입력 시 블록 선택 메뉴를 표시한다.
  • / 명령 메뉴는 화면 하단 공간이 부족하면 현재 블록 위쪽으로 표시한다.
  • / 명령 메뉴가 열린 블록 행은 아래 블록보다 위 stacking 순서로 표시해 메뉴와 본문 텍스트가 겹쳐 보이지 않게 한다.
  • / 명령 메뉴가 열린 상태에서 Enter를 누르면 현재 강조된 메뉴 항목을 적용한다.
  • / 명령 메뉴가 열린 상태에서 위/아래 방향키로 강조 항목을 이동한다.
  • / 명령 메뉴의 검색어가 바뀌지 않은 경우에는 현재 강조 인덱스를 유지해 연속 방향키 이동이 가능해야 한다.
  • / 명령 메뉴 필터는 한글 조합 입력 완료와 방향키/Enter 입력 직전에 현재 DOM 텍스트를 기준으로 동기화한다.
  • 슬래시 메뉴 방향키 이동 로직은 현재 블록 텍스트가 /로 시작할 때만 동작한다.
  • 슬래시 메뉴는 화면 높이에 맞춰 최대 높이를 제한하고, 넘치는 항목은 내부 스크롤로 표시한다.
  • 슬래시 메뉴 방향키 이동 시 현재 선택 항목이 스크롤 영역 안에 유지되도록 자동 스크롤한다.
  • 일반 본문 블록에서는 위/아래 방향키 입력 시 커서가 블록 시작/끝에 도달하면 인접 블록으로 커서를 이동한다.
  • 관리자 에디터에서 의도적으로 만든 빈 문단은 <!--sori:blank-paragraph--> 마커로 저장해 저장/재진입 후에도 유지한다.
  • 공개 본문 렌더러는 빈 문단 마커를 빈 문단 블록으로 파싱해 문단 간 추가 여백 의도를 유지한다.
  • /갤처럼 필터 결과가 하나로 좁혀진 상태에서 Enter를 누르면 해당 블록 명령을 적용한다.
  • 블록 메뉴는 문단, 제목 2, 제목 3, 이미지, 갤러리, 콜아웃, 토글, 임베드, 인용, 목록, 코드, 구분선을 제공한다.
  • #, ##, ###, >, - 입력 후 공백을 누르거나 ```을 입력하면 현재 블록 타입을 즉시 변환한다.
  • 한글 등 조합형 입력 중에는 단축 문법과 슬래시 메뉴 상태를 확정하지 않고 조합 종료 뒤 반영한다.
  • 한글 등 조합형 입력 직후 확정된 텍스트로 슬래시 메뉴 필터와 Enter 블록 이동을 반영한다.
  • 한글 등 조합형 입력 중 Shift+Enter가 들어오면 조합 완료 직후 줄바꿈을 예약 적용한다.
  • Shift+Enter는 같은 텍스트 블록 안에 줄바꿈 문자를 직접 삽입하고 커서를 줄바꿈 뒤로 유지하며, Enter는 다음 문단 블록을 생성한다.
  • 빈 문단에서 Enter를 누르면 다음 빈 문단 블록을 생성한다.
  • 문단 간 기본 간격은 다음 블록의 margin-top: 32px 기준으로 조정한다.
  • 블록 왼쪽 핸들은 hover/focus 상태에서 AFFiNE 참고 스타일의 세로 막대로 표시되며, hover 시 해당 블록 높이만큼 확장해 선택 범위를 드러낸다.
  • 블록 왼쪽 핸들을 클릭하면 블록을 선택하고 Delete 또는 Backspace로 해당 블록을 삭제할 수 있다.
  • 블록 왼쪽 핸들을 드래그하면 블록 순서를 이동할 수 있다.
  • 블록 드래그 중에는 현재 포인터 위치 기준으로 대상 블록 위 또는 아래에 삽입선을 표시하고, 드롭 또는 드래그 종료 시 표시 위치와 같은 곳으로 이동한다.
  • 빈 블록 placeholder는 현재 활성 블록 또는 첫 빈 블록에만 표시한다.
  • 에디터 마지막에는 클릭 가능한 빈 문단 블록을 항상 유지하며, 해당 블록이 비어 있으면 저장 콘텐츠에는 포함하지 않는다.
  • 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다.
  • 글 작성/수정 화면은 페이지 제목 헤더를 별도로 두지 않고, 폼 상단의 Ghost 스타일 도구막대에서 목록 이동, 상태, 미리보기, 저장, 설정 패널 토글을 제공한다.
  • 글 작성/수정 화면의 저장 버튼은 즉시 저장하지 않고, 전체 화면 발행 모달에서 상태(발행/초안/비공개)와 발행 시점(즉시/예약)을 최종 선택한 뒤 확정한다. 모달에서는 행마다 접힌 상태로 현재 선택 요약만 보이고, 행을 눌러 펼친 뒤 버튼으로 값을 고른다. 게시 상태 행에는 Ghost와 같은 종이비행기 아이콘·펼침 화살표 SVG를 쓰고, 발행 시점 행에는 시계 아이콘·동일 화살표를 쓴다. 발행이 아닌 상태에서는 발행 시점 행 자체를 숨긴다.
  • 글 작성/수정 화면의 저장 버튼은 현재 입력값이 마지막 저장 기준점과 다를 때만 활성화한다.
  • 글 작성/수정 화면은 관리자 사이드바 네비게이션을 숨기고, 관리자 공통 내부 패딩 없이 전체 화면 편집 모드로 표시한다.
  • 글 작성/수정 화면은 브라우저 문서가 아니라 에디터 작업 영역 내부에서만 세로 스크롤한다.
  • 글 작성/수정 폼은 에디터 작업 영역과 우측 설정 패널을 먼저 좌우로 나누고, 에디터 작업 영역 안에 상단 도구막대와 본문 스크롤 영역을 배치한다.
  • 본문 에디터는 데스크톱에서 좌우 32px 패딩을 포함한 804px 중앙 컬럼으로 배치해 실제 입력 영역 최대 폭을 740px로 유지한다.
  • 게시물 설정은 데스크톱에서 420px 우측 패널로 표시하며, 도구막대 버튼으로 열고 닫을 수 있고 너비 전환 애니메이션을 적용한다.
  • 공개 가능한 글의 보기 액션은 게시물 설정 패널의 Post URL 라벨 오른쪽에 표시한다.
  • 글 삭제 액션은 게시물 설정 패널 하단에 기본 중립 톤 버튼으로 제공하고 hover 시 위험 색상으로 강조한다.
  • 대표 이미지는 본문 제목 위의 에디터 흐름 안에서 추가, 변경, 삭제한다.
  • 대표 이미지가 설정된 상태의 변경/삭제 액션은 실제 이미지 레이아웃을 바꾸지 않도록 이미지 hover 또는 focus 오버레이로 표시한다.
  • 대표 이미지 선택 모달은 이미지 업로드 탭과 미디어 라이브러리 탭을 제공한다.
  • 제목 입력에서 Enter를 누르면 본문 첫 블록으로 포커스를 이동한다.
  • 새 글 작성처럼 본문이 비어 있는 경우에도 빈 문단 블록을 먼저 생성한다.
  • 글 작성/수정 중인 입력값은 브라우저 localStorage에 자동 저장한다.
  • 자동 저장 키는 SORI_ADMIN_POST_AUTOSAVE:new 또는 SORI_ADMIN_POST_AUTOSAVE:{postId} 형식을 사용한다.
  • 자동 저장본이 있으면 상단 툴바의 상태 문구 옆에서 복원 또는 무시(로컬 초안 삭제)를 선택할 수 있다.
  • 글 저장 성공 시 해당 자동 저장본은 삭제한다.
  • 미리보기 버튼은 현재 작성 폼 값을 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 설명이 없으면 요약을 메타 태그 기본값으로 사용한다.
  • 검색엔진 노출 제외가 켜진 글은 robots 메타를 noindex, nofollow로 출력한다.
  • 공개 상세 화면의 og:image와 Twitter large image 카드는 대표 이미지를 기본값으로 사용한다.
  • 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 ![alt](url){width=wide} 형식으로 저장한다.
  • 이미지/갤러리 삽입 시 파일명은 alt 값으로 자동 입력하지 않는다.
  • 이미지 블록 alt 입력은 이미지 hover 또는 focus 상태에서만 표시한다.
  • 이미지 블록 표시 옵션은 regular, wide, full 값을 사용하며 regular는 width 옵션을 생략한다.
  • 갤러리 블록은 :::gallery fenced block 안에 이미지 마크다운 행을 여러 개 저장한다.
  • 관리자 갤러리 미디어 선택은 여러 이미지를 선택한 뒤 확인 버튼으로 적용한다.
  • 관리자 갤러리 블록은 이미지 1개일 때 1열, 2개일 때 2열, 3개 이상일 때 3열로 표시한다.
  • 관리자 갤러리 블록의 이미지 순서는 드래그 앤 드롭으로 변경하며, 드래그 중 삽입 위치를 이미지 사이 라인으로 표시한다.
  • 공개 갤러리는 한 줄에 2~3개 이미지 그리드로 표시하고 클릭 시 라이트박스로 크게 확인한다.
  • 이미지와 갤러리 블록은 기존 업로드 미디어 선택 또는 새 이미지 업로드를 제공한다.
  • 콜아웃 블록은 :::callout fenced block 안에 본문을 저장한다.
  • 콜아웃 블록은 선언부 옵션으로 emoji, bg를 저장할 수 있다. 예: :::callout emoji=💡 bg=blue
  • emoji=none이면 공개 렌더러에서 이모지를 숨긴다.
  • 콜아웃 배경 프리셋은 gray, blue, green, yellow, red, purple, pink를 지원한다.
  • 토글 블록은 :::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, 로고 이미지, 저작권 문구를 수정할 수 있다.
  • 로고 이미지는 1:1 비율로 저장하며 /admin/api/settings/logo 업로드 시 /uploads/system/logo.webp/uploads/system/favicon.png를 함께 생성한다.
  • 공개 헤더와 오른쪽 사이드바는 공개 사이트 설정 API 값을 사용한다.
  • 공개 헤더와 오른쪽 사이드바는 logo_url이 있으면 이미지 로고를 표시하고, 없으면 logo_text fallback을 쓴다. favicon_url은 head의 icon 링크로 연결한다.
  • DB 연결이 없는 환경에서는 환경 변수와 기본값 기반 설정을 사용한다.

메뉴/네비게이션

  • 네비게이션은 navigation_items 테이블로 관리한다.
  • 컬럼: parent_id(nullable, self FK, ON DELETE CASCADE), is_folder(boolean, 자식이 있는 상단 항목이면 저장 시 서버가 true로 설정), location(primary|footer), 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평면 배열(parent_id 없음)이며 id, label, url, isVisible만 내려간다.
  • PUT /admin/api/navigation 요청 본문의 각 항목은 반드시 id(UUID)를 포함한다. 저장 시 sort_order는 서버가 위치별 트리 DFS 순으로 다시 부여한다. parent_idprimary에서만 허용되며 footer 항목은 항상 루트다. is_visible·is_folder는 요청값과 무관하게 서버에서 항상 표시·자식 유무 기준 폴더로 덮어쓴다.
  • URL은 /로 시작하는 내부 경로, http(s):// 외부 URL, 폴더 전용 자리 표시 #를 허용한다.
  • 관리자 메뉴 화면은 상단 네비게이션·하단 네비게이션 탭으로 구분한다. 편집 UI는 태그 관리 메인 태그와 같은 테이블·cursor-move 행 드래그 패턴을 쓰며, 드래그는 입력·버튼 위가 아닌 행 여백·번호 열에서 시작한다. 상단은 AdminNavPrimaryBranch로 트리·동일 부모 내 순서 변경·하위 추가를 제공한다. 하단은 한 단계 목록만 드래그 정렬한다.
  • parent_id / is_folder 컬럼이 DB에 없을 때 저장은 실패한다. npm run db:migrate:dev017_navigation_hierarchy.sql을 적용해야 한다(저장 API는 해당 경우 한국어 안내 메시지를 반환할 수 있다).
  • 공개 왼쪽 사이드바 상단은 SidebarPrimaryNavList로 렌더링한다. 하위가 있는 노드는 한 줄 button으로, 행 전체(이름·왼쪽 세로 데코·chevron) 클릭으로 접기/펼친다. 부모·리프 모두 왼쪽 장식은 기본 세로 막대(--site-line), 호버 시에만 리프와 동일하게 작은 원형으로 전환한다. 내부 경로(/로 시작, //·http(s) 제외)이고 현재 route.path와 정규화한 경로가 같으면 장식 색을 **--site-accent(브랜드 오렌지)**로 둔다. 외부 URL은 비교하지 않는다. 펼침 상태는 localStoragesori-primary-nav-expanded에 저장된다. 상단·리프 링크·부모 버튼 행은 **w-full**로 site-panel-hover 배경이 가로 전체를 쓴다.
  • 관리자 메뉴 관리 화면에서 저장 API로 보내는 직렬화 결과가 서버에서 불러온 직후와 동일하면 메뉴 저장 버튼이 비활성화된다.

관리자 인증

  • 관리자 인증은 users.is_admin=true 회원의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다.
  • DB에 사용자가 없으면 /signup에서 관리자 등록 모드(관리자 이름/이메일/비밀번호/비밀번호 확인)로 첫 계정을 생성한다.
  • DB에 owner/admin 계정이 없는 최초 상태에서 /admin/loginADMIN_EMAIL/ADMIN_PASSWORD와 같은 값을 입력하면 첫 owner 계정을 DB에 생성한 뒤 로그인한다. 같은 이메일의 일반 회원이 이미 있으면 해당 회원을 owner로 승격하고 비밀번호를 ADMIN_PASSWORD 기준 bcrypt 해시로 갱신한다. 이미 owner/admin 계정이 있으면 환경 변수 계정으로 우회 로그인하지 않고 DB의 관리자 계정만 사용한다.
  • 최초 owner 생성 시 is_admin=true, user_role=owner를 자동 부여한다. 최초 관리자 판정은 동시 요청에서 중복 owner가 생기지 않도록 users 테이블 잠금 안에서 처리한다.
  • 관리자 로그인 성공 시 httpOnly 세션 쿠키를 /admin 경로에 설정한다.
  • /admin/login 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
  • 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정하고 users.previous_last_seen_at/previous_last_seen_ip에 직전 로그인 값을 보존한 뒤 last_seen_at/last_seen_ip를 현재 로그인으로 갱신한다.
  • 관리자 설정 화면은 사이트 자체 설정만 관리한다. 관리자 프로필과 비밀번호는 멤버 편집 화면의 계정 작업에서 처리한다.
  • 관리자 사이드바 하단 사용자 메뉴의 내 프로필/admin/members/:id 멤버 편집 화면으로 이동한다.
  • 관리자 멤버 목록은 Ghost형 테이블 기준으로 닉네임 아래 이메일, 가입일 아래 최근 활동을 함께 표시한다.
  • 관리자 멤버 목록은 멤버 검색, 멤버 추가 버튼, 조건 필터를 제공한다. 필터는 이름·이메일·레이블 텍스트 조건, 활성/비활성 상태 조건, 최근 접속·가입일 날짜 조건을 지원하며 여러 조건은 AND로 적용한다.
  • 관리자 멤버 목록은 뉴스레터 지표 대신 댓글 작성 개수를 표시한다.
  • 관리자 멤버 상세/추가 화면은 Ghost형 편집 화면을 기준으로 썸네일, 이름, 이메일, 레이블, 관리자 노트를 편집한다. 본문은 3분할 그리드로 요약 영역 1fr, 입력 영역 2fr 비율을 사용한다. 썸네일은 URL 입력 대신 요약 영역의 원형 썸네일 hover 액션으로 등록·변경·제거한다. 기존 회원 상세는 가입 정보와 활동 요약을 표시하고, 신규 회원 화면은 활동 영역을 표시하지 않는다. 기존 회원 상세의 설정 메뉴는 관리자 직접 비밀번호 변경(PUT /admin/api/members/:id/password)과 멤버 삭제(DELETE /admin/api/members/:id)를 제공한다.
  • 관리자 게시글 작성/수정과 멤버 추가/수정 화면은 저장되지 않은 변경사항이 있을 때 내부 라우트 이동을 공통 확인 모달로 막는다. 브라우저 새로고침·탭 닫기는 브라우저 기본 beforeunload 확인을 사용한다. 게시글 화면에서 이탈을 승인하면 해당 로컬 자동 저장본은 삭제한다.
  • users.member_labels는 관리자용 문자열 배열이며, 이후 사용자별 칭호/분류 용도로 확장한다. users.member_note는 관리자에게만 보이는 500자 이하 메모다.
  • 관리자 멤버 권한은 소유자(owner), 관리자(admin), 멤버(member) 3단계를 유지하되, 목록 화면에서는 변경 UI를 노출하지 않고 상세/후속 화면에서 변경한다.
  • 관리자 페이지 접근은 /admin/api/auth/me 확인 후 허용한다.
  • 관리자 세션 토큰은 ADMIN_PASSWORD 기반 HMAC 서명으로 검증한다.
  • /admin/api/auth/login, /admin/api/auth/logout을 제외한 관리자 API 요청은 서버 미들웨어에서 세션 사용자 ID의 현재 DB 권한이 owner 또는 admin인지 다시 확인한다. 권한 변경이나 계정 삭제 이후 남아 있는 기존 관리자 쿠키는 이 단계에서 차단한다.

회원 인증

  • 회원 인증은 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를 현재 로그인으로 갱신한다. /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.png
/uploads/system/favicon.png
  • 관리자 에디터 이미지 업로드 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에 수동 생성·삭제할 수 없다.

  • 레거시 메타 값 posts, 회원/썸네일은 마이그레이션 016_media_category_normalize.sql 및 서버 정규화로 각각 미분류, 썸네일에 맞춘다.

  • 관리자 이미지 업로드 API는 image/jpeg, image/png, image/webp, image/gif만 허용한다.

  • 업로드 파일 크기 제한은 MAX_FILE_SIZE 환경 변수를 따른다.

  • 로컬 개발 업로드 파일은 public/uploads/posts/YYYY/MM/ 아래 저장하고 /uploads/posts/YYYY/MM/filename URL로 제공한다.

  • 관리자 미디어 화면 상단에 미디어 라이브러리 탭과 썸네일 탭을 두어, 라이브러리 탭에서는 게시물·기타 이미지만, 썸네일 탭에서는 /members/avatars/ 파일만 검색·탐색한다.

  • 미디어 라이브러리 탭은 왼쪽 폴더 트리와 오른쪽 고밀도 썸네일 갤러리, 검색, 파일명 변경, 개별 삭제를 제공한다.

  • 관리자는 폴더 추가 버튼으로 모달에서 새 폴더·하위 폴더 이름을 입력해 만들 수 있으며, 미분류·썸네일(및 그 하위)을 제외한 폴더는 목록에서 삭제할 수 있다. 폴더 삭제 시 해당 경로 및 하위 경로로 분류돼 있던 미디어 메타는 모두 미분류로 되돌린다.

  • 썸네일 본문(이미지·파일명) 한 번 클릭 시 상세(미리보기) 모달이 열리고, 썸네일 좌측 상단 선택 토글로 개별 선택한다. Shift+클릭으로 범위 선택이 가능하다.

  • 미디어 라이브러리 탭에서만 선택한 미디어를 폴더로 드래그하면 해당 미디어들의 폴더 경로가 일괄 변경된다.

  • 관리자 미디어 화면 검색은 저장 파일명과 게시물·페이지 사용처 제목만 대상으로 한다(URL 경로·논리 폴더명 문자열은 검색에 쓰지 않는다).

  • 미디어 파일 경로, 사용 현황(라이브러리), 연결 회원(썸네일 탭), 용량 등 세부 정보는 상세 모달에서 표시한다.

  • API 실패·클라이언트 검증 실패 등 사용자 피드백은 본문 상단 고정 배너가 아니라 useAdminToast 우측 상단 토스트로 표시해 모달에 가리지 않는다.

  • 상세 모달의 다운로드는 공개 /uploads/... URL을 download 속성으로 브라우저에 내려받는다(썸네일·게시물 이미지 공통).

  • 미디어 폴더는 실제 파일 경로를 옮기지 않고 media_metadata 테이블에 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

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