# 기술 명세

## 현재 아키텍처
- 프런트엔드: Vue 3 + Vite + Pinia + Vue Router
- 백엔드: Express 5
- 데이터 저장소: MariaDB(MySQL 호환)
- 세션 저장소: `session-file-store` 기반 파일 세션
- 업로드 저장소: 로컬 파일 시스템(`backend/uploads/`)
- 운영 배포: `frontend(Nginx 정적 서빙 + /api,/uploads 프록시) + backend + mariadb` Docker Compose 구조
- NAS HTTPS 리버스 프록시 운영 시 프런트 Nginx는 백엔드로 `X-Forwarded-Proto: https`를 전달하고, Express 세션은 프록시 환경에서 `secure` 쿠키를 허용하도록 설정한다.
- 프런트 브라우저 탭 파비콘은 운영 정적 파일 차단 영향을 줄이기 위해 `index.html`의 인라인 SVG 데이터 URL로 제공하고, iOS 홈 화면용 `apple-touch-icon.png`와 공유 미리보기용 `og-card.png`만 정적 파일로 유지한다.
- 프런트 앱 셸은 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 구조로 재정의되었고, `preview=1` 모드에서도 같은 셸을 유지한 채 중앙 본문만 완성본 프리뷰로 렌더링한다.
- 좌측 패널은 `248px`, 우측 패널은 `320px` 기준 폭을 사용하며, 우측 패널은 상단 토글 버튼으로 접고 펼칠 수 있다.
- 좌측 패널은 필요 시 축소형 레일로 접을 수 있으며, 접힌 상태에서는 아이콘 중심 내비게이션과 축약된 바로가기만 유지한다.
- 이 3단 셸 구조는 홈, 게임 허브, 에디터, 관리자 등 일반 페이지 전반의 공통 뼈대로 유지하고, 페이지별 차이는 중앙/우측에 어떤 콘텐츠를 넣는지만 달라지도록 관리한다.
- 비로그인 상태의 로그인 유도는 좌측 하단 버튼으로만 노출하고, 좌측 상단 사용자 카드 영역에는 별도 게스트 안내 카드를 렌더링하지 않는다.
- 공통 셸의 좌측 내비, 우측 패널, 빠른 점프 버튼은 간단한 선형 SVG 아이콘과 두꺼운 카드형 버튼 문법을 공유한다.

## 데이터 저장 구조
- 메인 데이터베이스: MariaDB `tier_cursor` (기본값)
- 세션 파일: `backend/.sessions/*.json`
- 업로드 파일:
  - 게임 이미지: `backend/uploads/games/`
  - 아바타: `backend/uploads/avatars/`
  - 커스텀 아이템: `backend/uploads/custom/`
  - 시드 이미지: `backend/uploads/seeds/`
  - 최적화 이미지 자산: 신규 업로드는 `backend/uploads/assets/<앞2글자>/<파일명>.webp` 형태로 1단계 샤딩 저장하고, 기존 `backend/uploads/assets/<파일명>.webp` 평면 경로도 계속 읽는다.
  - 기존 평면 자산을 샤딩 구조로 정리할 때는 `npm --prefix backend run images:shard-assets`를 실행하며, 스크립트가 파일 이동과 DB/JSON 참조 치환을 함께 처리한다.

## 화면 구조
- 좌측 패널
  - 사용자 요약, 전체 공개 티어표 검색 입력, 주요 라우트 내비게이션, 최근 즐겨찾기 티어표 바로가기, 관리자 진입 버튼을 배치한다.
  - 상단 토글 버튼은 항상 고정되어 있고, 패널을 축소하면 텍스트를 숨기고 아이콘 중심 레일로 전환한다.
  - `Settings`는 별도 메뉴 항목으로만 진입하며, 사용자 카드 자체는 정보 표시 용도로만 사용한다.
  - 사용자 아바타는 원형 보더 스타일을 유지하고, `Favorites` 영역은 최근 즐겨찾기 티어표 최대 10개를 메인 메뉴보다 작은 밀도의 바로가기 목록으로 보여준 뒤 하단 `즐겨찾기 더 보기` 링크로 전체 즐겨찾기 화면에 연결한다.
  - 사용자 아바타가 없을 때 표시하는 fallback 이니셜은 계정명보다 닉네임을 우선한다.
- 중앙 워크스페이스
  - 현재 라우트의 핵심 콘텐츠를 렌더링하는 영역이며, 홈/목록 계열 화면은 카드형 대시보드 레이아웃을 우선 적용한다.
  - 공통 `workspaceBody`는 별도 외곽 카드 테두리 없이 셸 여백만 제공하고, 실제 카드/패널 레이어는 각 화면 내부에서만 구성한다.
  - 홈, 게임 허브, 내 티어표, 즐겨찾기, 검색 결과 화면은 같은 카드 문법(상단 16:9 썸네일, `제목+좋아요` 1행, `작성자+최종 수정일` 1행)을 공유하며, 데스크톱 기준 기본 4열 카드 그리드를 사용한다.
  - `/` 홈은 템플릿 선택 화면이 아니라 `공개 티어표 피드`이며, 추천 티어표와 최신 공개 티어표를 같은 보드 카드 문법으로 보여준다.
  - `/templates`는 공개 템플릿 전용 화면이며, 템플릿 카드는 상단 메인 썸네일과 `주제명 + 작은 slug/id` 메타를 가진다.
  - 목록 계열 화면의 상단 도구 영역은 통계 카드와 액션 버튼을 공통 높이/반경으로 맞춰, 같은 라이브러리 대시보드로 읽히도록 정리한다.

