Compare commits

...

2 Commits

Author SHA1 Message Date
4285883e28 추가: 티어표 아이템 우클릭 복제 2026-04-03 15:26:34 +09:00
b16aa046e7 수정: 파비콘 인라인 아이콘으로 재전환 2026-04-03 15:05:23 +09:00
7 changed files with 160 additions and 6 deletions

View File

@@ -1,5 +1,9 @@
# 의사결정 이력
## 2026-04-03 v1.4.66
- 특정 템플릿은 같은 아이템을 조건별로 여러 칸에 동시에 배치해야 하므로, 기존 아이템을 직접 공유해서 재사용하기보다 우클릭으로 새 복제본을 만들어 미사용 풀에 넣는 방식이 더 자연스럽다고 판단했다.
- 현재 티어표 저장 구조는 아이템 ID 기준으로 위치를 추적하므로, 복제본이 원본과 같은 ID를 쓰면 중복 배치가 불가능해진다. 따라서 복제 시 `dup-...` 형태의 새 ID를 발급해 원본과 복제본을 별도 인스턴스로 관리하기로 했다.
## 2026-04-03 v1.4.62
- 운영 서버를 새 DB로 다시 시작하는 절차는 “일반 업데이트 재빌드”와 “볼륨까지 삭제하는 완전 초기화”가 같은 문서 안에 섞이면 실수로 데이터를 날릴 위험이 크므로, 배포 문서에서 두 흐름을 별도 섹션으로 나누는 편이 맞다고 판단했다.
- DB만 비우고 업로드 볼륨을 남기는 방식도 가능하지만, 현재 서비스는 DB 레코드와 업로드 파일 참조가 강하게 연결되어 있으므로 이 방법 역시 “운영 데이터를 전부 버리는 전제”라는 경고를 같이 적어두는 쪽으로 정리했다.
@@ -645,6 +649,10 @@
- 작성자가 자기 티어표를 직접 삭제할 수 있어야 관리 흐름이 완결되므로, `내 티어표` 화면에 삭제 기능을 추가하기로 결정했다.
- 사용자 커스텀 이미지는 서버 용량을 계속 차지하므로, 참조가 하나도 남지 않은 이미지(미사용 항목)를 식별하고 관리자 화면에서 개별/일괄 정리할 수 있도록 하기로 결정했다.
## 2026-04-03 v1.4.65
- 운영 환경에서 루트 정적 favicon 요청이 계속 `403`으로 떨어지는 상황에서는 원인을 프록시/권한 계층에서 끝까지 추적하기보다, 브라우저 탭용 파비콘을 인라인 데이터 URL로 제공해 해당 요청 자체를 없애는 편이 더 단순하고 안정적이라고 판단했다.
- 다만 iOS 홈 화면용 `apple-touch-icon.png`와 외부 공유용 `og-card.png`는 실제 파일이 필요하므로, 일반 브라우저 탭 favicon만 인라인 처리하는 선으로 범위를 제한했다.
## 2026-04-03 v1.4.64
- 운영/로컬 DB를 새로 미는 흐름을 공식화한 만큼, 더 이상 “기존 DB에서만 우연히 남아 있던 컬럼”에 기대지 않고 `ensureSchema()`의 신규 생성 정의만으로 관리자 화면 전체가 떠야 한다고 다시 정리했다.
- `template_requests`는 요청 목록 카드뿐 아니라 요청 미리보기와 이미지 참조 추적에도 쓰이므로, 저장 스냅샷 컬럼(`groups_json`, `board_items_json`, `show_character_names_snapshot`)을 초기 스키마에 반드시 포함하기로 결정했다.

View File

