Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b15ec12fa | |||
| 28c6dafa02 | |||
| f98524390b | |||
| da37fe9fc9 | |||
| d09cd7e508 | |||
| 907ea75182 | |||
| 4285883e28 | |||
| b16aa046e7 | |||
| 958c75d4fb | |||
| d5d4974751 |
@@ -14,6 +14,7 @@ const topicsRoutes = require('./src/routes/topics')
|
||||
const tierListsRoutes = require('./src/routes/tierlists')
|
||||
const usersRoutes = require('./src/routes/users')
|
||||
const adminRoutes = require('./src/routes/admin')
|
||||
const shareRoutes = require('./src/routes/share')
|
||||
|
||||
const app = express()
|
||||
const PORT = process.env.PORT ? Number(process.env.PORT) : 5179
|
||||
@@ -88,6 +89,7 @@ app.use('/api/topics', topicsRoutes)
|
||||
app.use('/api/tierlists', tierListsRoutes)
|
||||
app.use('/api/users', usersRoutes)
|
||||
app.use('/api/admin', adminRoutes)
|
||||
app.use('/share', shareRoutes)
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[backend] listening on http://localhost:${PORT}`)
|
||||
|
||||
@@ -470,7 +470,7 @@ async function ensureSchema() {
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
request_type VARCHAR(20) NOT NULL,
|
||||
requester_id VARCHAR(64) NOT NULL,
|
||||
source_tierlist_id VARCHAR(64) NOT NULL,
|
||||
source_tierlist_id VARCHAR(64) NULL DEFAULT NULL,
|
||||
source_topic_id VARCHAR(120) NOT NULL,
|
||||
target_topic_id VARCHAR(120) NOT NULL DEFAULT '',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
@@ -478,6 +478,9 @@ async function ensureSchema() {
|
||||
description_snapshot TEXT NOT NULL,
|
||||
thumbnail_src_snapshot VARCHAR(255) NOT NULL DEFAULT '',
|
||||
items_json LONGTEXT NOT NULL,
|
||||
groups_json LONGTEXT NOT NULL,
|
||||
board_items_json LONGTEXT NOT NULL,
|
||||
show_character_names_snapshot TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
INDEX idx_template_requests_status_created (status, created_at),
|
||||
@@ -1792,7 +1795,11 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
const templateSrcSet = new Set(topicItemRows.map((row) => row.src).filter(Boolean))
|
||||
const customSrcSet = new Set(customRows.map((row) => row.src).filter(Boolean))
|
||||
const assetLibraryItems = assetRows
|
||||
.filter((row) => row?.src && !templateSrcSet.has(row.src) && !customSrcSet.has(row.src))
|
||||
.filter((row) => {
|
||||
if (!row?.src) return false
|
||||
if (avatarSrcSet.has(row.src) || thumbnailSrcSet.has(row.src)) return true
|
||||
return !templateSrcSet.has(row.src) && !customSrcSet.has(row.src)
|
||||
})
|
||||
.map((row) => ({
|
||||
id: `asset:${row.id}`,
|
||||
assetId: row.id,
|
||||
|
||||
75
backend/src/routes/share.js
Normal file
75
backend/src/routes/share.js
Normal file
@@ -0,0 +1,75 @@
|
||||
const express = require('express')
|
||||
const { findTierListById } = require('../db')
|
||||
|
||||
const router = express.Router()
|
||||
const APP_ORIGIN = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, '')
|
||||
const DEFAULT_TITLE = 'Tier Maker | 템플릿으로 쉽게 만드는 티어표'
|
||||
const DEFAULT_DESCRIPTION = '템플릿과 커스텀 이미지로 티어표를 만들고 저장하고 공유하세요.'
|
||||
const DEFAULT_IMAGE_URL = `${APP_ORIGIN}/og-card.png`
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function toAbsoluteUrl(pathname) {
|
||||
const src = String(pathname || '').trim()
|
||||
if (!src) return DEFAULT_IMAGE_URL
|
||||
if (/^https?:\/\//i.test(src)) return src
|
||||
return `${APP_ORIGIN}${src.startsWith('/') ? src : `/${src}`}`
|
||||
}
|
||||
|
||||
function buildShareHtml({ title, description, imageUrl, shareUrl, appUrl }) {
|
||||
const safeTitle = escapeHtml(title || DEFAULT_TITLE)
|
||||
const safeDescription = escapeHtml(description || DEFAULT_DESCRIPTION)
|
||||
const safeImageUrl = escapeHtml(imageUrl || DEFAULT_IMAGE_URL)
|
||||
const safeShareUrl = escapeHtml(shareUrl || APP_ORIGIN)
|
||||
const safeAppUrl = escapeHtml(appUrl || APP_ORIGIN)
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>${safeTitle}</title>
|
||||
<meta name="description" content="${safeDescription}" />
|
||||
<link rel="canonical" href="${safeAppUrl}" />
|
||||
<meta property="og:site_name" content="Tier Maker" />
|
||||
<meta property="og:locale" content="ko_KR" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="${safeShareUrl}" />
|
||||
<meta property="og:title" content="${safeTitle}" />
|
||||
<meta property="og:description" content="${safeDescription}" />
|
||||
<meta property="og:image" content="${safeImageUrl}" />
|
||||
<meta property="og:image:alt" content="${safeTitle}" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="${safeTitle}" />
|
||||
<meta name="twitter:description" content="${safeDescription}" />
|
||||
<meta name="twitter:image" content="${safeImageUrl}" />
|
||||
<meta http-equiv="refresh" content="0; url=${safeAppUrl}" />
|
||||
</head>
|
||||
<body>
|
||||
<script>window.location.replace(${JSON.stringify(appUrl || APP_ORIGIN)})</script>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
router.get('/editor/:topicId/:tierListId', async (req, res) => {
|
||||
const { topicId, tierListId } = req.params
|
||||
const appUrl = `${APP_ORIGIN}/editor/${encodeURIComponent(topicId)}/${encodeURIComponent(tierListId)}?preview=1`
|
||||
const shareUrl = `${APP_ORIGIN}${req.originalUrl || `/share/editor/${encodeURIComponent(topicId)}/${encodeURIComponent(tierListId)}`}`
|
||||
|
||||
const tierList = await findTierListById(tierListId)
|
||||
const isPublicMatch = tierList?.isPublic && (tierList.topicSlug === topicId || tierList.topicId === topicId)
|
||||
const title = isPublicMatch ? tierList.title : DEFAULT_TITLE
|
||||
const description = isPublicMatch && tierList.description ? tierList.description : DEFAULT_DESCRIPTION
|
||||
const imageUrl = isPublicMatch ? toAbsoluteUrl(tierList.thumbnailSrc) : DEFAULT_IMAGE_URL
|
||||
|
||||
res.type('html').send(buildShareHtml({ title, description, imageUrl, shareUrl, appUrl }))
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -1,5 +1,31 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-04-03 v1.4.71
|
||||
- 모바일에서 공통 본문 하단이 딱 붙어 보이는 문제는 로그인 화면 하나만 고치는 것보다 `workspaceBody` 공통 하단 여백을 safe-area까지 포함해 보강하는 편이 이후 모든 본문 화면에 일괄 적용되어 유지보수상 낫다고 판단했다.
|
||||
- 모바일 왼쪽 네비게이션은 데스크톱의 폭 축소형 접기와 목적이 다르므로, 기존 `leftRailCollapsed`를 억지로 재사용하기보다 `mobileLeftNavOpen` 상태를 분리하고 유저 카드 우측 버튼으로 검색/메뉴 묶음만 접는 방식이 더 자연스럽다고 정리했다.
|
||||
- 오른쪽 레일은 모바일에서 기본 자동 열림이 실제 조작 공간을 빼앗는 경우가 많으므로, 모바일 진입과 라우트 이동 시 기본 닫힘으로 두되 PC 레이아웃으로 돌아오면 다시 기본 열림을 복원하는 쪽으로 맞췄다.
|
||||
- 모바일 터치에서는 짧은 탭 선택과 드래그 시작이 같은 포인터 입력에서 충돌하기 쉬우므로, Sortable에 터치 전용 지연과 threshold를 둬 탭은 선택, 길게 누르고 움직이면 드래그가 되도록 의도를 분리했다.
|
||||
|
||||
## 2026-04-03 v1.4.70
|
||||
- 카카오톡/디스코드/X 공유 미리보기는 대개 프런트 SPA 자바스크립트를 실행하기 전에 HTML 메타를 먼저 읽으므로, 기존 `index.html` 고정 메타를 프런트 런타임에서 바꾸는 방식만으로는 티어표별 썸네일/제목/설명을 안정적으로 보여주기 어렵다고 판단했다.
|
||||
- 현재 운영 구조가 프런트 Nginx 정적 서빙 + 백엔드 API 분리 형태이므로, 모든 SPA 경로를 SSR로 바꾸기보다 공유 버튼만 `/share/editor/...` 서버 렌더링 경로를 사용하게 하고, 이 경로에서 OG 메타를 만든 뒤 기존 `preview=1` 화면으로 넘기는 방식이 가장 작은 변경이라고 정리했다.
|
||||
- 다만 비공개 티어표의 제목/설명/썸네일이 외부 크롤러에게 노출되면 안 되므로, 공유 메타 생성은 공개 티어표이면서 URL의 주제 식별자와 실제 티어표 소속이 일치하는 경우에만 개별 메타를 사용하고, 그 외에는 서비스 기본 메타로 떨어지게 제한했다.
|
||||
|
||||
## 2026-04-03 v1.4.69
|
||||
- 아이템 검색 실패가 라벨 누락이나 이벤트 문제처럼 보일 수도 있지만, 코드상 필터링 조건 자체는 단순했으므로 한글 입력/저장 문자열의 유니코드 정규형 차이까지 먼저 흡수하는 편이 더 안전하다고 판단했다.
|
||||
- 검색 시점에만 임시 보정하는 것보다, 검색어와 저장 라벨 비교를 같은 정규화 함수로 통일하고 커스텀 파일명 기반 기본 라벨 생성도 `NFC`로 맞춰 이후 신규 업로드 항목까지 같은 규칙을 타게 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.68
|
||||
- 우클릭 복제 UX는 카드 영역과 썸네일 이미지 중 어디를 눌러도 같은 동작이어야 하므로, 개별 카드의 버블링 이벤트만 믿기보다 전역 캡처 단계에서 아이템 우클릭을 먼저 가로채는 방식이 더 안전하다고 판단했다.
|
||||
- 편집기에서는 아이템 이미지를 브라우저 기본 이미지처럼 드래그하거나 저장 메뉴로 여는 것보다 보드 조작이 우선이므로, 썸네일 이미지의 기본 드래그도 명시적으로 꺼두는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.67
|
||||
- 이미지 최적화는 해시 기반 중복 재사용을 하기 때문에, 프로필 아바타로 올린 이미지가 우연히 템플릿/사용자 아이템과 같은 `src`를 공유할 수 있다. 이때 자산 카드 쪽을 무조건 숨기면 “실제로는 프로필 이미지로 쓰이는데 관리자 필터에서 안 보이는 상태”가 생기므로, 아바타/썸네일 참조가 있는 `src`는 자산 카드도 유지하는 편이 맞다고 판단했다.
|
||||
|
||||
## 2026-04-03 v1.4.66
|
||||
- 특정 템플릿은 같은 아이템을 조건별로 여러 칸에 동시에 배치해야 하므로, 기존 아이템을 직접 공유해서 재사용하기보다 우클릭으로 새 복제본을 만들어 미사용 풀에 넣는 방식이 더 자연스럽다고 판단했다.
|
||||
- 현재 티어표 저장 구조는 아이템 ID 기준으로 위치를 추적하므로, 복제본이 원본과 같은 ID를 쓰면 중복 배치가 불가능해진다. 따라서 복제 시 `dup-...` 형태의 새 ID를 발급해 원본과 복제본을 별도 인스턴스로 관리하기로 했다.
|
||||
|
||||
## 2026-04-03 v1.4.62
|
||||
- 운영 서버를 새 DB로 다시 시작하는 절차는 “일반 업데이트 재빌드”와 “볼륨까지 삭제하는 완전 초기화”가 같은 문서 안에 섞이면 실수로 데이터를 날릴 위험이 크므로, 배포 문서에서 두 흐름을 별도 섹션으로 나누는 편이 맞다고 판단했다.
|
||||
- DB만 비우고 업로드 볼륨을 남기는 방식도 가능하지만, 현재 서비스는 DB 레코드와 업로드 파일 참조가 강하게 연결되어 있으므로 이 방법 역시 “운영 데이터를 전부 버리는 전제”라는 경고를 같이 적어두는 쪽으로 정리했다.
|
||||
@@ -645,6 +671,18 @@
|
||||
- 작성자가 자기 티어표를 직접 삭제할 수 있어야 관리 흐름이 완결되므로, `내 티어표` 화면에 삭제 기능을 추가하기로 결정했다.
|
||||
- 사용자 커스텀 이미지는 서버 용량을 계속 차지하므로, 참조가 하나도 남지 않은 이미지(미사용 항목)를 식별하고 관리자 화면에서 개별/일괄 정리할 수 있도록 하기로 결정했다.
|
||||
|
||||
## 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`)을 초기 스키마에 반드시 포함하기로 결정했다.
|
||||
|
||||
## 2026-04-03 v1.4.63
|
||||
- 관리자/에디터 화면의 우측 패널은 Teleport로 공통 셸의 레일 DOM에 끼워 넣는 구조이므로, 라우트 변경 시 Teleport 대상 노드 자체를 조건부로 없애면 Vue 언마운트/패치 순서에 따라 DOM 기준점이 깨질 수 있다고 판단했다.
|
||||
- 따라서 `#local-right-rail-root`는 항상 렌더링해두고, 일반 화면에서는 숨김 클래스만 적용하는 방식으로 유지해 라우트 전환 안정성을 우선 확보하기로 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.13
|
||||
- 관리자 페이지는 기능 수가 늘어난 만큼 게임, 아이템, 회원 관리 탭으로 나누는 편이 더 안전하다고 판단했다.
|
||||
- 사용자 커스텀 아이템은 수량이 빠르게 커질 수 있으므로, 검색과 페이지네이션을 기본 제공하는 관리 화면으로 보는 방향을 채택했다.
|
||||
|
||||
@@ -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`
|
||||
@@ -57,7 +57,7 @@
|
||||
|
||||
## 공통 레이아웃
|
||||
- 앱 셸 파일: `frontend/src/App.vue`
|
||||
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, `preview=1` 공유 프리뷰에서도 같은 좌우 레일과 중앙 헤더 유지, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링
|
||||
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, `preview=1` 공유 프리뷰에서도 같은 좌우 레일과 중앙 헤더 유지, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링, 관리자/에디터 화면이 Teleport로 사용하는 `#local-right-rail-root` 대상 DOM을 상시 유지해 라우트 전환 중 우측 레일 언마운트 순서를 안정화
|
||||
- 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker`와 서비스 설명이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다. 좌우 레일의 주요 액션은 각각 하단 `56px` 푸터 안에서 항상 보이도록 유지하면서 아래쪽 패딩으로 여백을 확보한다.
|
||||
|
||||
## 백엔드 진입점
|
||||
|
||||
21
docs/spec.md
21
docs/spec.md
@@ -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` 기준 폭을 사용하며, 우측 패널은 상단 토글 버튼으로 접고 펼칠 수 있다.
|
||||
- 좌측 패널은 필요 시 축소형 레일로 접을 수 있으며, 접힌 상태에서는 아이콘 중심 내비게이션과 축약된 바로가기만 유지한다.
|
||||
@@ -139,6 +139,23 @@
|
||||
- `userId`: string
|
||||
- `topicId`: string
|
||||
- `createdAt`: number
|
||||
- `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
|
||||
- 인증
|
||||
@@ -237,6 +254,7 @@
|
||||
- 사용자 아이템은 사용 횟수(`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 전용 모달로 연다.
|
||||
- `전체 티어표 관리`에서는 공개 티어표를 `추천 지정 / 추천 해제`할 수 있고, 추천 지정된 티어표는 주제별 공개 목록 상단의 `추천 티어표` 섹션에 먼저 노출된다. 비공개 티어표는 추천 지정할 수 없고, 추천글을 비공개로 바꾸면 추천 상태도 함께 해제된다.
|
||||
- `전체 티어표 관리` 카드에는 받은 즐겨찾기 수를 함께 보여주며, 우측 운영 패널에서 즐겨찾기 많은 순 정렬과 최소 즐겨찾기 수 필터로 추천 후보를 좁힐 수 있다.
|
||||
@@ -278,6 +296,7 @@
|
||||
- 티어 행에 넣은 아이템은 작은 제거 버튼으로 다시 우측 아이템 풀로 되돌릴 수 있다.
|
||||
- 편집 가능한 티어표에서는 아이템을 드래그로 옮길 수 있고, 아이템을 클릭해 선택한 뒤 원하는 셀이나 아이템 풀 빈 영역을 클릭하는 방식으로도 위치를 바꿀 수 있다.
|
||||
- 클릭 배치 모드에서 선택된 아이템은 파란 포커스 테두리로 표시하며, 드래그를 시작하면 선택 상태를 해제하고 드래그 직후 짧은 클릭 입력은 무시해 드래그/클릭 조작이 섞이지 않게 한다.
|
||||
- 보드 칸이나 미사용 아이템 풀의 아이템을 우클릭하면 `아이템 복제` 메뉴가 열리고, 실행 시 같은 이미지/이름/출처를 가진 새 아이템 인스턴스를 미사용 풀 맨 앞에 추가한다. 복제본은 `dup-...` 형태의 새 ID를 쓰므로 원본과 복제본을 서로 다른 칸에 동시에 배치할 수 있다.
|
||||
- 기존 저장 티어표/복사본을 수정 가능한 상태로 다시 열 때는 저장본의 그룹/풀을 먼저 복원하고, 이후 현재 템플릿에 새로 추가된 기본 아이템만 미사용 풀 끝에 병합한다.
|
||||
- 관리자 템플릿에서 삭제된 기본 아이템이라도 이미 저장된 티어표의 그룹/풀에 포함되어 있던 항목은 자동 제거하지 않고 그대로 보존한다.
|
||||
- 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다.
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
## 단기 확인
|
||||
- `v1.4.68`에서 아이템 우클릭 처리를 `window` 캡처 단계로 보강했으므로, 보드에 배치된 아이템/미사용 풀 아이템/아이템 썸네일 이미지 위에서 각각 우클릭했을 때 브라우저 기본 메뉴 대신 `아이템 복제` 메뉴가 바로 뜨는지 QA한다.
|
||||
- `v1.4.67`에서 같은 `src`가 프로필 아바타와 템플릿/사용자 아이템으로 동시에 쓰여도 자산 카드를 유지하도록 바꿨으므로, 운영 관리자 화면의 `전체 이미지`와 `프로필 이미지` 필터에서 실제 아바타가 보이고 상세 모달의 공유 참조 목록도 자연스럽게 읽히는지 QA한다.
|
||||
- 아이템 우클릭 복제 기능을 추가했으므로, 템플릿 아이템 복제/커스텀 아이템 복제/이미 보드에 배치된 아이템 복제 각각에서 복제본이 미사용 풀 맨 앞에 생기고 원본과 복제본을 서로 다른 칸에 동시에 둘 수 있는지 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한다.
|
||||
@@ -116,6 +120,9 @@
|
||||
- 검색 결과 화면도 `pageHead` 구조로 맞췄으므로, 주요 목록 화면들 간 상단 여백과 타이포 리듬이 자연스러운지 한 번 더 비교 QA한다.
|
||||
- 주제 상세 컬렉션 화면은 `pageHead` 공통 레이아웃과 `/topics` 기본 경로로 옮겼으므로, 직접 진입·뒤로가기·검색 후 재진입 시 주소와 헤더 흐름이 자연스러운지 한 번 더 QA한다.
|
||||
- `/topics/:gameId`를 기본 경로로 세우고 `/games/:gameId`는 alias로 남겼으므로, 다음 단계에서는 에디터/검색/공유 흐름에서 어떤 링크를 새 경로로 더 전환할지 범위를 정한다.
|
||||
- 신규 DB 기준 `template_requests` 스키마 누락은 보정했으므로, 운영 NAS에서 `down -v` 후 재배포했을 때 관리자 `/admin/featured`, `/admin/tierlists`, `/admin/users` 진입과 템플릿 요청/이미지 통계 API가 모두 500 없이 동작하는지 한 번 더 QA한다.
|
||||
- 브라우저 탭 파비콘은 다시 인라인 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/리다이렉트부터 둘지 점진 전환 순서를 정한다.
|
||||
- 관리자 화면까지 포함한 사용자 노출 `게임` 문구를 3차까지 정리했으므로, 실제 운영 흐름에서 뜨는 확인창/토스트/선택 모달까지 표현이 자연스러운지 한 번 더 QA한다.
|
||||
|
||||
@@ -1,5 +1,39 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-04-03 v1.4.71
|
||||
- 모바일에서 본문 페이지나 로그인 화면 하단이 카드/버튼 바로 아래에서 끊겨 보여 답답했던 부분을 줄이기 위해, 공통 워크스페이스 본문 하단에 모바일 safe-area 기반 여백을 추가했다.
|
||||
- 모바일 왼쪽 네비게이션은 유저 프로필 카드 오른쪽 토글 버튼으로 접고 펼칠 수 있게 바꾸고, 닫힘/열림 전환 시 검색창과 메뉴가 위아래로 부드럽게 스르륵 접히는 애니메이션을 추가했다.
|
||||
- 모바일 진입 시 오른쪽 레일은 기본 닫힘으로 시작하고, 모바일에서 직접 오른쪽 레일을 열었을 때도 레일 하단 컨텐츠가 화면 바닥에 붙지 않도록 safe-area 여백을 더했다.
|
||||
- 티어표 편집기 모바일 터치 조작에서 아이템을 짧게 탭하면 선택만 하고, 길게 누른 뒤 움직일 때 드래그가 시작되도록 Sortable 터치 시작 지연과 이동 임계값을 추가했다.
|
||||
- 서버 점검 안내 문구는 `서비스 내부 점검이 필요합니다.` 대신 `서비스 내부 점검중입니다.`로 다듬었고, 프런트 프로덕션 빌드(`npm run build`) 통과를 확인했다.
|
||||
|
||||
## 2026-04-03 v1.4.70
|
||||
- 저장된 티어표의 `공유하기` 버튼이 기존 `preview=1` 편집기 주소 대신 `/share/editor/:topicId/:tierListId` 공유 전용 주소를 복사하도록 바꿨다.
|
||||
- 이 공유 전용 주소는 공개 티어표인 경우 해당 티어표의 제목, 설명, 썸네일을 기반으로 Open Graph/Twitter 메타 태그를 서버에서 동적으로 생성한 뒤, 실제 뷰어 화면 `/editor/:topicId/:tierListId?preview=1`로 즉시 이동시킨다.
|
||||
- 비공개 티어표이거나 주제 경로와 티어표 소속이 맞지 않는 경우에는 개별 제목/설명/썸네일을 노출하지 않고 서비스 기본 공유 메타를 사용하도록 제한했다.
|
||||
- 운영 프런트 Nginx에서 `/share/` 경로를 백엔드로 프록시하도록 추가해, 카카오톡/디스코드/X 같은 크롤러가 JS 실행 전에 공유 메타를 먼저 읽을 수 있게 했다.
|
||||
- `backend/index.js`, `backend/src/routes/share.js` 문법 검사와 프런트 프로덕션 빌드(`npm run build`)까지 통과하는 것을 확인했다.
|
||||
|
||||
## 2026-04-03 v1.4.69
|
||||
- 티어표 편집 화면의 아이템 검색에서 한글 아이템명이 검색어와 눈으로는 같아 보여도 내부 유니코드 정규형 차이 때문에 일부 항목이 매칭되지 않을 수 있던 문제를 보강했다.
|
||||
- 검색어와 아이템 라벨을 비교하기 전에 `NFC`로 정규화하도록 바꾸고, 커스텀 이미지 파일명에서 기본 라벨을 만들 때도 같은 정규화를 거쳐 한글 조합형 차이로 검색이 빗나가는 상황을 줄였다.
|
||||
- 프런트 프로덕션 빌드(`npm run build`)까지 통과하는 것을 확인했다.
|
||||
|
||||
## 2026-04-03 v1.4.68
|
||||
- 티어표 편집 화면에서 아이템을 우클릭해도 브라우저 기본 컨텍스트 메뉴가 먼저 떠서 `아이템 복제` 메뉴를 누르기 어려울 수 있던 부분을 보강했다.
|
||||
- 기존에는 각 아이템 카드의 `@contextmenu.prevent`에 주로 의존했지만, 이제는 `window` 캡처 단계에서 `[data-item-id]` 대상 우클릭을 먼저 잡아 기본 메뉴를 막고 커스텀 복제 메뉴를 열도록 바꿨다.
|
||||
- 아이템 썸네일 이미지에도 `draggable="false"`를 명시해, 이미지 자체의 기본 드래그/컨텍스트 동작이 편집 조작보다 앞서는 상황을 줄였다.
|
||||
|
||||
## 2026-04-03 v1.4.67
|
||||
- 관리자 아이템 관리에서 프로필 아바타가 `전체 이미지`와 `프로필 이미지` 필터에 보이지 않을 수 있던 문제를 수정했다.
|
||||
- 원인은 `image_assets`의 같은 `src`가 템플릿 아이템이나 사용자 아이템에서도 쓰이는 경우, 자산 카드 생성 단계에서 해당 `src`를 무조건 제외하던 필터였다. 이제는 `users.avatar_src`나 각종 썸네일 참조로 실제 사용 중인 자산이면 같은 이미지가 다른 아이템에 재사용되더라도 자산 카드도 함께 유지한다.
|
||||
- 로컬 MariaDB를 새로 만든 뒤 `프로필 아바타`와 사용자 아이템이 같은 `src`를 공유하는 테스트 데이터를 직접 넣고, `listCustomItems({ filterMode: 'avatar' })`와 `filterMode: 'all'` 결과에 프로필 자산 카드가 포함되는 것까지 확인했다.
|
||||
|
||||
## 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 +1233,19 @@
|
||||
- **아이템 카드 레이아웃 개선**: 아이템 목록과 추가 미리보기를 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` 허용으로 정리
|
||||
- **빈 DB 재현 검증**: 로컬 MariaDB를 `DROP DATABASE → CREATE DATABASE → ensureData()`로 다시 초기화한 뒤 `listAdminTemplateRequests()`가 `[]`, `getImageAssetStats()`가 0값 통계를 반환하는 것까지 직접 확인
|
||||
|
||||
## 2026-04-03 v1.4.63
|
||||
- **우측 레일 Teleport 전환 안정화**: 관리자/에디터 전용 우측 패널이 사용하는 `#local-right-rail-root` DOM을 라우트에 따라 생성/삭제하지 않고 항상 유지하도록 바꿔, `/admin/...`에서 설정/다른 페이지로 이동하거나 새로고침 후 화면을 바꿀 때 Vue가 `nextSibling`/`emitsOptions` 기준점을 잃고 크래시하는 문제를 방지
|
||||
- **정적 favicon 403 분리 확인**: 프런트 빌드 기준 `favicon.svg`, `favicon-32x32.png`, `apple-touch-icon.png` 파일은 레포와 Vite `public/` 출력에 존재함을 확인했고, 운영 환경의 favicon `403 Forbidden`은 코드 누락보다 컨테이너/정적 서빙/프록시 권한 쪽 후속 점검 항목으로 분리
|
||||
|
||||
## 2026-03-19 v0.1.7
|
||||
- **AI 작업 규칙 보강**: `ai-rules.md`에 Git 작성자 정보, 한국어 커밋 메시지, 버전/태그 동기화, 민감 정보 확인 규칙 추가
|
||||
- **관리자 화면 재구성**: `/admin`을 좌우 병렬 구조에서 `모드 선택 → 게임 선택/생성 → 선택된 게임 상세 관리` 흐름으로 재구성
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -19,6 +19,15 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
|
||||
location /share/ {
|
||||
proxy_pass http://backend:5179/share/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
|
||||
location /uploads/ {
|
||||
proxy_pass http://backend:5179/uploads/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -27,6 +27,7 @@ const RIGHT_RAIL_COPYRIGHT_URL = 'https://x.com/zennbox'
|
||||
const currentTopicId = computed(() => route.params.topicId || '')
|
||||
|
||||
const leftRailCollapsed = ref(false)
|
||||
const mobileLeftNavOpen = ref(false)
|
||||
const rightRailOpen = ref(true)
|
||||
const searchQuery = ref('')
|
||||
const leftRailSearchPlaceholder = '주제 템플릿 검색'
|
||||
@@ -312,6 +313,12 @@ onMounted(async () => {
|
||||
const saved = window.localStorage.getItem('tier-maker:right-rail-open')
|
||||
if (saved === '0') rightRailOpen.value = false
|
||||
}
|
||||
if (isMobileLayout.value) {
|
||||
mobileLeftNavOpen.value = false
|
||||
rightRailOpen.value = false
|
||||
} else {
|
||||
rightRailOpen.value = true
|
||||
}
|
||||
searchQuery.value = typeof route.query.q === 'string' ? route.query.q : ''
|
||||
})
|
||||
|
||||
@@ -339,13 +346,27 @@ watch(
|
||||
searchQuery.value = typeof route.query.q === 'string' ? route.query.q : ''
|
||||
isCollapsedSearchOpen.value = false
|
||||
isGuideModalOpen.value = false
|
||||
if (isMobileLayout.value) {
|
||||
mobileLeftNavOpen.value = false
|
||||
rightRailOpen.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
isMobileLayout,
|
||||
(mobile) => {
|
||||
if (mobile) leftRailCollapsed.value = false
|
||||
if (mobile) {
|
||||
leftRailCollapsed.value = false
|
||||
mobileLeftNavOpen.value = false
|
||||
rightRailOpen.value = false
|
||||
return
|
||||
}
|
||||
mobileLeftNavOpen.value = false
|
||||
rightRailOpen.value = true
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem('tier-maker:right-rail-open', '1')
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
@@ -353,7 +374,7 @@ watch(
|
||||
watch(
|
||||
usesLocalRightRail,
|
||||
(needed) => {
|
||||
if (!needed || rightRailOpen.value) return
|
||||
if (!needed || rightRailOpen.value || isMobileLayout.value) return
|
||||
rightRailOpen.value = true
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem('tier-maker:right-rail-open', '1')
|
||||
@@ -368,7 +389,10 @@ function isRouteActive(path) {
|
||||
}
|
||||
|
||||
function toggleLeftRail() {
|
||||
if (isMobileLayout.value) return
|
||||
if (isMobileLayout.value) {
|
||||
mobileLeftNavOpen.value = !mobileLeftNavOpen.value
|
||||
return
|
||||
}
|
||||
leftRailCollapsed.value = !leftRailCollapsed.value
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem('tier-maker:left-rail-collapsed', leftRailCollapsed.value ? '1' : '0')
|
||||
@@ -449,6 +473,7 @@ function reloadApp() {
|
||||
class="appShell"
|
||||
:class="{
|
||||
'appShell--leftCollapsed': leftRailCollapsed,
|
||||
'appShell--mobileNavClosed': isMobileLayout && !mobileLeftNavOpen,
|
||||
'appShell--rightClosed': !rightRailOpen,
|
||||
'appShell--rightOverlay': isRightRailOverlay,
|
||||
}"
|
||||
@@ -483,7 +508,7 @@ function reloadApp() {
|
||||
|
||||
<div class="leftRail__body">
|
||||
<div class="leftRail__content">
|
||||
<div v-if="authReady && auth.user" class="appUserCard">
|
||||
<div v-if="authReady" class="appUserCard">
|
||||
<div class="appUserCard__button">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="appUserCard__avatar" alt="avatar" draggable="false" />
|
||||
<div v-else class="appUserCard__avatar appUserCard__avatar--fallback">{{ accountName[0]?.toUpperCase() || 'U' }}</div>
|
||||
@@ -491,40 +516,52 @@ function reloadApp() {
|
||||
<div class="appUserCard__name">{{ accountName }}</div>
|
||||
<div class="appUserCard__email">{{ accountEmail }}</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="isMobileLayout"
|
||||
class="appUserCard__navToggle"
|
||||
type="button"
|
||||
:aria-label="mobileLeftNavOpen ? '네비게이션 메뉴 닫기' : '네비게이션 메뉴 열기'"
|
||||
:aria-expanded="mobileLeftNavOpen"
|
||||
@click="toggleLeftRail"
|
||||
>
|
||||
<SvgIcon :src="mobileLeftNavOpen ? iconDockToLeft : iconDockToRight" :size="24" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="searchStub" @submit.prevent="submitGlobalSearch">
|
||||
<button class="searchStub__iconButton" type="button" :aria-label="leftRailCollapsed ? '검색 열기' : '검색'" @click="handleLeftRailSearch">
|
||||
<span class="searchStub__icon">
|
||||
<SvgIcon :src="iconSearch" :size="24" />
|
||||
</span>
|
||||
</button>
|
||||
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : leftRailSearchPlaceholder" />
|
||||
</form>
|
||||
<div class="leftRail__mobileMenu">
|
||||
<form class="searchStub" @submit.prevent="submitGlobalSearch">
|
||||
<button class="searchStub__iconButton" type="button" :aria-label="leftRailCollapsed ? '검색 열기' : '검색'" @click="handleLeftRailSearch">
|
||||
<span class="searchStub__icon">
|
||||
<SvgIcon :src="iconSearch" :size="24" />
|
||||
</span>
|
||||
</button>
|
||||
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : leftRailSearchPlaceholder" />
|
||||
</form>
|
||||
|
||||
<nav
|
||||
class="leftNav"
|
||||
:class="{ 'leftNav--hasActive': activeLeftNavIndex >= 0 }"
|
||||
:style="{ '--left-nav-active-index': String(Math.max(activeLeftNavIndex, 0)) }"
|
||||
>
|
||||
<span class="leftNav__indicator" aria-hidden="true"></span>
|
||||
<RouterLink
|
||||
v-for="item in leftNavItems"
|
||||
:key="item.key"
|
||||
:to="item.path"
|
||||
class="leftNav__item"
|
||||
:class="{ 'leftNav__item--active': isRouteActive(item.path) }"
|
||||
:title="leftRailCollapsed ? item.label : ''"
|
||||
:aria-label="leftRailCollapsed ? item.label : undefined"
|
||||
<nav
|
||||
class="leftNav"
|
||||
:class="{ 'leftNav--hasActive': activeLeftNavIndex >= 0 }"
|
||||
:style="{ '--left-nav-active-index': String(Math.max(activeLeftNavIndex, 0)) }"
|
||||
>
|
||||
<span class="leftNav__glyph">
|
||||
<SvgIcon v-if="item.iconSrc" :src="item.iconSrc" :size="24" />
|
||||
<svg v-else viewBox="0 0 24 24" aria-hidden="true"><path :d="item.icon" /></svg>
|
||||
</span>
|
||||
<span class="leftNav__label">{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
<span class="leftNav__indicator" aria-hidden="true"></span>
|
||||
<RouterLink
|
||||
v-for="item in leftNavItems"
|
||||
:key="item.key"
|
||||
:to="item.path"
|
||||
class="leftNav__item"
|
||||
:class="{ 'leftNav__item--active': isRouteActive(item.path) }"
|
||||
:title="leftRailCollapsed ? item.label : ''"
|
||||
:aria-label="leftRailCollapsed ? item.label : undefined"
|
||||
>
|
||||
<span class="leftNav__glyph">
|
||||
<SvgIcon v-if="item.iconSrc" :src="item.iconSrc" :size="24" />
|
||||
<svg v-else viewBox="0 0 24 24" aria-hidden="true"><path :d="item.icon" /></svg>
|
||||
</span>
|
||||
<span class="leftNav__label">{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="leftRail__bottom">
|
||||
@@ -659,8 +696,12 @@ function reloadApp() {
|
||||
</div>
|
||||
<div class="rightRail__body">
|
||||
<div class="rightRail__content">
|
||||
<div v-if="usesLocalRightRail" id="local-right-rail-root" class="localRightRailRoot"></div>
|
||||
<template v-else>
|
||||
<div
|
||||
id="local-right-rail-root"
|
||||
class="localRightRailRoot"
|
||||
:class="{ 'localRightRailRoot--hidden': !usesLocalRightRail }"
|
||||
></div>
|
||||
<template v-if="!usesLocalRightRail">
|
||||
<section v-if="showSettingsThemePanel" class="settingsThemePanel">
|
||||
<div class="settingsThemePanel__eyebrow">Appearance</div>
|
||||
<div class="settingsThemePanel__title">테마 설정</div>
|
||||
@@ -964,6 +1005,25 @@ function reloadApp() {
|
||||
transition: opacity 180ms ease, max-width 220ms ease, transform 220ms ease;
|
||||
}
|
||||
|
||||
.leftRail__mobileMenu {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.appUserCard__navToggle {
|
||||
display: none;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
margin-left: auto;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
background: var(--theme-surface-soft);
|
||||
color: var(--theme-text-soft);
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.appUserCard__name {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
@@ -1780,6 +1840,10 @@ function reloadApp() {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.localRightRailRoot--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toastStack {
|
||||
position: fixed;
|
||||
top: 18px;
|
||||
@@ -1986,6 +2050,22 @@ function reloadApp() {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.appUserCard {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.appUserCard__button {
|
||||
padding: 8px 6px;
|
||||
}
|
||||
|
||||
.appUserCard__meta {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.appUserCard__navToggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.appMain {
|
||||
min-height: auto;
|
||||
border-left: 0;
|
||||
@@ -2003,6 +2083,18 @@ function reloadApp() {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.leftRail__mobileMenu {
|
||||
max-height: 540px;
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
overflow: hidden;
|
||||
transition:
|
||||
max-height 260ms ease,
|
||||
opacity 220ms ease,
|
||||
transform 220ms ease,
|
||||
margin-top 220ms ease;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .leftRail__top {
|
||||
display: none;
|
||||
}
|
||||
@@ -2038,17 +2130,33 @@ function reloadApp() {
|
||||
}
|
||||
|
||||
.workspaceBody {
|
||||
padding: 0;
|
||||
padding: 0 0 calc(28px + env(safe-area-inset-bottom));
|
||||
border-radius: 0;
|
||||
margin: 14px 14px 0;
|
||||
}
|
||||
|
||||
.workspaceBody--localRail {
|
||||
padding: 0;
|
||||
padding: 0 0 calc(28px + env(safe-area-inset-bottom));
|
||||
border-radius: 0;
|
||||
margin: 14px 14px 0;
|
||||
}
|
||||
|
||||
.appShell--mobileNavClosed .leftRail__mobileMenu {
|
||||
max-height: 0;
|
||||
margin-top: -8px;
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.appShell--mobileNavClosed .leftRail__bottom {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rightRail--overlay .rightRail__body {
|
||||
padding-bottom: calc(14px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.collapsedSearchModal {
|
||||
padding: 72px 16px 16px;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ async function request(path, { method = 'GET', body, headers } = {}) {
|
||||
} else if (res.status >= 500) {
|
||||
emitBackendStatus({
|
||||
state: 'maintenance',
|
||||
message: '서비스 내부 점검이 필요합니다. 잠시 후 다시 이용해주세요.',
|
||||
message: '서비스 내부 점검중입니다. 잠시 후 다시 이용해주세요.',
|
||||
path,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ export function editorPath(topicId, tierListId, { preview = false } = {}) {
|
||||
return preview ? `${base}?preview=1` : base
|
||||
}
|
||||
|
||||
export function shareEditorPath(topicId, tierListId) {
|
||||
return `/share/editor/${encodeSegment(topicId)}/${encodeSegment(tierListId)}`
|
||||
}
|
||||
|
||||
export function mePath() {
|
||||
return '/me'
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -10,7 +10,7 @@ import addPhotoAlternateIcon from '../assets/icons/add_photo_alternate.svg'
|
||||
import shareIcon from '../assets/icons/share.svg'
|
||||
import RightRailAd from '../components/RightRailAd.vue'
|
||||
import { api } from '../lib/api'
|
||||
import { editorNewPath, editorPath, loginPath, mePath, topicPath, userProfilePath } from '../lib/paths'
|
||||
import { editorNewPath, editorPath, loginPath, mePath, shareEditorPath, topicPath, userProfilePath } from '../lib/paths'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useToast } from '../composables/useToast'
|
||||
@@ -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)
|
||||
@@ -96,6 +102,12 @@ const isNewTierList = computed(() => tierListId.value === 'new')
|
||||
const isOwnTierList = computed(() => !!auth.user && !!ownerId.value && ownerId.value === auth.user.id)
|
||||
const canEdit = computed(() => !!auth.user && !previewMode.value && (!ownerId.value || ownerId.value === auth.user.id))
|
||||
const iconSizeOptions = [48, 64, 80, 96, 112]
|
||||
const touchSortableOptions = {
|
||||
delayOnTouchOnly: true,
|
||||
delay: 180,
|
||||
touchStartThreshold: 8,
|
||||
fallbackTolerance: 8,
|
||||
}
|
||||
const hasCustomTitle = computed(() => !!(title.value || '').trim())
|
||||
const fallbackTimestamp = computed(() => (updatedAt.value ? updatedAt.value : Date.now()))
|
||||
const effectiveAuthorName = computed(() => {
|
||||
@@ -135,7 +147,7 @@ const copiedFromLabel = computed(() => {
|
||||
return parts.join(' · ') || '복사해 온 티어표'
|
||||
})
|
||||
const customItems = computed(() => getOrderedItems().filter((item) => item?.origin === 'custom'))
|
||||
const normalizedPoolSearchQuery = computed(() => poolSearchQuery.value.trim().toLowerCase())
|
||||
const normalizedPoolSearchQuery = computed(() => normalizeSearchText(poolSearchQuery.value))
|
||||
const hasSavedTierList = computed(() => !!(persistedTierListId.value || (tierListId.value && tierListId.value !== 'new')))
|
||||
const canRequestTemplateCreate = computed(
|
||||
() => canEdit.value && hasSavedTierList.value && templateId.value === 'freeform' && customItems.value.length > 0
|
||||
@@ -149,8 +161,9 @@ const templateRequestTargetLabel = computed(() => (templateId.value === 'freefor
|
||||
const shareTierListUrl = computed(() => {
|
||||
const savedTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
|
||||
if (!savedTierListId) return ''
|
||||
if (typeof window === 'undefined') return editorPath(templateId.value, savedTierListId, { preview: true })
|
||||
return new URL(editorPath(templateId.value, savedTierListId, { preview: true }), window.location.origin).toString()
|
||||
const sharePath = shareEditorPath(templateId.value, savedTierListId)
|
||||
if (typeof window === 'undefined') return sharePath
|
||||
return new URL(sharePath, window.location.origin).toString()
|
||||
})
|
||||
watch(error, (message) => {
|
||||
if (!message) return
|
||||
@@ -158,6 +171,13 @@ watch(error, (message) => {
|
||||
error.value = ''
|
||||
})
|
||||
|
||||
function normalizeSearchText(text) {
|
||||
return String(text || '')
|
||||
.normalize('NFC')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function createAutoTierListTitle() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
const pick = (size) => Array.from({ length: size }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
|
||||
@@ -223,7 +243,7 @@ function isPoolItemVisible(itemId) {
|
||||
const query = normalizedPoolSearchQuery.value
|
||||
if (!query) return true
|
||||
const item = itemsById.value[itemId]
|
||||
const label = String(item?.label || itemId || '').toLowerCase()
|
||||
const label = normalizeSearchText(item?.label || itemId || '')
|
||||
return label.includes(query)
|
||||
}
|
||||
|
||||
@@ -329,6 +349,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 +450,56 @@ 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) {
|
||||
const target = event?.target
|
||||
if (target?.closest?.('[data-item-context-menu]')) {
|
||||
event?.preventDefault?.()
|
||||
event?.stopPropagation?.()
|
||||
return
|
||||
}
|
||||
|
||||
const itemEl = target?.closest?.('[data-item-id]')
|
||||
if (canEdit.value && itemEl?.dataset?.itemId) {
|
||||
event?.preventDefault?.()
|
||||
event?.stopPropagation?.()
|
||||
openItemContextMenu(itemEl.dataset.itemId, event)
|
||||
return
|
||||
}
|
||||
|
||||
if (!itemContextMenu.value.open) 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) {
|
||||
@@ -458,6 +553,7 @@ async function initSortables() {
|
||||
destroySortables()
|
||||
|
||||
groupSortable.value = Sortable.create(groupListEl.value, {
|
||||
...touchSortableOptions,
|
||||
animation: 160,
|
||||
handle: '[data-group-handle]',
|
||||
ghostClass: 'ghost',
|
||||
@@ -471,6 +567,7 @@ async function initSortables() {
|
||||
})
|
||||
|
||||
poolSortable.value = Sortable.create(poolEl.value, {
|
||||
...touchSortableOptions,
|
||||
group: 'tier-items',
|
||||
animation: 160,
|
||||
draggable: '[data-item-id]',
|
||||
@@ -488,6 +585,7 @@ async function initSortables() {
|
||||
|
||||
dropSortables.value = Object.entries(groupDropEls.value).map(([, el]) =>
|
||||
Sortable.create(el, {
|
||||
...touchSortableOptions,
|
||||
group: 'tier-items',
|
||||
animation: 160,
|
||||
draggable: '[data-item-id]',
|
||||
@@ -537,6 +635,7 @@ function createColumnName(index = columns.value.length) {
|
||||
|
||||
function createCustomItemLabel(fileName = '') {
|
||||
const normalized = String(fileName || '')
|
||||
.normalize('NFC')
|
||||
.replace(/\.[^.]+$/, '')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
@@ -544,6 +643,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 +1238,7 @@ function resetEditorStateForRoute() {
|
||||
selectedItemId.value = ''
|
||||
recentDragFinishedAt.value = 0
|
||||
savedEditorSnapshot.value = ''
|
||||
closeItemContextMenu()
|
||||
resetTemplateRequestDrafts()
|
||||
}
|
||||
|
||||
@@ -1240,7 +1344,21 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
window.addEventListener('pointerdown', handleGlobalPointerDown)
|
||||
window.addEventListener('contextmenu', handleGlobalContextMenu, true)
|
||||
window.addEventListener('blur', closeItemContextMenu)
|
||||
window.addEventListener('scroll', closeItemContextMenu, true)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('pointerdown', handleGlobalPointerDown)
|
||||
window.removeEventListener('contextmenu', handleGlobalContextMenu, true)
|
||||
window.removeEventListener('blur', closeItemContextMenu)
|
||||
window.removeEventListener('scroll', closeItemContextMenu, true)
|
||||
}
|
||||
if (thumbnailPreviewUrl.value) URL.revokeObjectURL(thumbnailPreviewUrl.value)
|
||||
destroySortables()
|
||||
})
|
||||
@@ -1557,7 +1675,12 @@ onUnmounted(() => {
|
||||
:data-item-id="id"
|
||||
@click.stop="selectItemByClick(id)"
|
||||
>
|
||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||
<img
|
||||
:src="resolveItemSrc(itemsById[id])"
|
||||
class="thumb"
|
||||
:alt="itemsById[id]?.label || id"
|
||||
draggable="false"
|
||||
/>
|
||||
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
|
||||
<button
|
||||
v-if="canEdit && !isExporting"
|
||||
@@ -1638,7 +1761,12 @@ onUnmounted(() => {
|
||||
:data-item-id="id"
|
||||
@click.stop="selectItemByClick(id)"
|
||||
>
|
||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||
<img
|
||||
:src="resolveItemSrc(itemsById[id])"
|
||||
class="thumb"
|
||||
:alt="itemsById[id]?.label || id"
|
||||
draggable="false"
|
||||
/>
|
||||
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
|
||||
<div v-if="!canEdit" class="poolItem__state">미배치</div>
|
||||
</div>
|
||||
@@ -1646,6 +1774,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 +3081,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: color-mix(in srgb, var(--theme-main-bg) 96%, transparent);
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user