## 주요 라우트/데이터 규칙
- 일반 `GET /api/topics`는 로그인한 관리자라도 공개 템플릿만 반환한다.
- 관리자 전용 템플릿 목록은 `GET /api/admin/templates`를 사용하며, 비공개 템플릿까지 포함한다.
- 홈 피드(`/`)는 `GET /api/tierlists/public?q=...`를 사용한다.
  - `featuredTierLists`: 상단 추천 티어표
  - `tierLists`: 추천 제외 최신 공개 티어표
- 홈, 템플릿, 나의 티어표, 즐겨찾기, 팔로우 피드 화면은 공통 `viewToggle`로 `그리드 / 리스트` 보기를 전환하며, 상태는 현재 라우트의 `?view=list` 쿼리로 반영한다.
- 전역 단축키
  - `S/ㄴ`: 검색 포커스. 편집 화면에서는 아이템 검색창, 그 외 화면에서는 왼쪽 공통 검색창
  - `G/ㅎ`: 그리드 보기
  - `L/ㅣ`: 리스트 보기
  - `A/ㅁ`: 관리자 계정일 때 관리자 화면으로 이동
- 설정의 가이드 모달은 좌우 화살표, 점 네비게이션, 좌측 단계 목록으로만 이동하고, 설명 영역은 최소 4줄 높이를 유지해 페이지별 높이 차이를 줄인다.
- 설정(`/profile`) 화면은 상시 입력 폼보다 `현재 상태 요약 카드 + 필요 시 모달 편집` 흐름을 기본으로 한다. 닉네임과 비밀번호는 작은 액션 버튼으로만 모달을 열어 변경하고, 이메일은 현재 로그인 계정 정보로 읽기 전용 표시를 유지한다.
- 닉네임은 2주(14일)에 한 번만 변경할 수 있다. 인증 응답에는 `nicknameUpdatedAt`, `nicknameChangeAvailableAt`를 함께 포함하고, 프로필 저장 API는 제한 기간 안의 닉네임 변경 요청에 `nickname_change_locked` 오류를 반환한다.
- 왼쪽 공통 검색창은 현재 화면 범위만 검색한다.
  - 홈: 전체 공개 티어표
  - 템플릿: 공개 템플릿
  - 나의 티어표: 내 저장 티어표
  - 특정 주제 화면: 해당 주제의 공개 티어표
  - 팔로우 피드: 팔로우한 작성자의 공개 티어표
  - 즐겨찾기: 내가 즐겨찾기한 티어표
- 위 네 화면의 목록 데이터는 현재 페이지네이션이나 무한 스크롤 없이 조회 결과 전체를 한 번에 렌더링한다.
- 저장된 티어표에는 댓글 스레드가 붙을 수 있다. 작성자 본인 편집 화면에서는 `작업 팁` 아래, 작성자가 아닌 사용자의 보기 전용 화면에서는 `preview` 보드 아래에서 같은 댓글 카드를 사용한다.
- 댓글 알림 메뉴는 좌측 사이드 `댓글 관리`로 노출하며, 읽지 않은 댓글이 하나라도 있으면 빨간 dot을 표시한다.
- 댓글 관리(`/comments`)는 기본적으로 `안 읽은 댓글만 보기`를 켠 상태로 시작한다.
- 댓글 정렬은 루트 댓글 최신순, 각 루트 내부의 답글은 오래된순을 기본 규칙으로 유지한다.
- 댓글 표시 밀도 제어를 위해 기본 노출 개수는 루트 댓글 10개, 각 루트의 답글 3개로 제한하고 `더 보기` 버튼으로 추가 노출한다.
- 댓글 관리 카드(`/comments`)는 좌측 `16:9 썸네일 + 티어표 제목 + 템플릿 이름`, 우측 `알림 제목 + 루트 댓글 정보 + 새 댓글/답글 정보`의 2열 구조를 사용한다.
- 댓글 관리 카드의 상단 우측 배지는 상태 라벨이 아니라 개별 `읽음 처리` 액션으로 사용한다.
- 티어표 즐겨찾기 API(`POST/DELETE /api/tierlists/:id/favorite`)는 이미 존재하며, 보기 화면 우측 레일에는 이를 직접 호출하는 단독 CTA를 노출한다.
- 티어표 즐겨찾기는 작성자 본인 저장 티어표에도 사용할 수 있다.
- 우측 패널
  - 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다.
  - 에디터/관리자 세부 옵션은 후속 단계에서 이 패널로 점진 이관한다.
  - 공통 토글 버튼은 패널이 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 각각 아이콘만 표시하는 방식으로 동작한다.
  - 오른쪽 패널 토글은 열기/닫기 모두 `dock_to_left`, 왼쪽 패널 토글은 `dock_to_right` 아이콘으로 통일한다.
  - 좌우 레일의 주요 CTA는 스크롤되는 본문과 분리된 하단 `56px` 액션 영역에 배치한다.
  - 하단 액션은 화면 바닥에 바로 붙지 않도록 푸터 내부에 추가 하단 여백을 둔다.
  - 홈 화면 기준 우측 패널은 임시 정보 카드 여러 개보다 핵심 CTA 하나만 남겨, 시안처럼 단순한 보조 레일 역할을 우선 유지한다.
  - 광고 영역은 상단 헤더와 시각적으로 너무 붙지 않도록 `78px` 상단 여백을 두고, 하단 카피라이트는 중앙 정렬된 공통 footer로 표시한다. 카피라이트 링크는 다크/라이트 테마 모두에서 읽히도록 고정 민트색 대신 테마 텍스트 색과 굵기를 사용한다.
