diff --git a/.env.production.example b/.env.production.example index 8aa78d9..dff85fd 100644 --- a/.env.production.example +++ b/.env.production.example @@ -10,3 +10,4 @@ SMTP_SECURE=true SMTP_USER=your-gmail-account@gmail.com SMTP_PASS=change-this-gmail-app-password SMTP_FROM="Tier Maker " +SKIP_EMAIL_VERIFICATION=false diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 16bc8d5..f4a886e 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -30,6 +30,11 @@ const { isReservedNickname } = require('../lib/user-validation') const router = express.Router() const EMAIL_VERIFICATION_TTL_MS = 24 * 60 * 60 * 1000 const PASSWORD_RESET_TTL_MS = 60 * 60 * 1000 +const SKIP_EMAIL_VERIFICATION = process.env.SKIP_EMAIL_VERIFICATION === 'true' + +function shouldSkipEmailVerification() { + return SKIP_EMAIL_VERIFICATION && process.env.NODE_ENV !== 'production' +} function resolveNicknameChangeIntervalMs() { const rawHours = String(process.env.NICKNAME_CHANGE_INTERVAL_HOURS || '').trim() @@ -211,7 +216,8 @@ router.post('/signup', async (req, res) => { const passwordHash = await bcrypt.hash(password, 10) const isAdmin = (await countUsers()) === 0 - if (!isAdmin && !isMailerConfigured()) { + const skipEmailVerification = !isAdmin && shouldSkipEmailVerification() + if (!isAdmin && !skipEmailVerification && !isMailerConfigured()) { return res.status(503).json({ error: 'mail_not_configured' }) } @@ -220,11 +226,11 @@ router.post('/signup', async (req, res) => { email, nickname, passwordHash, - emailVerified: isAdmin, + emailVerified: isAdmin || skipEmailVerification, isAdmin, }) - if (!isAdmin) { + if (!isAdmin && !skipEmailVerification) { try { await issueEmailVerificationMail(req, user) return res.json({ diff --git a/docs/history.md b/docs/history.md index f867b49..759a71b 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,10 @@ # 의사결정 이력 +## 2026-04-27 v1.1.31 +- 캐릭터 이름 표시를 켠 상태에서 긴 라벨이 잘리는 문제는 글자 수 제한으로 자르기보다, 카드 안에서 overflow 시에만 자동 슬라이드시키는 편이 정보 전달과 가독성 모두에 유리하다고 판단했다. +- 짧은 라벨까지 항상 움직이면 오히려 시선 피로가 생기므로, 실제 렌더링 너비를 기준으로 overflow 여부를 계산해 마퀴 애니메이션을 조건부로 켜는 방식으로 정리했다. +- 로컬 개발에서는 SMTP가 없거나 불안정한 경우 가입/로그인 플로우 자체를 막아버리기 쉬우므로, 운영 환경과 분리된 개발 전용 스위치(`SKIP_EMAIL_VERIFICATION`)로 메일 인증 단계를 건너뛸 수 있게 두는 편이 낫다고 판단했다. + ## 2026-04-08 v1.1.30 - 관리자 목록관리의 상단 고정 템플릿 후보가 많아지면 모든 후보를 화면에 상시 렌더링하는 방식은 탐색성이 떨어지고 관리 영역을 불필요하게 길게 만든다고 판단했다. 따라서 고정 목록은 현재 선택된 항목만 보여주고, 추가는 검색 모달로 분리하는 쪽으로 정리했다. - 모달이 열린 상태에서 배경 스크롤이 움직이면 선택 작업 중 문맥 위치가 바뀌어 관리자가 다시 위치를 찾아야 하므로, 이 유형의 모달은 배경 스크롤을 잠그는 것이 맞다고 정리했다. diff --git a/docs/map.md b/docs/map.md index 076d78e..2188127 100644 --- a/docs/map.md +++ b/docs/map.md @@ -17,7 +17,7 @@ ## `/editor/:topicId/new`, `/editor/:topicId/:tierListId` - 화면 파일: `frontend/src/views/TierEditorView.vue` -- 역할: 주제 slug 기반 에디터 진입, 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 아이템 클릭 선택 후 셀/풀 재배치, 아이템 우클릭 메뉴 기반 복제본 생성, 헤더는 공통 `pageHead` 문법을 따르며 편집 가능 상태에서는 `NEW/EDIT + 템플릿 이름 + 작업 가이드`, 보기 전용 상태에서는 `템플릿 이름 + 티어표 제목 + 설명` 구조를 사용하고 아이브로우 클릭 시 해당 주제 허브로 이동하며 별도 `Preview` 아이브로우는 두지 않음, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 읽기 전용 상태의 즐겨찾기 단독 CTA, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, 댓글 카드 표시, 데이터가 준비되기 전에는 편집 모드/프리뷰 모드 모두 전용 스켈레톤 레이아웃을 먼저 보여주고, 저장용 랜덤 제목이나 내부 ID는 화면 표시용 제목으로 직접 노출하지 않으며, 스켈레톤이 내려간 뒤 실제 보드/풀 DOM에서만 드래그 초기화를 실행하고, `?preview=1` 진입 시 공통 앱 셸은 유지한 채 중앙 본문에서 완성본 프리뷰와 하단 댓글 카드를 렌더링하며, 우측 뷰어 카드(`공유 티어표 보기`)는 스폰서 카드 바로 아래에서 유지하고 즐겨찾기 CTA도 함께 노출 +- 역할: 주제 slug 기반 에디터 진입, 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 아이템 클릭 선택 후 셀/풀 재배치, 아이템 우클릭 메뉴 기반 복제본 생성, 헤더는 공통 `pageHead` 문법을 따르며 편집 가능 상태에서는 `NEW/EDIT + 템플릿 이름 + 작업 가이드`, 보기 전용 상태에서는 `템플릿 이름 + 티어표 제목 + 설명` 구조를 사용하고 아이브로우 클릭 시 해당 주제 허브로 이동하며 별도 `Preview` 아이브로우는 두지 않음, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 읽기 전용 상태의 즐겨찾기 단독 CTA, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, 댓글 카드 표시, 데이터가 준비되기 전에는 편집 모드/프리뷰 모드 모두 전용 스켈레톤 레이아웃을 먼저 보여주고, 저장용 랜덤 제목이나 내부 ID는 화면 표시용 제목으로 직접 노출하지 않으며, 스켈레톤이 내려간 뒤 실제 보드/풀 DOM에서만 드래그 초기화를 실행하고, `캐릭터 이름 표시` 시 이름이 칸을 넘는 항목만 하단 오버레이에서 천천히 무한 슬라이드되며, `?preview=1` 진입 시 공통 앱 셸은 유지한 채 중앙 본문에서 완성본 프리뷰와 하단 댓글 카드를 렌더링하며, 우측 뷰어 카드(`공유 티어표 보기`)는 스폰서 카드 바로 아래에서 유지하고 즐겨찾기 CTA도 함께 노출 - 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/:id`, `GET /api/tierlists/:id/comments`, `POST /api/tierlists/:id/comments`, `DELETE /api/tierlists/:id/comments/:commentId`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`, `POST /api/tierlists/template-request` ## `/comments` diff --git a/docs/spec.md b/docs/spec.md index 2f79f9c..3aed175 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -104,6 +104,7 @@ - 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 우측 로컬 패널에 배치한다. 템플릿 등록/업데이트 요청 버튼은 저장된 티어표가 있을 때만 노출하며, 제목이 비어 있는 상태에서 저장하면 랜덤 고유 제목을 먼저 부여해 저장본을 만든다. - 보기 전용 상태에서 상단 아이브로우(템플릿 이름)는 해당 주제 허브로 이동하는 액션으로 사용하며, 편집 중 미저장 변경이 있으면 이동 전에 확인 모달을 띄운다. - 보드 바로 옆에는 드래그용 아이템 풀을 별도 패널로 두고, 커스텀 아이템 이름 정리 목록은 우측 편집 패널 내부에서 관리한다. + - `캐릭터 이름 표시`를 켠 상태에서 카드 하단 이름 오버레이가 칸 너비를 넘으면, 넘치는 항목만 자동으로 천천히 무한 슬라이드한다. 너비 안에 들어오는 이름은 기존 고정 표시를 유지한다. - 관리자 화면 - 공통 우측 패널 대신 전용 로컬 운영 패널을 사용한다. - 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 관리 목록은 메인 컬럼에, 우측 운영 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다. @@ -437,10 +438,12 @@ - `SMTP_USER`: 발신용 Gmail 계정 - `SMTP_PASS`: Gmail 앱 비밀번호 - `SMTP_FROM`: 실제 메일 From 주소, 비워두면 `SMTP_USER`를 기본값으로 사용한다 + - `SKIP_EMAIL_VERIFICATION`: `true`면 개발 환경(`NODE_ENV !== production`)에서 일반 회원도 메일 인증 없이 즉시 로그인한다. 운영에서는 `false` 유지 ## 회원 인증 메모 - 첫 번째 가입 계정은 운영 초기 부트스트랩을 위해 이메일 인증 없이 바로 최고 관리자 계정으로 활성화한다. - 두 번째 이후 일반 회원가입은 가입 직후 로그인 세션을 만들지 않고, 인증 메일 링크를 눌러 `email_verified=1`이 된 뒤에만 로그인할 수 있게 한다. +- 단, 개발 환경에서 `SKIP_EMAIL_VERIFICATION=true`를 주면 두 번째 이후 일반 회원도 메일 인증 단계를 건너뛰고 즉시 로그인한다. - 인증 메일/비밀번호 재설정 메일 토큰은 원문을 DB에 저장하지 않고 SHA-256 해시만 저장하며, 새 토큰을 발급할 때는 같은 사용자의 이전 미사용 토큰을 먼저 만료 처리한다. - 이메일 인증 토큰은 24시간, 비밀번호 재설정 토큰은 1시간 유효 기간을 사용한다. - 비밀번호 재설정 링크로 새 비밀번호를 저장한 사용자는 같은 메일 주소를 확인한 것으로 보고, 기존에 미인증 상태였더라도 저장과 함께 이메일 인증을 완료 처리한다. diff --git a/docs/todo.md b/docs/todo.md index 090f155..394aee2 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,6 +1,8 @@ # 할 일 및 이슈 ## 단기 확인 +- `v1.1.31` 이후 로컬에서 `SKIP_EMAIL_VERIFICATION=true`를 적용했을 때 두 번째 가입 계정도 인증 메일 단계 없이 즉시 로그인되는지, 운영 환경(`false`)에서는 기존처럼 인증 메일 단계가 유지되는지 확인한다. +- `v1.1.31` 이후 티어표 편집 화면과 `preview=1` 화면에서 `캐릭터 이름 표시`를 켰을 때, 긴 이름만 천천히 무한 슬라이드되고 짧은 이름은 고정 표시로 유지되는지 확인한다. - `v1.1.30` 이후 관리자 목록관리에서 `템플릿 추가` 모달을 열고 검색·선택·닫기 흐름이 자연스러운지, 이미 고정된 템플릿이 추가 후보에서 제외되는지 확인한다. - `v1.1.30` 이후 상단 고정 템플릿 추가 모달이 열린 동안 마우스 휠/트랙패드/모바일 터치로 뒤쪽 관리자 화면이 스크롤되지 않는지 확인한다. - `v1.1.30` 이후 상단 고정 목록의 `위로 / 아래로` 버튼을 눌렀을 때 카드가 깜빡이지 않고 부드럽게 이동하며, 드래그 정렬과 저장 순서가 계속 일치하는지 확인한다. diff --git a/docs/update.md b/docs/update.md index 402e2d2..81ad414 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,13 @@ # 업데이트 로그 +## 2026-04-27 v1.1.31 +- 티어표 편집/뷰어 화면(`TierEditorView`)에서 `캐릭터 이름 표시`를 켰을 때 카드 하단 이름 오버레이가 박스를 넘으면 자동으로 천천히 무한 슬라이드되도록 보강했다. +- 이름 길이가 짧아 박스를 넘지 않는 경우에는 기존처럼 고정 표시(ellipsis)만 유지하고, 넘치는 경우에만 마퀴 애니메이션이 켜지도록 overflow 기준을 실제 DOM 너비로 계산하도록 정리했다. +- 같은 이름 오버레이를 쓰는 편집 보드와 `preview=1` 완성본 양쪽에 동일 동작을 적용했다. +- 백엔드 회원가입 흐름에 `SKIP_EMAIL_VERIFICATION` 환경변수를 추가했다. 개발 환경에서만 `true`일 때 일반 회원도 메일 인증을 건너뛰고 바로 로그인되도록 처리했다. +- `.env.production.example`에 `SKIP_EMAIL_VERIFICATION=false` 기본값을 추가해 로컬/운영 설정을 분리해 적용할 수 있게 했다. +- 확인: `frontend/src/views/TierEditorView.vue` lint 0건 + ## 2026-04-08 v1.1.30 - 관리자 목록관리의 홈 화면 상단 고정 템플릿 추가 방식을 전체 템플릿 목록 상시 노출에서 `템플릿 추가` 버튼 기반 검색 모달로 변경했다. 템플릿 이름, slug, ID로 검색한 뒤 선택한 항목만 고정 목록에 추가한다. - 상단 고정 템플릿 추가 모달이 열려 있는 동안 배경 페이지가 휠/터치 스크롤로 움직이지 않도록 `body.modal-scroll-lock` 잠금 처리를 추가했다. diff --git a/frontend/src/views/TierEditorView.vue b/frontend/src/views/TierEditorView.vue index 985ca58..f129007 100644 --- a/frontend/src/views/TierEditorView.vue +++ b/frontend/src/views/TierEditorView.vue @@ -204,6 +204,47 @@ function normalizeSearchText(text) { .toLowerCase() } +function updateNameMarquee(el) { + if (!el) return + const textEl = el.querySelector('.itemNameOverlay__text') + if (!textEl) return + + const content = (textEl.textContent || '').trim() + textEl.setAttribute('data-text', content) + + if (!content) { + el.classList.remove('itemNameOverlay--marquee') + el.style.removeProperty('--marquee-distance') + el.style.removeProperty('--marquee-duration') + return + } + + const overflowPx = Math.ceil(textEl.scrollWidth - textEl.clientWidth) + const needsMarquee = overflowPx > 0 + + if (!needsMarquee) { + el.classList.remove('itemNameOverlay--marquee') + el.style.removeProperty('--marquee-distance') + el.style.removeProperty('--marquee-duration') + return + } + + const distancePx = overflowPx + 20 + const duration = Math.min(24, Math.max(8, distancePx / 10)) + el.classList.add('itemNameOverlay--marquee') + el.style.setProperty('--marquee-distance', `${distancePx}px`) + el.style.setProperty('--marquee-duration', `${duration}s`) +} + +const vNameMarquee = { + mounted(el) { + updateNameMarquee(el) + }, + updated(el) { + updateNameMarquee(el) + }, +} + function createAutoTierListTitle() { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' const pick = (size) => Array.from({ length: size }, () => chars[Math.floor(Math.random() * chars.length)]).join('') @@ -1520,7 +1561,9 @@ onUnmounted(() => {
{{ column.name || '열 ' + (columnIndex + 1) }}
-
{{ itemsById[id]?.label || id }}
+
+ {{ itemsById[id]?.label || id }} +
@@ -1832,7 +1875,9 @@ onUnmounted(() => { :alt="itemsById[id]?.label || id" draggable="false" /> -
{{ itemsById[id]?.label || id }}
+
+ {{ itemsById[id]?.label || id }} +