Compare commits

...

4 Commits

15 changed files with 292 additions and 77 deletions

1
.gitignore vendored
View File

@@ -8,6 +8,7 @@ backend/.sessions/
backend/uploads/avatars/
backend/uploads/games/
backend/uploads/custom/
backend/uploads/assets/
.DS_Store
.env.production

View File

@@ -54,8 +54,6 @@ const {
} = require('../db')
const { requireAdmin } = require('../middleware/auth')
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
const { isReservedNickname } = require('../lib/user-validation')
const router = express.Router()
function getTemplateIdFromParams(req) {
@@ -212,28 +210,57 @@ router.post('/templates/:templateId/images', requireAdmin, upload.array('images'
const labels = Array.isArray(labelsRaw) ? labelsRaw : labelsRaw ? [labelsRaw] : []
const normalizedLabels = labels.map((label) => (typeof label === 'string' ? label.trim().slice(0, 60) : ''))
if (normalizedLabels.some((label) => label.length > 60)) return res.status(400).json({ error: 'bad_request' })
const totalBytes = files.reduce((sum, file) => sum + Number(file?.size || 0), 0)
const items = await Promise.all(
files.map(async (file, index) => {
const optimized = await writeOptimizedImage({
file,
directory: 'topics',
width: 512,
height: 512,
fit: 'inside',
quality: 84,
})
console.info('[admin] template image upload start', {
templateId: template.id,
fileCount: files.length,
totalBytes,
fileNames: files.map((file) => file.originalname),
})
return createTopicItem({
id: nanoid(),
topicId: template.id,
src: optimized.src,
label: normalizedLabels[index] || buildItemLabelFromFilename(file),
try {
const items = await Promise.all(
files.map(async (file, index) => {
const optimized = await writeOptimizedImage({
file,
directory: 'topics',
width: 512,
height: 512,
fit: 'inside',
quality: 84,
})
return createTopicItem({
id: nanoid(),
topicId: template.id,
src: optimized.src,
label: normalizedLabels[index] || buildItemLabelFromFilename(file),
})
})
)
console.info('[admin] template image upload success', {
templateId: template.id,
fileCount: items.length,
totalBytes,
})
)
res.json({ item: items[0], items })
res.json({ item: items[0], items })
} catch (error) {
console.error('[admin] template image upload failed', {
templateId: template.id,
fileCount: files.length,
totalBytes,
message: error?.message || 'upload_failed',
code: error?.code || '',
stack: error?.stack || '',
})
res.status(500).json({
error: 'template_image_upload_failed',
detail: error?.message || 'upload_failed',
})
}
})
router.delete('/templates/:templateId/items/:itemId', requireAdmin, async (req, res) => {
@@ -965,9 +992,6 @@ router.patch('/users/:userId', requireAdmin, async (req, res) => {
return res.status(403).json({ error: 'primary_admin_only' })
}
if (isReservedNickname(parsed.data.nickname)) {
return res.status(400).json({ error: 'nickname_reserved' })
}
const duplicateEmail = await findUserByEmail(parsed.data.email)
if (duplicateEmail && duplicateEmail.id !== targetUser.id) {
return res.status(409).json({ error: 'email_taken' })

View File

@@ -1,5 +1,10 @@
# 의사결정 이력
## 2026-04-03 v1.4.38
- 최고 관리자 보호는 서버에서만 막고 프런트 버튼은 열어두면 운영자가 계속 실패 액션을 누르게 되므로, 최고 관리자 대상 행은 일반 운영자 화면에서 버튼 자체를 비활성화하는 편이 맞다고 판단했다.
- 예약어 닉네임은 일반 가입/프로필 수정에서는 계속 막아야 하지만, 운영자가 직접 회원 계정을 정리할 때는 `공식`, `운영자` 같은 표기를 의도적으로 부여해야 할 수 있으므로 관리자 수정 API만 예외를 두는 쪽으로 정리했다.
- 공유 프리뷰는 별도 단독 페이지처럼 보이는 것보다 홈과 같은 좌측/우측 레일, 같은 중앙 헤더, 같은 메인 배경 위에 자연스럽게 얹히는 편이 서비스 유입과 탐색 전환에 더 유리하다고 판단해, 프리뷰 전용 셸을 공통 앱 셸로 흡수했다.
## 2026-04-02 v1.4.33
- 서비스 공개 전 단계에서는 가입 자체를 열어두는 것보다, 이메일/닉네임 중복과 운영자 사칭성 닉네임을 먼저 막아두는 편이 훨씬 중요하다고 판단했다.
- 닉네임 제한은 회원가입 한 곳에만 두면 이후 프로필 수정이나 관리자 수정으로 쉽게 우회되므로, auth/profile/admin 수정 흐름 전부가 같은 예약어 정책을 공유하도록 정리했다.
@@ -612,6 +617,18 @@
- 게임 이미지 경로는 저장 시 상대 경로(`/uploads/...`)를 유지하는 방향으로 정리했다.
- 현재 단계에서는 구조 변경 비용을 고려해 DB를 유지하되, 운영/확장성 요구가 커지기 전 RDB 이관 판단이 필요하다고 기록했다.
## 2026-04-03 v1.4.37
- 드롭 또는 클릭 안내를 보여주는 업로드 박스는 실제 클릭 동작까지 연결돼 있어야 UX가 자연스러우므로, 이후 같은 패턴에서는 박스 자체가 트리거 역할을 하도록 맞추기로 했다.
## 2026-04-03 v1.4.36
- 복사 기능은 “타인 티어표 가져오기”에만 묶기보다, 본인 작업도 파생본을 빠르게 만드는 용도로 열어두는 편이 실제 제작 흐름에 더 맞는다고 정리했다.
- 공유 프리뷰도 서비스와 완전히 단절된 단일 화면보다, 광고 레일과 카피라이트를 포함한 가벼운 사이트 문맥을 유지하는 편이 자연스럽다고 판단했다.
## 2026-04-03 v1.4.35
- 실제 사용 테스트에서 아이템 수가 80개 안팎으로 늘어나면 “기능은 있는데 찾을 수 없는 상태”가 되기 쉬워, 편집기 풀에는 가벼운 이름 검색이 필수라고 정리했다.
- 공유 링크는 완성본만 보여주는 데서 끝내지 말고 서비스 메인으로 돌아오는 손잡이를 함께 두는 편이 자연스럽다고 판단했다.
- 공개 프리뷰의 작성 시각은 분 단위까지 노출할 필요가 낮아, 신뢰에 필요한 최소 정보만 남기는 쪽으로 줄이기로 했다.
## 2026-04-02 v1.4.34
- 라이트모드는 단순 토글 존재만으로 충분하지 않고, 셸/카드/버튼/오버레이가 같은 색 문법을 공유해야 품질이 안정된다고 판단해 공통 토큰을 다시 정리했다.
- 홈 카드 즐겨찾기 버튼처럼 다크 전용 하드코딩이 남아 있으면 전체 인상이 쉽게 무너지므로, 이후 테마 보정은 공통 변수 우선 원칙으로 계속 가져가기로 했다.

View File

@@ -6,13 +6,13 @@
- 연동 API: `GET /api/topics`
## `/topics/:topicId`
- 화면 파일: `frontend/src/views/GameHubView.vue`
- 화면 파일: `frontend/src/views/TopicHubView.vue`
- 역할: 선택한 주제 정보 표시, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입
- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`
## `/editor/:topicId/new`, `/editor/:topicId/:tierListId`
- 화면 파일: `frontend/src/views/TierEditorView.vue`
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, `?preview=1` 진입 시 공통 앱 셸은 유지한 채 중앙 본문에서 완성본 프리뷰 렌더링
- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/:id`, `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`
## `/login`
@@ -47,7 +47,7 @@
## 공통 레이아웃
- 앱 셸 파일: `frontend/src/App.vue`
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, `preview=1` 공유 프리뷰에서도 같은 좌우 레일과 중앙 헤더 유지, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링
- 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker`와 서비스 설명이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다. 좌우 레일의 주요 액션은 각각 하단 `56px` 푸터 안에서 항상 보이도록 유지하면서 아래쪽 패딩으로 여백을 확보한다.
## 백엔드 진입점

View File

@@ -9,7 +9,7 @@
- 운영 배포: `frontend(Nginx 정적 서빙 + /api,/uploads 프록시) + backend + mariadb` Docker Compose 구조
- NAS HTTPS 리버스 프록시 운영 시 프런트 Nginx는 백엔드로 `X-Forwarded-Proto: https`를 전달하고, Express 세션은 프록시 환경에서 `secure` 쿠키를 허용하도록 설정한다.
- 프런트 파비콘은 운영 정적 파일 차단 영향을 줄이기 위해 `index.html`의 인라인 데이터 URL로 제공한다.
- 프런트 앱 셸은 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 구조로 재정의되었고, preview 모드에서는 이 셸을 숨기고 콘텐츠만 렌더링한다.
- 프런트 앱 셸은 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 구조로 재정의되었고, `preview=1` 모드에서도 같은 셸을 유지한 채 중앙 본문만 완성본 프리뷰로 렌더링한다.
- 좌측 패널은 `248px`, 우측 패널은 `320px` 기준 폭을 사용하며, 우측 패널은 상단 토글 버튼으로 접고 펼칠 수 있다.
- 좌측 패널은 필요 시 축소형 레일로 접을 수 있으며, 접힌 상태에서는 아이콘 중심 내비게이션과 축약된 바로가기만 유지한다.
- 이 3단 셸 구조는 홈, 게임 허브, 에디터, 관리자 등 일반 페이지 전반의 공통 뼈대로 유지하고, 페이지별 차이는 중앙/우측에 어떤 콘텐츠를 넣는지만 달라지도록 관리한다.
@@ -45,6 +45,7 @@
- 좌우 레일의 주요 CTA는 스크롤되는 본문과 분리된 하단 `56px` 액션 영역에 배치한다.
- 하단 액션은 화면 바닥에 바로 붙지 않도록 푸터 내부에 추가 하단 여백을 둔다.
- 홈 화면 기준 우측 패널은 임시 정보 카드 여러 개보다 핵심 CTA 하나만 남겨, 시안처럼 단순한 보조 레일 역할을 우선 유지한다.
- 광고 영역은 상단 헤더와 시각적으로 너무 붙지 않도록 `78px` 상단 여백을 두고, 하단 카피라이트는 중앙 정렬된 공통 footer로 표시한다.
- 티어표 편집 화면
- 공통 우측 패널 대신 전용 로컬 편집 패널을 사용한다.
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 보드 영역은 메인 컬럼에, 우측 `320px` 편집 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
@@ -54,7 +55,7 @@
- 관리자 화면
- 공통 우측 패널 대신 전용 로컬 운영 패널을 사용한다.
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 관리 목록은 메인 컬럼에, 우측 운영 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
- 우측 로컬 패널에는 `게임/아이템/티어표/회원 관리` 탭, 검색, 필터, 새로고침, 빠른 작업 제어를 배치하고, 중앙 영역에는 실제 관리 대상 목록과 상세만 렌더링한다. 템플릿 요청 카드는 전체 티어표 카드와 같은 썸네일 좌측/정보 우측 구조를 따르며, 요청 미리보기 일반 티어표 완성본과 같은 행·열 보드 문법으로 검수한다.
- 우측 로컬 패널에는 `게임/아이템/티어표/회원 관리` 탭, 검색, 필터, 새로고침, 빠른 작업 제어를 배치하고, 중앙 영역에는 실제 관리 대상 목록과 상세만 렌더링한다. 템플릿 요청 카드는 전체 티어표 카드와 같은 썸네일 좌측/정보 우측 구조를 따르며, 요청 미리보기`preview=1` 공유 프리뷰는 공통 앱 셸 안에서 일반 티어표 완성본과 같은 행·열 보드 문법으로 검수한다.
- 상단 헤더에는 현재 탭 기준 요약 통계 카드를 배치해 운영 상태를 먼저 읽고, 각 관리 카드는 공통 대시보드 카드 문법(두꺼운 반경, 얕은 레이어 배경, 강조된 액션 버튼)을 공유한다.
## DB 스키마
@@ -172,6 +173,8 @@
- 관리자 티어표 관리 상단에는 사용자가 보낸 템플릿 등록/업데이트 요청 목록이 별도로 표시되며, 여기서 승인/반려를 바로 처리할 수 있다.
- 관리자 템플릿 요청 목록에서 `반려 후 숨김`을 누르면 해당 요청은 pending 목록에서 즉시 제외된다.
- 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다.
- 단, 일반 운영자는 최고 관리자 계정의 프로필 이미지/회원 정보/비밀번호/삭제 버튼을 사용할 수 없고, 최고 관리자만 다른 관리자 권한을 변경할 수 있다.
- 관리자 회원 정보 수정은 운영상 필요한 경우 예약어 닉네임도 저장할 수 있지만, 일반 회원가입과 개인 프로필 수정에서는 운영자 사칭성 예약어 닉네임을 계속 차단한다.
- 회원 관리 카드에는 아바타, 작성 티어표 수, 최근 활동 시각을 함께 표시한다.
## 티어표 접근 메모

View File

@@ -1,6 +1,10 @@
# 할 일 및 이슈
## 단기 확인
- `v1.4.38`에서 운영자 계정의 최고 관리자 관리 버튼을 프런트에서 비활성화했으므로, 최고 관리자/일반 운영자/일반 회원 세 계정 조합으로 회원 정보 수정, 썸네일 변경, 비밀번호 초기화, 삭제 버튼 활성 상태와 서버 차단 응답이 기대대로 맞는지 확인한다.
- 관리자 회원 정보 수정에서는 예약어 닉네임을 허용하도록 예외를 열었으므로, 일반 회원가입/개인 프로필 수정은 여전히 예약어가 막히고 관리자 수정만 저장되는지 한 번 더 QA한다.
- `preview=1` 화면이 공통 앱 셸을 그대로 쓰도록 바뀌었으므로, 좌측 레일 접기/펼치기, 우측 레일 닫기/열기, 중앙 헤더 타이틀, 메인 배경색, 오른쪽 광고와 카피라이트 정렬이 홈 화면과 같은 문법으로 보이는지 비교 QA한다.
- 프리뷰 우측 광고에 `padding-top: 78px`를 넣었으므로, 데스크톱/태블릿 폭에서 광고 시작 위치가 너무 내려가거나 모바일 오버레이 레이아웃에서 어색해지지 않는지 확인한다.
- `v1.4.33`에서 회원가입에 닉네임 입력과 중복/예약어 검사를 붙였으므로, 실제 QA에서는 이메일 중복, 닉네임 중복, 예약 닉네임, 프로필 닉네임 변경, 관리자 회원 수정 흐름이 같은 규칙으로 막히는지 확인한다.
- 테마는 저장값이 없을 때 무조건 다크로 시작하게 바꿨고 설정 화면 토글도 다시 열었으므로, 첫 접속/새 브라우저/다른 운영체제에서 기본 다크 시작과 수동 토글 저장이 그대로 정상인지 확인한다.
- 관리자 템플릿 썸네일 드롭존 빈 상태 아이콘 제거와 아이템 상세 모달 썸네일 프리뷰가 들어갔으므로, 관리자 화면에서 썸네일 교체와 아이템 선택 모달 가독성을 한 번 더 QA한다.
@@ -124,6 +128,8 @@
- 책 아이콘 기반 사용법 모달은 제작 흐름뿐 아니라 복사, 템플릿 업데이트 요청, 새 템플릿 요청까지 확장했으므로, 실제 16:9 스크린샷 자산과 단계별 문구를 운영 톤에 맞게 채운다.
- 관리자 아이템 라이브러리에서 동일 이미지(src)를 여러 템플릿이 공유하는 경우, 필요하면 묶어서 보거나 대표 카드로 합쳐 보는 후속 정리 옵션을 검토한다.
- 라이트모드 최종 QA 시 홈/설정/관리자/에디터를 실제 사용 흐름으로 돌리며, 남아 있는 하드코딩 텍스트 색과 플레이스홀더 배경을 한 번 더 점검한다.
- 템플릿 기본 아이템 다중 업로드는 8개까지 성공, 9개 이상 한 번에 전송 시 실패하는 사례가 있었으므로 NAS/리버스 프록시의 업로드 body 제한(`client_max_body_size` 등)과 실제 응답 코드를 운영 환경에서 확인한다.
- 프리뷰 우측 광고 레일을 붙였으므로, 실제 운영 환경에서 광고가 로드될 때 프리뷰 본문 폭이 과하게 줄지 않는지 데스크톱 기준으로 한 번 더 확인한다.
- 관리자 아이템 라이브러리는 보관 자산까지 노출되므로, 이후에는 `활성 템플릿 / 보관 자산` 분리 필터나 그룹 보기까지 검토한다.
- 가이드 모달과 관리자 아이템 모달은 현재 같은 톤의 큰 셸을 쓰므로, 이후 공통 모달 레이아웃 컴포넌트로 분리할지 검토한다.
- 관리자 아이템 라이브러리 이름 변경은 템플릿·사용자 업로드·보관 자산까지 모두 가능하므로, 이후에는 일괄 이름 정리나 중복 이름 감지 보조 기능까지 검토한다.

View File

@@ -1,5 +1,12 @@
# 업데이트 로그
## 2026-04-03 v1.4.38
- 관리자 회원 관리에서 운영자 계정으로는 최고 관리자 계정의 썸네일 변경, 비밀번호 초기화, 회원 삭제, 회원 정보 수정 버튼이 비활성화되도록 프런트 보호를 추가했고, 자기 자신 삭제 버튼도 함께 막았다.
- 관리자 회원 정보 수정에서는 운영자/관리자 예약어가 들어간 닉네임도 저장할 수 있도록 서버 검증 예외를 분리했고, 일반 회원가입과 개인 프로필 수정의 예약어 차단은 그대로 유지했다.
- `preview=1` 티어표 화면은 별도 프리뷰 셸을 걷어내고 공통 좌측 레일·중앙 헤더·우측 레일을 그대로 쓰도록 바꿨으며, 프리뷰 본문 제목도 홈 화면과 같은 `pageHead` 문법으로 맞췄다.
- 프리뷰/일반 화면의 오른쪽 광고 레일은 같은 공통 레일 footer를 사용하게 되어 카피라이트 중앙 정렬이 맞춰졌고, 애드센스 영역 상단에는 `padding-top: 78px`를 적용해 시각적 시작점을 조금 아래로 내렸다.
- 로컬에서 생성되는 `backend/uploads/assets/` 최적화 이미지가 Git 변경분에 섞이지 않도록 `.gitignore` 제외 경로에 추가했다.
## 2026-04-02 v1.4.33
- 회원가입 시 닉네임 입력을 함께 받도록 바꾸고, 이메일 중복과 닉네임 중복을 서버에서 명확히 차단하도록 정리했다.
- `admin`, `운영자`, `관리자`, `official`, `zenn`처럼 운영자·공식 계정으로 오해될 수 있는 닉네임은 예약어로 막고, 프로필 수정/관리자 회원 수정에서도 같은 규칙을 공유하도록 맞췄다.
@@ -1104,6 +1111,21 @@
- **티어표 데이터 정규화**: 게임 이미지 경로가 절대 로컬 URL로 저장되지 않도록 저장/조회 시 `/uploads/...` 상대 경로로 정규화
- **프로젝트 점검 결과 문서화**: DB 구조, 화면-파일 매핑, 코딩 규칙, 기술 명세, 남은 위험 요소를 `docs/`에 신규 정리
## 2026-04-03 v1.4.37
- **썸네일 업로드 UX 보정**: 티어표 편집기 우측 `대표 썸네일` 프레임을 클릭/엔터/스페이스로 바로 파일 선택할 수 있게 바꾸고, 중복이던 `파일 업로드` 버튼은 제거
## 2026-04-03 v1.4.36
- **자기 티어표 복사 허용**: 기존에는 타인의 저장본만 복사할 수 있었지만, 이제는 본인 티어표도 저장본이면 복사해서 일부만 수정한 새 버전으로 다시 작업할 수 있게 변경
- **프리뷰 우측 레일 추가**: 공유 프리뷰 화면도 본 사이트 문법을 더 닮도록 우측에 300×600 광고 레일과 카피라이트를 붙이고, 모바일 폭에서는 자동으로 숨기도록 정리
## 2026-04-03 v1.4.35
- **에디터 아이템 검색 추가**: 미배치 아이템이 많아졌을 때 바로 찾을 수 있도록 사이드바에 `아이템 이름 검색` 입력과 `표시 개수 / 전체 개수`를 추가
- **검색 중 드래그 유지**: 아이템 풀 검색은 목록 순서를 바꾸지 않고 일치하지 않는 항목만 숨기는 방식으로 넣어, 검색 중에도 바로 드래그 배치할 수 있게 유지
- **공유 프리뷰 유입선 보강**: 공유 링크 프리뷰 좌상단에 `Tier Maker` 로고 링크를 추가해, 미리보기에서 메인 화면으로 자연스럽게 돌아올 수 있게 함
- **작성 시각 노출 축소**: 프리뷰와 이미지 저장 하단 메타 정보의 시간 표시를 제거하고 날짜까지만 남겨 개인 생활 패턴 노출을 줄임
- **업로드 추적 로그 보강**: 관리자 템플릿 기본 아이템 업로드는 프런트/백엔드 양쪽에서 파일 수·총 용량·응답 상태를 콘솔에 남기도록 해, 다중 업로드 실패 원인을 다음 재현 때 바로 좁힐 수 있게 보강
- **카피라이트 링크 변경**: 우측 레일 하단 카피라이트의 `zenn` 링크를 `https://x.com/zennbox`로 변경
## 2026-04-02 v1.4.34
- **라이트모드 팔레트 재정비**: 공통 라이트 테마 색상을 회색 위주에서 더 정돈된 청회색 계열로 다시 잡고, 셸/레일/메인/카드 표면 대비를 처음부터 재조정
- **공통 토큰 확장**: 강조색 강도, 강조 배경, 오버레이 스크림, 아바타 테두리, 즐겨찾기 버튼 상태색을 공통 변수로 분리해 화면별 하드코딩을 줄임

View File

@@ -22,7 +22,7 @@ const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const { toasts, dismissToast } = useToast()
const RIGHT_RAIL_COPYRIGHT_URL = 'https://zenn.town/@murabito'
const RIGHT_RAIL_COPYRIGHT_URL = 'https://x.com/zennbox'
const currentTopicId = computed(() => route.params.topicId || '')
const leftRailCollapsed = ref(false)
@@ -43,7 +43,9 @@ const authReady = computed(() => auth.hydrated)
const isAdmin = computed(() => authReady.value && !!auth.user?.isAdmin)
const isPreviewMode = computed(() => route.query.preview === '1')
const isAdminRoute = computed(() => String(route.name || '').startsWith('admin'))
const usesLocalRightRail = computed(() => ['editEditor', 'newEditor'].includes(String(route.name || '')) || isAdminRoute.value)
const usesLocalRightRail = computed(
() => !isPreviewMode.value && (['editEditor', 'newEditor'].includes(String(route.name || '')) || isAdminRoute.value)
)
const isRightRailOverlay = computed(() => viewportWidth.value <= 1200)
const isMobileLayout = computed(() => viewportWidth.value <= 860)
const avatarUrl = computed(() => (auth.user?.avatarSrc ? toApiUrl(auth.user.avatarSrc) : ''))
@@ -424,7 +426,6 @@ function reloadApp() {
<div
class="appShell"
:class="{
'appShell--preview': isPreviewMode,
'appShell--leftCollapsed': leftRailCollapsed,
'appShell--rightClosed': !rightRailOpen,
'appShell--rightOverlay': isRightRailOverlay,
@@ -450,11 +451,6 @@ function reloadApp() {
</section>
</main>
</template>
<template v-else-if="isPreviewMode">
<main class="appMain appMain--preview">
<RouterView />
</main>
</template>
<template v-else>
<aside class="leftRail">
<div class="leftRail__top railHeader">
@@ -697,6 +693,7 @@ function reloadApp() {
}
.backendFallback {
min-width: 100dvw;
min-height: 100dvh;
display: grid;
place-items: center;
@@ -755,10 +752,6 @@ function reloadApp() {
cursor: pointer;
}
.appShell--preview {
display: block;
}
.leftRail,
.rightRail {
min-height: 100dvh;
@@ -1220,10 +1213,6 @@ function reloadApp() {
border-right: 1px solid var(--theme-border);
}
.appMain--preview {
padding: 0;
}
.workspace {
display: grid;
grid-template-rows: 56px minmax(0, 1fr);

View File

@@ -67,6 +67,7 @@ onMounted(async () => {
.rightRailAd {
display: grid;
gap: 12px;
padding-top: 78px;
}
.rightRailAd__eyebrow {

View File

@@ -15,6 +15,10 @@ const props = defineProps({
userDisplayName: { type: Function, required: true },
userAvatarFallback: { type: Function, required: true },
removeUserAvatar: { type: Function, required: true },
canEditUserAvatar: { type: Function, required: true },
canEditUserInfo: { type: Function, required: true },
canResetUserPassword: { type: Function, required: true },
canDeleteUser: { type: Function, required: true },
roleLabelOf: { type: Function, required: true },
fmt: { type: Function, required: true },
openUserPasswordModal: { type: Function, required: true },
@@ -76,7 +80,12 @@ const userSortDirectionModel = computed({
@change="props.onUserAvatarChange(user, $event)"
/>
<div class="userAvatarWrap">
<button class="userAvatar userAvatarButton" type="button" :disabled="user.isAvatarBusy" @click="props.openUserAvatarPicker(user)">
<button
class="userAvatar userAvatarButton"
type="button"
:disabled="user.isAvatarBusy || !props.canEditUserAvatar(user)"
@click="props.openUserAvatarPicker(user)"
>
<img v-if="props.userAvatarUrl(user)" class="userAvatar__image" :src="props.userAvatarUrl(user)" :alt="props.userDisplayName(user)" />
<span v-else class="userAvatar__fallback">{{ props.userAvatarFallback(user) }}</span>
<span class="userAvatarButton__overlay">{{ user.isAvatarBusy ? '업데이트중...' : '수정' }}</span>
@@ -86,7 +95,7 @@ const userSortDirectionModel = computed({
class="userAvatarRemoveButton"
type="button"
title="회원 썸네일 삭제"
:disabled="user.isAvatarBusy"
:disabled="user.isAvatarBusy || !props.canEditUserAvatar(user)"
@click.stop="props.removeUserAvatar(user)"
>
<SvgIcon class="userAvatarRemoveIcon" :src="props.deleteIcon" :size="12" />
@@ -111,13 +120,32 @@ const userSortDirectionModel = computed({
</div>
<div class="userCard__actions userCard__actions--compact">
<button class="iconActionButton" type="button" title="비밀번호 초기화" @click="props.openUserPasswordModal(user)">
<button
class="iconActionButton"
type="button"
title="비밀번호 초기화"
:disabled="!props.canResetUserPassword(user)"
@click="props.openUserPasswordModal(user)"
>
<SvgIcon class="iconActionButton__icon" :src="props.lockResetIcon" :size="18" />
</button>
<button class="iconActionButton iconActionButton--danger" type="button" title="회원 삭제" @click="props.openUserDeleteModal(user)">
<button
class="iconActionButton iconActionButton--danger"
type="button"
title="회원 삭제"
:disabled="!props.canDeleteUser(user)"
@click="props.openUserDeleteModal(user)"
>
<SvgIcon class="iconActionButton__icon" :src="props.deleteIcon" :size="18" />
</button>
<button class="btn btn--ghost userSaveButton" type="button" @click="props.openUserEditModal(user)">회원 정보 수정</button>
<button
class="btn btn--ghost userSaveButton"
type="button"
:disabled="!props.canEditUserInfo(user)"
@click="props.openUserEditModal(user)"
>
회원 정보 수정
</button>
</div>
</article>
</div>

View File

@@ -278,9 +278,16 @@ export function useAdminTemplateManager({
const fileDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'file')
const requestDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'request')
const totalUploadBytes = fileDrafts.reduce((sum, entry) => sum + Number(entry.file?.size || 0), 0)
let uploadCount = 0
if (fileDrafts.length) {
console.info('[admin] template item upload start', {
topicId: selectedTemplateId.value,
fileCount: fileDrafts.length,
totalBytes: totalUploadBytes,
labels: fileDrafts.map((entry) => entry.label.trim()),
})
const fd = new FormData()
fileDrafts.forEach((entry) => {
fd.append('images', entry.file)
@@ -291,7 +298,25 @@ export function useAdminTemplateManager({
credentials: 'include',
body: fd,
})
if (!res.ok) throw new Error('failed')
if (!res.ok) {
const responseText = await res.text().catch(() => '')
console.error('[admin] template item upload failed', {
topicId: selectedTemplateId.value,
fileCount: fileDrafts.length,
totalBytes: totalUploadBytes,
status: res.status,
body: responseText,
})
const uploadError = new Error('failed')
uploadError.status = res.status
uploadError.body = responseText
throw uploadError
}
console.info('[admin] template item upload success', {
topicId: selectedTemplateId.value,
fileCount: fileDrafts.length,
totalBytes: totalUploadBytes,
})
uploadCount += fileDrafts.length
}
@@ -316,6 +341,12 @@ export function useAdminTemplateManager({
await loadTemplate()
success.value = `템플릿 기본 아이템 ${uploadCount}개 추가를 완료했어요.`
} catch (e) {
console.error('[admin] uploadItem error', {
message: e?.message || '',
status: e?.status || 0,
body: e?.body || '',
data: e?.data || null,
})
const apiError = e?.data?.error || ''
if (apiError === 'no_items_selected') {
error.value = '추가할 요청 아이템이 없어요.'
@@ -330,6 +361,10 @@ export function useAdminTemplateManager({
error.value = '선택한 템플릿을 찾지 못했어요.'
return
}
if (e?.status === 413) {
error.value = '한 번에 업로드한 파일 용량이 너무 커서 실패했어요.'
return
}
error.value = '아이템 추가에 실패했어요.'
}
}

View File

@@ -38,6 +38,29 @@ export function useAdminUsers({
return !modalTargetUser.value.isPrimaryAdmin
})
function canManageUser(user) {
if (!user?.isPrimaryAdmin) return true
return !!auth.user?.isPrimaryAdmin
}
function canEditUserAvatar(user) {
return canManageUser(user)
}
function canEditUserInfo(user) {
return canManageUser(user)
}
function canResetUserPassword(user) {
return canManageUser(user)
}
function canDeleteUser(user) {
if (!canManageUser(user)) return false
if (user?.isAdmin && !auth.user?.isPrimaryAdmin) return false
return user?.id !== auth.user?.id
}
const isUserEditDirty = computed(() => {
if (!modalTargetUser.value) return false
return (
@@ -54,6 +77,7 @@ export function useAdminUsers({
}
function openUserAvatarPicker(user) {
if (!canEditUserAvatar(user)) return
userAvatarInputs.value[user?.id]?.click()
}
@@ -96,11 +120,13 @@ export function useAdminUsers({
}
async function removeUserAvatar(user) {
if (!canEditUserAvatar(user)) return
if (!user?.avatarSrc) return
await uploadUserAvatar(user, null, { remove: true })
}
function openUserEditModal(user) {
if (!canEditUserInfo(user)) return
resetMessages()
modalTargetUser.value = user ? { ...user } : null
modalUserDraftEmail.value = user?.email || ''
@@ -147,6 +173,7 @@ export function useAdminUsers({
}
function openUserPasswordModal(user) {
if (!canResetUserPassword(user)) return
resetMessages()
modalTargetUser.value = user ? { ...user } : null
modalPasswordDraft.value = ''
@@ -183,6 +210,7 @@ export function useAdminUsers({
}
function openUserDeleteModal(user) {
if (!canDeleteUser(user)) return
resetMessages()
modalTargetUser.value = user ? { ...user } : null
userDeleteModalOpen.value = true
@@ -242,6 +270,10 @@ export function useAdminUsers({
return {
setUserAvatarInput,
canManageModalRole,
canEditUserAvatar,
canEditUserInfo,
canResetUserPassword,
canDeleteUser,
isUserEditDirty,
roleLabelOf,
openUserAvatarPicker,

View File

@@ -1039,6 +1039,10 @@ const {
const {
setUserAvatarInput,
canManageModalRole,
canEditUserAvatar,
canEditUserInfo,
canResetUserPassword,
canDeleteUser,
isUserEditDirty,
roleLabelOf,
openUserAvatarPicker,
@@ -1795,6 +1799,10 @@ function userAvatarFallback(user) {
:user-display-name="userDisplayName"
:user-avatar-fallback="userAvatarFallback"
:remove-user-avatar="removeUserAvatar"
:can-edit-user-avatar="canEditUserAvatar"
:can-edit-user-info="canEditUserInfo"
:can-reset-user-password="canResetUserPassword"
:can-delete-user="canDeleteUser"
:role-label-of="roleLabelOf"
:fmt="fmt"
:open-user-password-modal="openUserPasswordModal"

View File

@@ -72,6 +72,7 @@ const favoriteCount = ref(0)
const isFavorited = ref(false)
const isRequestingTemplate = ref(false)
const isDeleting = ref(false)
const poolSearchQuery = ref('')
const boardEl = ref(null)
const exportBoardEl = ref(null)
@@ -113,7 +114,7 @@ const untitledWarning = computed(
'제목 없이 저장된 티어표는 무분별한 도배 방지를 위해 관리자에 의해 임의 삭제될 수 있어요.'
)
const canFavorite = computed(() => !!auth.user && !isNewTierList.value && !canEdit.value)
const canDuplicate = computed(() => !!auth.user && !isNewTierList.value && !canEdit.value)
const canDuplicate = computed(() => !!auth.user && hasSavedTierList.value)
const copiedFromLabel = computed(() => {
if (!sourceTierListId.value) return ''
const parts = []
@@ -122,6 +123,7 @@ const copiedFromLabel = computed(() => {
return parts.join(' · ') || '복사해 온 티어표'
})
const customItems = computed(() => getOrderedItems().filter((item) => item?.origin === 'custom'))
const normalizedPoolSearchQuery = computed(() => poolSearchQuery.value.trim().toLowerCase())
const hasSavedTierList = computed(() => !!(persistedTierListId.value || (tierListId.value && tierListId.value !== 'new')))
const canRequestTemplateCreate = computed(
() => canEdit.value && hasSavedTierList.value && templateId.value === 'freeform' && customItems.value.length > 0
@@ -138,7 +140,6 @@ const shareTierListUrl = computed(() => {
if (typeof window === 'undefined') return editorPath(templateId.value, savedTierListId, { preview: true })
return new URL(editorPath(templateId.value, savedTierListId, { preview: true }), window.location.origin).toString()
})
watch(error, (message) => {
if (!message) return
toast.error(message)
@@ -166,8 +167,6 @@ function formatExportDate(ts) {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
@@ -194,6 +193,16 @@ function getOrderedItems() {
return getOrderedItemIds().map((itemId) => itemsById.value[itemId]).filter(Boolean)
}
function isPoolItemVisible(itemId) {
const query = normalizedPoolSearchQuery.value
if (!query) return true
const item = itemsById.value[itemId]
const label = String(item?.label || itemId || '').toLowerCase()
return label.includes(query)
}
const visiblePoolCount = computed(() => pool.value.filter((itemId) => isPoolItemVisible(itemId)).length)
function setIconSize(nextSize) {
iconSize.value = nextSize
}
@@ -962,9 +971,14 @@ onUnmounted(() => {
<template>
<section v-if="previewMode" class="previewOnly" :style="{ '--thumb-size': `${iconSize}px` }">
<header class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Preview</div>
<h1 class="pageHead__title">{{ effectiveTitle }}</h1>
<p v-if="description" class="pageHead__desc">{{ description }}</p>
</div>
</header>
<div class="previewOnly__sheet">
<div class="previewOnly__title">{{ effectiveTitle }}</div>
<div v-if="description" class="previewOnly__description">{{ description }}</div>
<div v-if="columns.length > 1" class="previewOnly__columns">
<div class="previewOnly__columnsSpacer" aria-hidden="true"></div>
<div class="previewOnly__columnsGrid" :style="{ '--column-count': columns.length }">
@@ -1264,12 +1278,28 @@ onUnmounted(() => {
</div>
<div class="sidebar">
<div class="sidebar__title">아이템</div>
<div class="sidebar__titleRow">
<div class="sidebar__title">아이템</div>
<div class="sidebar__count">{{ visiblePoolCount }} / {{ pool.length }}</div>
</div>
<div class="sidebar__hint">
{{ canEdit ? '등록된 아이템 리스트입니다. 드래그해서 표에 넣을 수 있습니다.' : '공개 티어표는 보기 전용입니다.' }}
</div>
<input
v-model="poolSearchQuery"
class="sidebar__search"
type="text"
maxlength="60"
placeholder="아이템 이름 검색"
/>
<div ref="poolEl" class="pool" data-list-type="pool">
<div v-for="id in pool" :key="id" class="poolItem" :class="{ 'poolItem--readonly': !canEdit }" :data-item-id="id">
<div
v-for="id in pool"
:key="id"
class="poolItem"
:class="{ 'poolItem--readonly': !canEdit, 'poolItem--hidden': !isPoolItemVisible(id) }"
:data-item-id="id"
>
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
<div v-if="!canEdit" class="poolItem__state">미배치</div>
@@ -1309,6 +1339,11 @@ onUnmounted(() => {
<div
class="editorSidebar__thumbFrame"
:class="{ 'editorSidebar__thumbFrame--active': isThumbnailDragActive }"
:role="canEdit ? 'button' : undefined"
:tabindex="canEdit ? 0 : undefined"
@click="canEdit ? openThumbnailFile() : null"
@keydown.enter.prevent="canEdit ? openThumbnailFile() : null"
@keydown.space.prevent="canEdit ? openThumbnailFile() : null"
@dragenter.prevent="onThumbnailDragEnter"
@dragover.prevent="onThumbnailDragEnter"
@dragleave="onThumbnailDragLeave"
@@ -1318,7 +1353,6 @@ onUnmounted(() => {
<div v-else class="editorSidebar__thumbEmpty">대표 썸네일</div>
<div class="editorSidebar__thumbOverlay">드래그 또는 클릭으로 썸네일 추가</div>
</div>
<button v-if="canEdit" class="btn btn--ghost editorSidebar__button" @click="openThumbnailFile">파일 업로드</button>
<div v-if="pendingThumbnailFile" class="editorSidebar__fileName">{{ pendingThumbnailFile.name }}</div>
</div>
@@ -1440,30 +1474,18 @@ onUnmounted(() => {
cursor: pointer;
}
.previewOnly {
min-height: 100vh;
padding: 20px;
box-sizing: border-box;
background:
radial-gradient(circle at top, rgba(96, 165, 250, 0.14), transparent 38%),
var(--theme-shell-bg);
display: grid;
gap: 18px;
}
.previewOnly__sheet {
display: grid;
gap: 16px;
width: 100%;
max-width: 1280px;
margin: 0 auto;
}
.previewOnly__title {
font-size: 28px;
font-weight: 900;
letter-spacing: -0.03em;
}
.previewOnly__description {
margin-top: -8px;
font-size: 14px;
line-height: 1.6;
opacity: 0.76;
padding: 18px;
border-radius: 24px;
border: 1px solid var(--theme-card-border);
background: var(--theme-card-bg);
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
}
.previewOnly__columns {
display: grid;
@@ -2443,6 +2465,30 @@ onUnmounted(() => {
font-size: 13px;
line-height: 1.4;
}
.sidebar__titleRow {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.sidebar__count {
font-size: 12px;
font-weight: 700;
color: var(--theme-text-soft);
}
.sidebar__search {
width: 100%;
margin-top: 12px;
margin-bottom: 12px;
padding: 11px 14px;
border-radius: 14px;
border: 1px solid var(--theme-border);
background: var(--theme-card-bg);
color: var(--theme-text);
}
.sidebar__search::placeholder {
color: var(--theme-text-faint);
}
.pool {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
@@ -2490,6 +2536,9 @@ onUnmounted(() => {
text-transform: uppercase;
color: var(--theme-text-soft);
}
.poolItem--hidden {
display: none;
}
.hidden {
display: none;
}