- 티어표 편집 화면
  - 공통 우측 패널 대신 전용 로컬 편집 패널을 사용한다.
  - 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 보드 영역은 메인 컬럼에, 우측 `320px` 편집 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
  - 공통 상단 토글 버튼은 Teleport로 이동한 로컬 편집 패널의 접힘/펼침 상태와도 연결되어, 우측 패널을 숨기면 중앙 보드 영역이 확장된다.
  - 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 우측 로컬 패널에 배치한다. 템플릿 등록/업데이트 요청 버튼은 저장된 티어표가 있을 때만 노출하며, 제목이 비어 있는 상태에서 저장하면 랜덤 고유 제목을 먼저 부여해 저장본을 만든다.
  - 보드 바로 옆에는 드래그용 아이템 풀을 별도 패널로 두고, 커스텀 아이템 이름 정리 목록은 우측 편집 패널 내부에서 관리한다.
- 관리자 화면
  - 공통 우측 패널 대신 전용 로컬 운영 패널을 사용한다.
  - 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 관리 목록은 메인 컬럼에, 우측 운영 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
  - 우측 로컬 패널에는 `게임/아이템/티어표/회원 관리` 탭, 검색, 필터, 새로고침, 빠른 작업 제어를 배치하고, 중앙 영역에는 실제 관리 대상 목록과 상세만 렌더링한다. 템플릿 요청 카드는 전체 티어표 카드와 같은 썸네일 좌측/정보 우측 구조를 따르며, 요청 미리보기와 `preview=1` 공유 프리뷰는 공통 앱 셸 안에서 일반 티어표 완성본과 같은 행·열 보드 문법으로 검수한다.
  - 상단 헤더에는 현재 탭 기준 요약 통계 카드를 배치해 운영 상태를 먼저 읽고, 각 관리 카드는 공통 대시보드 카드 문법(두꺼운 반경, 얕은 레이어 배경, 강조된 액션 버튼)을 공유한다.

## DB 스키마
- `users`
  - `id`: string
  - `email`: string
  - `nickname`: string
  - `nicknameUpdatedAt`: number
  - `passwordHash`: string
  - `emailVerified`: boolean
  - `isAdmin`: boolean
  - `avatarSrc`: string
  - `lastLoginAt`: number
  - `createdAt`: number
  - 관리자 목록 집계 응답에서는 `tierListCount`, `followerCount`, `receivedFavoriteCount`, `lastLoginAt`, `recentActivityAt`도 함께 내려준다.
- `emailVerificationTokens`
  - `id`: string
  - `userId`: string
  - `tokenHash`: string
  - `expiresAt`: number
  - `consumedAt`: number
  - `createdAt`: number
- `passwordResetTokens`
  - `id`: string
  - `userId`: string
  - `tokenHash`: string
  - `expiresAt`: number
  - `consumedAt`: number
  - `createdAt`: number
- `topics`
  - `id`: string
    - 서버가 자동 생성하는 내부 참조용 랜덤 ID이며, 공개 URL 노출값으로 직접 사용하지 않는다.
  - `slug`: string
    - 운영자가 지정/수정하는 공개 주소용 식별자이며, 영문 소문자/숫자/하이픈 조합만 허용한다.
  - `name`: string
  - `thumbnailSrc`: string
  - `isPublic`: boolean
  - `displayRank`: number | null
  - `createdAt`: number
  - 시스템 전용 `freeform` 레코드는 홈 화면의 `직접 티어표 만들기` 저장 대상이며 일반 주제 목록에서는 숨긴다. 신규 빈 DB 초기화 시 자동 생성되는 템플릿은 이 `freeform` 한 건만 유지한다.
- `topicItems`
  - `id`: string
  - `topicId`: string
  - `src`: string
  - `label`: string
  - `displayOrder`: number | null
  - `createdAt`: number
- `customItems`
  - `id`: string
  - `ownerId`: string
  - `src`: string
  - `label`: string
  - `createdAt`: number
- `tierLists`
  - `id`: string
  - `authorId`: string
  - `topicId`: string
    - DB에는 내부 `topics.id`를 저장하고, API 응답에는 공개 경로용 `topicSlug`도 함께 내려준다.
  - `title`: string
  - `thumbnailSrc`: string
    - 사용자가 직접 지정하지 않으면 저장 시 티어표 대표 아이템 이미지로 자동 채운다.
  - `description`: string
  - `isPublic`: boolean
  - `isFeatured`: boolean
  - `featuredAt`: number
  - `featuredBy`: string
  - `groups`: `{ id, name, itemIds[] }[]`
  - `pool`: `{ id, src, label, origin }[]`
  - `createdAt`: number
  - `updatedAt`: number
- `favoriteTierLists`
  - `userId`: string
  - `tierListId`: string
  - `createdAt`: number
- `userFollows`
  - `followerId`: string
  - `followingId`: string
  - `createdAt`: number
- `favoriteTopics`
  - `userId`: string
  - `topicId`: string
  - `createdAt`: number
- `tierListComments`
  - `id`: string
  - `tierListId`: string
  - `authorId`: string
  - `parentCommentId`: string
  - `content`: string
  - `createdAt`: number
  - `updatedAt`: number
  - 답글은 1단계까지만 허용한다.
- `commentNotifications`
  - `id`: string
  - `userId`: string
  - `tierListId`: string
  - `commentId`: string
  - `actorUserId`: string
  - `notificationType`: `tierlist_comment | comment_reply`
  - `isRead`: boolean
  - `readAt`: number
  - `createdAt`: number
  - 기존 운영 DB에 예전 형태 테이블이 남아 있어도 서버 시작 시 스키마 보정으로 누락 컬럼을 자동 추가한다.
  - 댓글 관리 카드 구성을 위해 조회 응답에는 `parentCommentContent`, `parentCommentCreatedAt`, `parentAuthorName`, `parentAuthorAccountName`, `parentAuthorAvatarSrc`를 함께 내려준다.