@@ -12,7 +12,7 @@
## `/editor/:topicId/new`, `/editor/:topicId/:tierListId`
- 화면 파일: `frontend/src/views/TierEditorView.vue`
- 역할: 주제 slug 기반 에디터 진입, 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, `?preview=1` 진입 시 공통 앱 셸은 유지한 채 중앙 본문에서 완성본 프리뷰 렌더링
- 역할: 주제 slug 기반 에디터 진입, 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 아이템 클릭 선택 후 셀/풀 재배치, 아이템 우클릭 메뉴 기반 복제본 생성, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, 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`

View File

@@ -8,7 +8,7 @@
- 업로드 저장소: 로컬 파일 시스템(`backend/uploads/`)
- 운영 배포: `frontend(Nginx 정적 서빙 + /api,/uploads 프록시) + backend + mariadb` Docker Compose 구조
- NAS HTTPS 리버스 프록시 운영 시 프런트 Nginx는 백엔드로 `X-Forwarded-Proto: https`를 전달하고, Express 세션은 프록시 환경에서 `secure` 쿠키를 허용하도록 설정한다.
- 프런트 파비콘은 운영 정적 파일 차단 영향을 줄이기 위해 `index.html`의 인라인 데이터 URL로 제공한다.
- 프런트 브라우저 탭 파비콘은 운영 정적 파일 차단 영향을 줄이기 위해 `index.html`의 인라인 SVG 데이터 URL로 제공하고, iOS 홈 화면용 `apple-touch-icon.png`와 공유 미리보기용 `og-card.png`만 정적 파일로 유지한다.
- 프런트 앱 셸은 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 구조로 재정의되었고, `preview=1` 모드에서도 같은 셸을 유지한 채 중앙 본문만 완성본 프리뷰로 렌더링한다.
- 좌측 패널은 `248px`, 우측 패널은 `320px` 기준 폭을 사용하며, 우측 패널은 상단 토글 버튼으로 접고 펼칠 수 있다.
- 좌측 패널은 필요 시 축소형 레일로 접을 수 있으며, 접힌 상태에서는 아이콘 중심 내비게이션과 축약된 바로가기만 유지한다.
@@ -295,6 +295,7 @@
- 티어 행에 넣은 아이템은 작은 제거 버튼으로 다시 우측 아이템 풀로 되돌릴 수 있다.
- 편집 가능한 티어표에서는 아이템을 드래그로 옮길 수 있고, 아이템을 클릭해 선택한 뒤 원하는 셀이나 아이템 풀 빈 영역을 클릭하는 방식으로도 위치를 바꿀 수 있다.
- 클릭 배치 모드에서 선택된 아이템은 파란 포커스 테두리로 표시하며, 드래그를 시작하면 선택 상태를 해제하고 드래그 직후 짧은 클릭 입력은 무시해 드래그/클릭 조작이 섞이지 않게 한다.
- 보드 칸이나 미사용 아이템 풀의 아이템을 우클릭하면 `아이템 복제` 메뉴가 열리고, 실행 시 같은 이미지/이름/출처를 가진 새 아이템 인스턴스를 미사용 풀 맨 앞에 추가한다. 복제본은 `dup-...` 형태의 새 ID를 쓰므로 원본과 복제본을 서로 다른 칸에 동시에 배치할 수 있다.
- 기존 저장 티어표/복사본을 수정 가능한 상태로 다시 열 때는 저장본의 그룹/풀을 먼저 복원하고, 이후 현재 템플릿에 새로 추가된 기본 아이템만 미사용 풀 끝에 병합한다.
- 관리자 템플릿에서 삭제된 기본 아이템이라도 이미 저장된 티어표의 그룹/풀에 포함되어 있던 항목은 자동 제거하지 않고 그대로 보존한다.
- 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다.

View File

@@ -1,6 +1,8 @@
# 할 일 및 이슈
## 단기 확인
- 아이템 우클릭 복제 기능을 추가했으므로, 템플릿 아이템 복제/커스텀 아이템 복제/이미 보드에 배치된 아이템 복제 각각에서 복제본이 미사용 풀 맨 앞에 생기고 원본과 복제본을 서로 다른 칸에 동시에 둘 수 있는지 QA한다.
- 복제본은 `dup-...` 새 ID로 저장되므로, 저장 후 재진입/티어표 복사본 생성/뷰어 모드 열람에서도 복제본이 그대로 유지되는지와, 템플릿 업데이트 요청에 복제된 커스텀 아이템이 포함될 때 운영상 이상이 없는지 확인한다.
- `v1.4.62`에서 NAS 배포 문서에 운영 DB 완전 초기화 절차를 추가했으므로, 실제 NAS에서 `git pull → docker compose ... down -v → up -d --build` 순서로 재배포했을 때 빈 DB가 현재 스키마로 다시 올라오고 `freeform`만 생성되는지 확인한다.
- `docker volume rm tier-maker_tmaker_mariadb_data` 방식은 프로젝트 디렉터리명에 따라 실제 볼륨 이름이 달라질 수 있으므로, 운영 NAS에서는 먼저 `docker volume ls | grep tmaker`로 이름을 확인한 뒤 문서 명령이 그대로 맞는지 점검한다.
- `v1.4.61`에서 템플릿 공개 주소를 `slug`로 분리했으므로, 홈 카드/주제 상세/나의 티어표/즐겨찾기/검색 결과/팔로우 피드/사용자 프로필에서 열리는 URL이 `/topics/:slug`, `/editor/:slug/...` 형태로 바뀌고, 실제 화면 내용도 같은 주제 템플릿으로 정확히 열리는지 QA한다.
@@ -117,7 +119,7 @@
- 주제 상세 컬렉션 화면은 `pageHead` 공통 레이아웃과 `/topics` 기본 경로로 옮겼으므로, 직접 진입·뒤로가기·검색 후 재진입 시 주소와 헤더 흐름이 자연스러운지 한 번 더 QA한다.
- `/topics/:gameId`를 기본 경로로 세우고 `/games/:gameId`는 alias로 남겼으므로, 다음 단계에서는 에디터/검색/공유 흐름에서 어떤 링크를 새 경로로 더 전환할지 범위를 정한다.
- 신규 DB 기준 `template_requests` 스키마 누락은 보정했으므로, 운영 NAS에서 `down -v` 후 재배포했을 때 관리자 `/admin/featured`, `/admin/tierlists`, `/admin/users` 진입과 템플릿 요청/이미지 통계 API가 모두 500 없이 동작하는지 한 번 더 QA한다.
- 운영 환경에서 `/favicon.svg`, `/favicon-32x32.png``403 Forbidden`으로 떨어지는 현상이 남아 있으므로, 배포 컨테이너 안의 `/usr/share/nginx/html` 실제 파일/권한, 프런트 빌드 산출물 복사 상태, NAS 또는 Cloudflare 앞단 보안 규칙을 순서대로 확인한다.
- 브라우저 탭 파비콘은 다시 인라인 SVG로 돌려 정적 `/favicon.svg`, `/favicon-32x32.png` 요청 자체를 끊었으므로, 최신 배포 후 강력 새로고침 기준으로 favicon 403 로그가 실제로 사라졌는지 한 번 더 QA한다.
- 우측 레일 Teleport 대상 DOM을 상시 유지하는 방식으로 바꿨으므로, 관리자 `/admin/...` → 설정 `/profile` → 홈 `/`처럼 전용 우측 레일과 일반 우측 레일을 오가는 라우트 전환에서 콘솔 오류가 더 이상 재현되지 않는지 운영 브라우저로 한 번 더 QA한다.
- 내부 리네이밍 2단계로 관리자 `selectedTemplate / templates / loadTemplate / refreshTemplates` 묶음까지 정리했으므로, 다음 단계에서는 `/games/:gameId` 라우트와 프런트 API 호출부를 어디까지 `topic/template` 의미로 감쌀지 범위를 먼저 정리한다.
- 화면/문서 층의 용어 정리는 거의 마무리됐으므로, 다음 단계에서는 내부 `gameId / games` 모델을 실제로 옮길지, 아니면 라우트 alias/리다이렉트부터 둘지 점진 전환 순서를 정한다.

View File

@@ -1,5 +1,10 @@
# 업데이트 로그
## 2026-04-03 v1.4.66
- 티어표 편집 화면에서 보드 위 아이템이나 미사용 아이템 풀의 아이템을 우클릭하면 `아이템 복제` 메뉴가 뜨고, 선택한 아이템의 이미지/이름/출처를 유지한 새 복제본을 미사용 풀 맨 앞에 추가하도록 구현했다.
- 기존 아이템 ID를 그대로 다시 쓰면 같은 항목을 서로 다른 칸에 동시에 둘 수 없으므로, 복제 시 `dup-...` 새 ID를 발급해 원본과 복제본을 별도 아이템 인스턴스로 저장하도록 정리했다.
- 우클릭 메뉴는 메뉴 밖 클릭, 다른 곳 우클릭, 스크롤, 창 포커스 이탈 시 닫히도록 했고, 화면 가장자리에서는 메뉴가 뷰포트 밖으로 나가지 않게 좌표를 보정했다.
## 2026-04-03 v1.4.62
- UGREEN NAS 운영 배포 문서에 `git pull origin main` 후 일반 재빌드하는 절차와, 운영 데이터를 전부 버리고 `docker compose ... down -v`로 MariaDB/업로드/세션 볼륨까지 초기화한 뒤 새로 `up -d --build` 하는 절차를 분리해서 추가했다.
- DB만 비우고 싶을 때 `tmaker_mariadb_data` 볼륨만 삭제하는 방법과, 실제 볼륨 이름이 다를 수 있으니 `docker volume ls | grep tmaker`로 먼저 확인하는 안내도 함께 적었다.
@@ -1199,6 +1204,10 @@
- **아이템 카드 레이아웃 개선**: 아이템 목록과 추가 미리보기를 1:1 비율 기준으로 재구성하고 더 촘촘한 카드 그리드로 조정
- **레거시 파일 역할 정리**: `db.json`과 lowdb 관련 코드는 현재 MariaDB 기본 런타임에는 필수가 아니며, 마이그레이션/예외 fallback 용도임을 문서에 명시
## 2026-04-03 v1.4.65
- **파비콘 403 재발 차단**: 운영 환경에서 `/favicon.svg`, `/favicon-32x32.png` 정적 요청이 계속 `403 Forbidden`으로 떨어지던 문제를 피하기 위해, 브라우저 탭 파비콘을 다시 `index.html` 인라인 SVG 데이터 URL로 전환하고 해당 정적 favicon 링크를 제거
- **광고 스크립트 외부 DNS 오류 분리**: `e.dlx.addthis.com ... net::ERR_NAME_NOT_RESOLVED`는 애드센스/광고 네트워크에서 발생한 외부 도메인 해석 실패 로그로, 서비스 파비콘/관리자 API 오류와는 별개 현상으로 분리
## 2026-04-03 v1.4.64
- **신규 DB 관리자 페이지 500 수정**: 빈 DB를 새로 만든 직후 `/admin/...` 진입 시 `GET /api/admin/template-requests``GET /api/admin/image-assets/stats`가 500으로 터지던 문제를 수정
- **`template_requests` 초기 스키마 보정**: 새 테이블 생성 정의에 누락돼 있던 `groups_json`, `board_items_json`, `show_character_names_snapshot` 컬럼을 추가하고, `source_tierlist_id`는 요청 종류에 따라 비어 있을 수 있도록 `NULL` 허용으로 정리

View File

@@ -12,8 +12,11 @@
<meta name="application-name" content="Tier Maker" />
<link rel="canonical" href="https://tmaker.sori.studio/" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link
rel="icon"
type="image/svg+xml"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='18' fill='%23090d16'/%3E%3Cpath d='M17 15h30v10H36v24H26V25H17V15Z' fill='%237fe7d6'/%3E%3Cpath d='M39 31h8v18h-8V31Z' fill='%235fcaff' opacity='.9'/%3E%3C/svg%3E"
/>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta property="og:site_name" content="Tier Maker" />

View File

@@ -1,5 +1,5 @@
<script setup>
import { Teleport, computed, inject, nextTick, onUnmounted, ref, watch } from 'vue'
import { Teleport, computed, inject, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import Sortable from 'sortablejs'
import * as htmlToImage from 'html-to-image'
@@ -79,6 +79,12 @@ const poolSearchQuery = ref('')
const selectedItemId = ref('')
const recentDragFinishedAt = ref(0)
const savedEditorSnapshot = ref('')
const itemContextMenu = ref({
open: false,
x: 0,
y: 0,
itemId: '',
})
let editorLoadToken = 0
const boardEl = ref(null)
@@ -329,6 +335,31 @@ function shouldIgnoreItemClick() {
return Date.now() - recentDragFinishedAt.value < 180
}
function closeItemContextMenu() {
if (!itemContextMenu.value.open) return
itemContextMenu.value = {
open: false,
x: 0,
y: 0,
itemId: '',
}
}
function openItemContextMenu(itemId, event) {
if (!canEdit.value || !itemId || !itemsById.value[itemId] || shouldIgnoreItemClick()) return
selectedItemId.value = itemId
const viewportWidth = typeof window === 'undefined' ? 0 : window.innerWidth
const viewportHeight = typeof window === 'undefined' ? 0 : window.innerHeight
const menuX = Number(event?.clientX || 0)
const menuY = Number(event?.clientY || 0)
itemContextMenu.value = {
open: true,
x: viewportWidth ? Math.max(12, Math.min(menuX, viewportWidth - 180)) : menuX,
y: viewportHeight ? Math.max(12, Math.min(menuY, viewportHeight - 72)) : menuY,
itemId,
}
}
function getItemLocation(itemId) {
if (!itemId) return { type: null, groupId: '', columnIndex: -1, index: -1 }
@@ -405,6 +436,43 @@ function moveSelectedItemToPool() {
selectedItemId.value = ''
}
function duplicateItemToPool() {
if (!canEdit.value || !itemContextMenu.value.itemId) return
const sourceItem = itemsById.value[itemContextMenu.value.itemId]
if (!sourceItem) {
closeItemContextMenu()
return
}
const clonedId = createClonedItemId()
itemsById.value = {
...itemsById.value,
[clonedId]: {
...sourceItem,
id: clonedId,
},
}
pool.value = [clonedId, ...pool.value]
selectedItemId.value = clonedId
closeItemContextMenu()
toast.success('복제본을 미사용 아이템 목록에 추가했어요.')
}
function handleGlobalContextMenu(event) {
if (!itemContextMenu.value.open) return
const target = event?.target
if (target?.closest?.('[data-item-context-menu]') || target?.closest?.('[data-item-id]')) return
closeItemContextMenu()
}
function handleGlobalPointerDown(event) {
if (!itemContextMenu.value.open) return
const target = event?.target
if (target?.closest?.('[data-item-context-menu]')) return
closeItemContextMenu()
}
function setGroupDropEl(groupId, columnIndex, el) {
const key = `${groupId}::${columnIndex}`
if (!el) {
@@ -544,6 +612,10 @@ function createCustomItemLabel(fileName = '') {
return (normalized || 'custom').slice(0, 60)
}
function createClonedItemId() {
return `dup-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`
}
async function addGroup() {
groups.value = [
...groups.value,
@@ -1135,6 +1207,7 @@ function resetEditorStateForRoute() {
selectedItemId.value = ''
recentDragFinishedAt.value = 0
savedEditorSnapshot.value = ''
closeItemContextMenu()
resetTemplateRequestDrafts()
}
@@ -1240,7 +1313,21 @@ watch(
{ immediate: true }
)
onMounted(() => {
if (typeof window === 'undefined') return
window.addEventListener('pointerdown', handleGlobalPointerDown)
window.addEventListener('contextmenu', handleGlobalContextMenu)
window.addEventListener('blur', closeItemContextMenu)
window.addEventListener('scroll', closeItemContextMenu, true)
})
onUnmounted(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('pointerdown', handleGlobalPointerDown)
window.removeEventListener('contextmenu', handleGlobalContextMenu)
window.removeEventListener('blur', closeItemContextMenu)
window.removeEventListener('scroll', closeItemContextMenu, true)
}
if (thumbnailPreviewUrl.value) URL.revokeObjectURL(thumbnailPreviewUrl.value)
destroySortables()
})
@@ -1556,6 +1643,7 @@ onUnmounted(() => {
:class="{ 'cell--selected': selectedItemId === id }"
:data-item-id="id"
@click.stop="selectItemByClick(id)"
@contextmenu.prevent.stop="openItemContextMenu(id, $event)"
>
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
@@ -1637,6 +1725,7 @@ onUnmounted(() => {
}"
:data-item-id="id"
@click.stop="selectItemByClick(id)"
@contextmenu.prevent.stop="openItemContextMenu(id, $event)"
>
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
@@ -1646,6 +1735,19 @@ onUnmounted(() => {
</div>
</div>
<div
v-if="itemContextMenu.open && canEdit"
class="itemContextMenu"
:style="{ left: `${itemContextMenu.x}px`, top: `${itemContextMenu.y}px` }"
data-item-context-menu
@click.stop
@contextmenu.prevent.stop
>
<button class="itemContextMenu__action" type="button" @click="duplicateItemToPool">
아이템 복제
</button>
</div>
</div>
</section>
@@ -2940,6 +3042,35 @@ onUnmounted(() => {
.poolItem--hidden {
display: none;
}
.itemContextMenu {
position: fixed;
z-index: 50;
min-width: 150px;
padding: 8px;
border-radius: 16px;
border: 1px solid var(--theme-border);
background: var(--theme-card-bg);
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.28);
}
.itemContextMenu__action {
width: 100%;
border: 0;
border-radius: 12px;
padding: 11px 12px;
background: transparent;
color: var(--theme-text);
font-size: 13px;
font-weight: 800;
text-align: left;
cursor: pointer;
}
.itemContextMenu__action:hover {
background: var(--theme-pill-bg);
}
.hidden {
display: none;
}