- `templateRequests`
  - `id`: string
  - `type`: string
  - `requesterId`: string
  - `sourceTierListId`: string | null
  - `sourceTopicId`: string
  - `targetTopicId`: string
  - `status`: string
  - `sourceTierListTitle`: string
  - `sourceDescription`: string
  - `thumbnailSrc`: string
  - `items`: `{ id, src, label, origin }[]`
  - `snapshotGroups`: `{ id, name, itemIds[] }[]`
  - `snapshotItems`: `{ id, src, label, origin }[]`
  - `snapshotShowCharacterNames`: boolean
  - `createdAt`: number
  - `updatedAt`: number

## 주요 API
- 인증
  - `POST /api/auth/signup`
    - 첫 관리자 계정은 바로 로그인 세션을 만들고, 이후 일반 계정은 인증 메일 발송 후 `verificationRequired` 상태를 반환한다.
  - `POST /api/auth/login`
    - 이메일 인증이 끝나지 않은 계정은 `email_unverified`로 차단한다.
  - `POST /api/auth/logout`
  - `GET /api/auth/me`
    - 로그인 세션이 살아 있는 사용자의 `last_login_at`을 주기적으로 갱신해, 회원 관리에서 `마지막 접속일`을 따로 볼 수 있게 한다.
  - `GET /api/auth/meta`
  - `POST /api/auth/profile`
  - `POST /api/auth/password`
    - 로그인한 사용자가 현재 비밀번호를 확인한 뒤 새 비밀번호로 직접 변경한다.
  - `POST /api/auth/email/verify`
    - `login?verifyToken=...` 링크에서 받은 토큰으로 이메일 인증을 완료하고 바로 로그인 세션을 만든다.
    - 인증 완료 직후 로그인 세션이 열리면서 `last_login_at`도 함께 갱신한다.
  - `POST /api/auth/email/resend`
    - 미인증 계정의 인증 메일을 다시 발송한다.
  - `POST /api/auth/password-reset/request`
    - 입력한 이메일로 비밀번호 재설정 링크를 발송한다.
  - `POST /api/auth/password-reset/confirm`
    - `login?resetToken=...` 링크의 토큰과 새 비밀번호로 비밀번호를 재설정하고 바로 로그인 세션을 만든다.
    - 재설정 완료 직후 로그인 세션이 열리면서 `last_login_at`도 함께 갱신한다.
- 주제
  - `GET /api/topics`
  - `GET /api/topics/:topicId`
    - `:topicId`는 공개 URL에서는 보통 `slug`를 받지만, 내부 ID를 넘겨도 같은 템플릿을 찾을 수 있게 서버가 레코드를 해석한다.
- 티어표
  - `GET /api/tierlists/public`
    - `featuredTierLists`와 일반 공개 `tierLists`를 분리해서 반환한다.
    - `topicId`에는 주제 `slug`를 우선 전달하며, `topicId` 없이 `q`만 전달하면 전체 공개 티어표 검색에 사용한다.
  - `GET /api/tierlists/me`
  - `GET /api/tierlists/favorites/me`
  - `GET /api/tierlists/:id`
  - `GET /api/tierlists/:id/comments`
  - `POST /api/tierlists/:id/comments`
  - `DELETE /api/tierlists/:id/comments/:commentId`
  - `POST /api/tierlists/:id/template-request`
  - `POST /api/tierlists/:id/favorite`
  - `DELETE /api/tierlists/:id/favorite`
  - `DELETE /api/tierlists/:id`
  - `POST /api/tierlists/thumbnail`
  - `POST /api/tierlists/custom-items`
  - `POST /api/tierlists`
- 사용자/팔로우
  - `GET /api/users/following-feed`
    - 로그인한 사용자가 팔로우한 작성자의 공개 티어표를 최신 업데이트순으로 조회한다.
  - `GET /api/users/:userId`
    - 작성자 공개 프로필, 공개 티어표 수, 팔로워/팔로잉 수, 현재 로그인 사용자의 팔로우 여부를 반환한다.
  - `GET /api/users/:userId/tierlists`
    - 해당 작성자의 공개 티어표 목록을 반환한다.
  - `POST /api/users/:userId/follow`
  - `DELETE /api/users/:userId/follow`
- 댓글 알림
  - `GET /api/comments/inbox`
    - 알림 카드 렌더링을 위해 티어표 썸네일과 부모 댓글 내용도 함께 반환한다.
  - `GET /api/comments/inbox/unread-count`
  - `POST /api/comments/inbox/read`
- 관리자
  - `POST /api/admin/templates`
    - 요청 본문은 `slug`, `name`, `isPublic`, `thumbnailSrc`를 사용하고, 내부 `topics.id`는 서버가 자동 생성한다.
  - `PATCH /api/admin/templates/:templateId`
    - 내부 ID로 템플릿을 찾아 `name`, `slug`, `isPublic`을 수정한다.
  - `POST /api/admin/templates/:templateId/thumbnail`
  - `POST /api/admin/templates/:templateId/images`
    - 여러 이미지를 한 번에 최대 `100개`까지 업로드할 수 있고, 별도 라벨이 없으면 파일명 기준으로 기본 아이템 이름을 만든다.
  - `PATCH /api/admin/templates/:templateId/items/:itemId`
  - `GET /api/admin/tierlists`
    - `sort=recent|created|favorites`, `minFavorites`, `topicId`, `q`, `page`, `limit`으로 인기 티어표 후보를 정렬/필터링할 수 있다.
  - `GET /api/admin/tierlists/stats`
    - 현재 검색어/주제/최소 즐겨찾기 필터가 적용된 범위의 전체/공개/비공개/추천 수를 반환한다.
  - `PATCH /api/admin/tierlists/:tierListId/featured`
  - `GET /api/admin/template-requests`
  - `POST /api/admin/template-requests/:requestId/approve`
  - `POST /api/admin/template-requests/:requestId/reject`
  - `POST /api/admin/template-requests/:requestId/link-template`
  - `POST /api/admin/tierlists/:tierListId/promote-items`
  - `POST /api/admin/tierlists/:tierListId/create-template`
  - `GET /api/admin/custom-items`
    - `filter=library`를 기본값으로 사용해 반복 사용 가능한 `템플릿 아이템 + 사용자 아이템`만 먼저 보여주고, `filter=thumbnail` / `filter=avatar`로는 현재 참조 역할이 썸네일/프로필인 이미지를 따로 조회한다.
    - `filter=all|library|template|user|thumbnail|avatar|unused-user`를 사용하며, `filter=asset|unused-admin`은 과거 UI 호환용으로만 유지한다.
  - `POST /api/admin/custom-items/:itemId/promote`
  - `DELETE /api/admin/custom-items/:itemId`
  - `DELETE /api/admin/custom-items`
  - `GET /api/admin/users`
    - `sort=recent|lastLogin|created|tierlists|followers|favorites`, `direction=asc|desc`로 회원을 콘텐츠 활동/마지막 접속/작성량/팔로워/받은 즐겨찾기 기준으로 정렬한다.
  - `PATCH /api/admin/users/:userId`
  - `PATCH /api/admin/users/:userId/password`
  - `DELETE /api/admin/users/:userId`
  - `DELETE /api/admin/templates/:templateId/items/:itemId`
  - `DELETE /api/admin/templates/:templateId`

## 관리자 화면 메모
- 썸네일은 16:9 비율 미리보기 후 `썸네일 적용` 버튼으로 실제 반영한다.
- 게임 기본 아이템 추가는 드래그 앤 드롭 또는 다중 파일 선택으로 처리하고, 미리보기 카드에서 여러 장을 함께 확인할 수 있다.
- 현재 기본 아이템 목록에서는 등록된 아이템 이름을 직접 수정하고 저장할 수 있다.
- 기본 아이템 이름 저장 버튼은 값이 실제로 바뀐 경우에만 활성화된다.
- 아이템 미리보기는 반응형 환경에서도 최대 `192px` 크기 안에서 표시한다.
- 게임 전환 또는 업로드 성공 뒤에는 파일 입력값을 초기화해 같은 파일도 다시 선택할 수 있다.
- 게임 관리 탭에서는 홈 화면 상단에 먼저 노출할 게임을 최대 50개까지 지정하고, 드래그 또는 위/아래 버튼으로 순서를 저장할 수 있다.
- 사용자 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다.
- 사용자 커스텀 아이템은 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있다.
- 사용자 아이템은 사용 횟수(`usageCount`)를 표시하며, `미사용 아이템` 필터에서 저장 티어표/템플릿 참조가 더 이상 없는 항목만 개별/일괄 삭제할 수 있다. 사용자가 계정 탈퇴 등으로 삭제된 경우는 `custom_items.owner_id` 외래키로 레코드가 같이 사라지므로 보통 `미사용 아이템`으로 남지 않는다.
- 아이템 관리 기본 필터는 `아이템(템플릿 + 사용자)`이며, 우측 필터 순서는 `전체 이미지 → 아이템(템플릿 + 사용자) → 템플릿 아이템 → 사용자 아이템 → 썸네일 이미지 → 프로필 이미지 → 미사용 아이템`을 사용한다.
- `/uploads/assets/avatars/`는 `프로필 아바타`, `/uploads/assets/tierlists/`와 `/uploads/assets/topics/`는 `썸네일 이미지`, 그 외 보관 이미지는 `보관 자산` 배지로 표시한다. 최근처럼 `/uploads/assets/<파일명>.webp` 또는 `/uploads/assets/<앞2글자>/<파일명>.webp` 경로만 보고 종류를 알 수 없는 자산은 DB 참조(`avatar_src`, `thumbnail_src`, `thumbnail_src_snapshot`)를 역추적해 프로필/썸네일 여부를 분류한다. 실제 템플릿 기본 항목은 `템플릿 아이템`, 사용자 커스텀 항목은 `사용자 아이템` 배지를 사용한다.
- 같은 이미지 `src`가 해시 중복 재사용으로 템플릿 아이템/사용자 아이템과 프로필 아바타 또는 썸네일 자산에서 동시에 공유되더라도, 아바타/썸네일로 참조 중인 `src`는 자산 카드도 함께 유지해 `프로필 이미지`, `썸네일 이미지`, `전체 이미지` 필터에서 누락되지 않게 한다.
- 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 내부에서 `템플릿 요청 관리 / 전체 티어표 관리`를 분리해 볼 수 있고, 확인용 완성본은 탐색 UI 없는 preview 전용 모달로 연다.
- `전체 티어표 관리`에서는 공개 티어표를 `추천 지정 / 추천 해제`할 수 있고, 추천 지정된 티어표는 주제별 공개 목록 상단의 `추천 티어표` 섹션에 먼저 노출된다. 비공개 티어표는 추천 지정할 수 없고, 추천글을 비공개로 바꾸면 추천 상태도 함께 해제된다.
- `전체 티어표 관리` 카드에는 받은 즐겨찾기 수를 함께 보여주며, 우측 운영 패널에서 즐겨찾기 많은 순 정렬과 최소 즐겨찾기 수 필터로 추천 후보를 좁힐 수 있다.
- `티어표 관리` 탭의 추가 아이템은 작은 그리드 카드로 표시하고, 클릭 시 `기존 템플릿에 추가 / 새 템플릿 만들기` 모달을 통해 목적지를 선택한다.
- `티어표 관리` 탭에서는 티어표 안의 커스텀 아이템을 개별 또는 일괄로 기존 게임 템플릿에 복제할 수 있다.
- `freeform` 티어표는 관리자 화면에서 새 템플릿 slug/이름을 입력해 새로운 템플릿으로 복제 생성할 수 있다. 내부 ID는 서버가 자동 생성하므로 운영자가 직접 입력하지 않는다.
- 관리자 티어표 관리 상단에는 사용자가 보낸 템플릿 등록/업데이트 요청 목록이 별도로 표시되며, 여기서 승인/반려를 바로 처리할 수 있다.
- 관리자 템플릿 요청 목록에서 `반려 후 숨김`을 누르면 해당 요청은 pending 목록에서 즉시 제외된다.
- 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다.
- 단, 일반 운영자는 최고 관리자 계정의 프로필 이미지/회원 정보/비밀번호/삭제 버튼을 사용할 수 없고, 최고 관리자만 다른 관리자 권한을 변경할 수 있다.
- 관리자 회원 정보 수정은 운영상 필요한 경우 예약어 닉네임도 저장할 수 있지만, 일반 회원가입과 개인 프로필 수정에서는 운영자 사칭성 예약어 닉네임을 계속 차단한다.
- 회원 관리 카드에는 아바타, 작성 티어표 수, 팔로워 수, 받은 즐겨찾기 수, 최근 콘텐츠 활동, 마지막 접속일을 함께 표시한다.
- 운영자는 회원 목록을 작성 티어표 수뿐 아니라 팔로워 수와 받은 즐겨찾기 수 기준으로도 정렬할 수 있어, 핵심 작성자를 더 빠르게 찾을 수 있다.
- 마지막 접속일은 로그인/세션 확인 기준, 최근 콘텐츠 활동은 작성한 티어표의 마지막 수정일 기준으로 분리해서 보여준다. 따라서 장기 미접속 계정 정리 판단은 마지막 접속일을 우선 사용하고, 콘텐츠 기여가 최근인지 볼 때는 최근 콘텐츠 활동을 사용한다.
- 회원 카드의 `프로필 보기` 버튼은 해당 회원의 `/users/:userId` 공개 프로필 화면으로 이동해, 팔로워/공개 티어표 현황을 관리자 화면 밖에서도 바로 확인할 수 있게 한다.
- 회원 비밀번호를 운영자가 임의로 덮어쓰는 기능은 비상 상황용 API로만 유지하고, 일반 회원 관리 카드에서는 비밀번호 초기화 버튼과 모달을 숨긴다. 평소 사용자 비밀번호 변경은 이메일 재설정 메일과 설정 화면 직접 변경을 우선 사용한다.

## 티어표 접근 메모
- `new` 작성 경로는 로그인한 사용자만 진입할 수 있다.
- 공유 링크로 여는 `preview=1` 화면은 `뷰어 모드`로 취급하며, 드래그/행열 편집/저장 같은 편집 UI 없이 완성본만 렌더링한다.
- 비로그인 사용자나 작성자 본인이 아닌 로그인 사용자는 저장된 티어표를 기본적으로 뷰어 모드로 열람하며, 일반 편집 URL로 직접 진입해도 소유자가 아니면 `preview=1` 주소로 자동 전환된다.
- 비로그인 사용자도 뷰어 모드 우측 레일의 `공유하기` 버튼으로 현재 공유 링크를 복사할 수 있다.
- 로그인한 사용자는 뷰어 모드 우측 레일에서 저장된 티어표를 복사할 수 있고, 타인 티어표면 `내 티어표로 복사`, 본인 티어표면 `복사본 만들기` 문구를 사용한다. 작성자 본인은 `수정 모드로 전환`도 사용할 수 있다.
- 작성자 본인이 일반 편집 화면에서 저장된 본인 티어표를 보고 있을 때는 우측 패널의 `뷰어 모드로 보기`로 공유 화면 형태를 바로 확인할 수 있다.
- 편집/뷰어 우측 패널의 `작성자 프로필 보기`로 해당 작성자의 공개 프로필과 공개 티어표 목록을 열 수 있고, 로그인 상태에서는 작성자 프로필에서 팔로우/언팔로우를 전환할 수 있다.
- `/users/:userId` 공개 프로필 화면 상단 헤더는 고정 제목 `사용자 프로필`과 안내 문구를 보여주고, 실제 닉네임/아바타는 본문 프로필 카드에서만 표시한다. 이메일 앞부분에서 파생된 `@accountName`은 사용자가 직접 설정한 핸들이 아니므로 프로필 UI에 노출하지 않는다.
- 같은 `TierEditorView` 안에서 `topicId / tierListId / preview` 라우트 값만 바뀌어도 화면이 이전 티어표 데이터에 남지 않도록, 라우트 변경마다 에디터 상태를 다시 로드한다.
- 복사한 티어표 상단의 `원본` 링크를 누르면 원본 티어표로 이동하며, 편집 모드에서 저장하지 않은 변경이 있으면 이동 전에 `저장 없이 이동` 확인 모달을 먼저 띄운다.
- 본인 티어표도 저장된 상태라면 편집/뷰어 우측 패널에서 복사본을 만들 수 있고, 편집 중 저장하지 않은 수정이 남아 있으면 복사 직전에 현재 수정본을 먼저 저장해 최신 상태 기준 복사본을 만든다.
- 비공개 티어표라도 관리자는 편집 화면에서 완성본을 열람할 수 있다.
- 공개 티어표는 목록과 상세 화면에서 즐겨찾기 토글 및 개수를 함께 표시한다.
- 카드형 목록에서는 즐겨찾기 수/상태만 표시하고, 실제 토글은 상세 화면에서 처리한다.
- 공개 티어표 목록은 현재 게임 기준으로 제목/작성자 검색을 지원한다.
- 주제별 공개 티어표 화면은 관리자 추천글을 상단 `추천 티어표` 섹션으로 먼저 보여주고, 일반 공개 목록은 아래 `전체 공개 티어표` 섹션으로 분리해 중복 없이 렌더링한다. 추천 섹션은 최대 16개까지 표시한다.
- `내 즐겨찾기` 화면에서는 즐겨찾기한 순, 최신 업데이트순, 인기순 정렬을 제공한다.
- 커스텀 이미지 추가는 다중 파일 선택과 드래그 앤 드롭을 모두 지원한다.
- 사용자가 직접 추가한 커스텀 아이템 이름은 편집 화면 우측 목록에서 정리할 수 있고, 저장 시 원본 커스텀 아이템 라벨과 함께 동기화된다.
- 티어 행은 기본 5단으로 시작하지만, 사용자가 직접 추가하거나 제거할 수 있다.
- 티어 행에 넣은 아이템은 작은 제거 버튼으로 다시 우측 아이템 풀로 되돌릴 수 있다.
- 편집 가능한 티어표에서는 아이템을 드래그로 옮길 수 있고, 아이템을 클릭해 선택한 뒤 원하는 셀이나 아이템 풀 빈 영역을 클릭하는 방식으로도 위치를 바꿀 수 있다.
- 클릭 배치 모드에서 선택된 아이템은 파란 포커스 테두리로 표시하며, 드래그를 시작하면 선택 상태를 해제하고 드래그 직후 짧은 클릭 입력은 무시해 드래그/클릭 조작이 섞이지 않게 한다.
- 보드 칸이나 미사용 아이템 풀의 아이템을 우클릭하면 `아이템 복제` 메뉴가 열리고, 실행 시 같은 이미지/이름/출처를 가진 새 아이템 인스턴스를 미사용 풀 맨 앞에 추가한다. 복제본은 `dup-...` 형태의 새 ID를 쓰므로 원본과 복제본을 서로 다른 칸에 동시에 배치할 수 있다.
- 기존 저장 티어표/복사본을 수정 가능한 상태로 다시 열 때는 저장본의 그룹/풀을 먼저 복원하고, 이후 현재 템플릿에 새로 추가된 기본 아이템만 미사용 풀 끝에 병합한다.
- 관리자 템플릿에서 삭제된 기본 아이템이라도 이미 저장된 티어표의 그룹/풀에 포함되어 있던 항목은 자동 제거하지 않고 그대로 보존한다.
- 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다.
- 제목이 비어 있는 상태로 저장하면 내부 제목은 현재 게임명을 기본값으로 사용한다.
- 제목 입력이 비어 있는 동안에는 무분별한 도배 방지를 위한 관리자 임의 삭제 가능성 안내 문구를 표시한다.
- `freeform` 티어표는 커스텀 아이템이 준비된 상태에서 `템플릿 등록 요청`을 보낼 수 있다.
- `템플릿 등록 요청` 전에는 체크리스트 모달로 `제목 직접 입력` 여부를 확인하고, 관리자가 식별하기 쉬운 게임 이름을 입력하도록 안내한다.
- 신규 티어표를 막 저장한 직후에도, 템플릿 요청은 새로 발급된 실제 티어표 ID를 기준으로 이어서 처리한다.
- 기존 게임 티어표는 커스텀 아이템이 포함되어 있으면 `템플릿 업데이트 요청`을 보낼 수 있다.
- 티어표는 편집 화면에서 16:9 썸네일 이미지를 별도로 선택해 저장할 수 있고, 목록 카드에서는 그 이미지를 상단 대표 썸네일로 사용한다.
- 티어표에 썸네일을 직접 지정하지 않으면 저장 시 대표 아이템 이미지 하나를 기본 썸네일로 자동 선택한다.
- 편집 화면 상단 헤더는 좌측 제목/설명, 우측 썸네일 카드 구조를 사용하며 모바일에서는 한 열로 접힌다.
- 티어표 편집 화면의 우측 패널은 공통 `rightRail`의 `localRightRailRoot`에 직접 section들을 쌓는 구조이며, 별도 외곽 래퍼 카드 없이 공통 오른쪽 컬럼 문법을 그대로 따른다.
- 뷰어 모드 우측 패널도 같은 `localRightRailRoot`를 사용하며, 상단에는 광고 블록을, 하단에는 공유/복사/수정 전환 액션 카드를 배치한다.
- 공통 앱 셸은 좌측/중앙/우측 컬럼마다 높이 `56px`의 상단 헤더 블록을 유지하며, 중앙 헤더에는 사이트 타이틀 `Tier Maker`와 현재 서비스 설명을 표시한다.
- 티어표 편집기의 아이콘 기본 크기는 `80px`이며, 사용자가 `48 / 60 / 80 / 100 / 120` 단계로 즉시 조절할 수 있다.
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
- 작성자 아바타 이미지가 없을 경우 목록 썸네일 fallback은 닉네임이 아니라 계정명 기준 첫 글자를 사용한다.
- 티어표 목록 메타 정보는 최종 업데이트 시각만 간략하게 표시한다.
- 저장 성공 시에는 에디터 안에서 반투명 오버레이 기반 확인 모달을 띄우고, PNG export 이미지는 약 `960px` 보드 폭과 `pixelRatio 1.5`, 외곽 여백, 작성자/날짜 하단 메타를 포함해 생성한다.
- 저장/삭제/가져오기 같은 사용자 행동 피드백은 전역 우측 상단 토스트로 표시한다.
- 전역 토스트는 동일 메시지/타입이 연속 발생하면 하나로 합쳐 카운트를 올리고, 종료 시 짧은 페이드아웃 애니메이션을 사용한다.
- 홈 게임 목록은 관리자 상단 고정 순서가 있으면 그 순서를 먼저 적용하고, 그 외 게임은 최근 생성순으로 뒤에 이어진다.
- 홈 주제 템플릿 목록의 실제 정렬 우선순위는 `즐겨찾기 여부 → 관리자 수동 순서(displayRank) → 최신 생성순(createdAt DESC) → 이름순`이다.
- `커스텀 티어표 만들기`는 카드가 아니라 홈 우측 상단 버튼으로 진입한다.

## 업로드 제한 메모
- 프로필 아바타 업로드는 파일당 최대 `3MB`다.
- 관리자 템플릿 썸네일/기본 아이템 업로드는 파일당 최대 `20MB`다.
- 사용자 커스텀 이미지 업로드는 파일당 최대 `6MB`다.
- 운영 프런트 Nginx는 다중 이미지 업로드 한 번의 요청 본문을 최대 `1024MB`까지 허용한다.
- 현재는 업로드 전에 이미지 리사이즈/압축을 하지 않고 원본 파일을 그대로 저장한다.

## 운영 환경 변수
- 프런트엔드
  - `VITE_API_ORIGIN`: API 및 업로드 파일 절대 기준 주소
- 백엔드
  - `DB_HOST`: MariaDB 호스트
  - `DB_PORT`: MariaDB 포트
  - `DB_USER`: MariaDB 계정
  - `DB_PASSWORD`: MariaDB 비밀번호
  - `DB_NAME`: 데이터베이스명
  - `PORT`: 서버 포트
  - `SESSION_SECRET`: 세션 서명 키
  - `CORS_ORIGINS`: 허용할 프런트 도메인 목록(쉼표 구분)
  - `TRUST_PROXY`: 프록시 홉 수
  - `SESSION_COOKIE_SECURE`: `true`면 HTTPS 전용 쿠키
  - `SESSION_COOKIE_SAME_SITE`: 기본 `lax`
  - `APP_ORIGIN`: 이메일 인증/비밀번호 재설정 링크를 만들 때 사용할 서비스 기준 주소
  - `SMTP_HOST`: 메일 서버 호스트, Gmail SMTP 사용 시 보통 `smtp.gmail.com`
  - `SMTP_PORT`: 메일 서버 포트, Gmail SSL SMTP 기준 보통 `465`
  - `SMTP_SECURE`: `true`면 SMTP SSL/TLS 연결을 사용
  - `SMTP_USER`: 발신용 Gmail 계정
  - `SMTP_PASS`: Gmail 앱 비밀번호
  - `SMTP_FROM`: 실제 메일 From 주소, 비워두면 `SMTP_USER`를 기본값으로 사용한다

## 회원 인증 메모
- 첫 번째 가입 계정은 운영 초기 부트스트랩을 위해 이메일 인증 없이 바로 최고 관리자 계정으로 활성화한다.
- 두 번째 이후 일반 회원가입은 가입 직후 로그인 세션을 만들지 않고, 인증 메일 링크를 눌러 `email_verified=1`이 된 뒤에만 로그인할 수 있게 한다.
- 인증 메일/비밀번호 재설정 메일 토큰은 원문을 DB에 저장하지 않고 SHA-256 해시만 저장하며, 새 토큰을 발급할 때는 같은 사용자의 이전 미사용 토큰을 먼저 만료 처리한다.
- 이메일 인증 토큰은 24시간, 비밀번호 재설정 토큰은 1시간 유효 기간을 사용한다.
- 비밀번호 재설정 링크로 새 비밀번호를 저장한 사용자는 같은 메일 주소를 확인한 것으로 보고, 기존에 미인증 상태였더라도 저장과 함께 이메일 인증을 완료 처리한다.
- 로그인한 상태로도 `login?resetToken=...` 재설정 링크를 열 수 있으며, 이때는 기존 로그인 세션이 있어도 자동으로 내 티어표 화면으로 보내지 않고 새 비밀번호 입력 화면을 먼저 보여준다.
- 설정 화면의 직접 비밀번호 변경은 현재 비밀번호가 맞는지 먼저 확인하고, 맞지 않으면 `invalid_current_password`로 차단한다.

## 운영 배포 메모
- 프로덕션 컴포즈 파일은 [docker-compose.prod.yml](/Users/bicute/Desktop/zenn.dev/tier-cursor/docker-compose.prod.yml)이다.
- 외부 도메인 `tmaker.sori.studio`는 `frontend` 컨테이너의 `18080` 포트로 리버스 프록시하고, `/api`, `/uploads`, `/health`는 프런트 Nginx가 내부 `backend:5179`로 전달한다.
- 운영 볼륨은 MariaDB 데이터, 업로드 파일, 세션 파일을 각각 분리해 유지한다.
- MariaDB healthcheck는 NAS 첫 기동 지연을 고려해 `root` 기준 ping과 긴 `start_period/retries`를 사용한다.

## NAS 배포 메모
- 현재 구조는 MariaDB/MySQL 계열이므로 NAS에 MariaDB를 올리면 phpMyAdmin 또는 Adminer로 직접 데이터 확인이 가능하다.
- 추천 구성:
  - MariaDB 컨테이너 또는 NAS 패키지 설치
  - phpMyAdmin 또는 Adminer 설치
  - 앱은 환경변수로 해당 DB에 연결

## 로컬 개발 기준
- 기본 로컬 개발도 `docker compose`로 띄운 MariaDB를 사용한다.
- 기본 백엔드 실행 스크립트 `backend/package.json`의 `dev`, `start`는 로컬 MariaDB(`127.0.0.1:3307`) 기준으로 맞춰져 있다.
- `backend/src/db.js`는 MariaDB만 대상으로 동작하며, 파일 기반 fallback은 제거되었다.
