Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28c6dafa02 | |||
| f98524390b | |||
| da37fe9fc9 | |||
| d09cd7e508 | |||
| 907ea75182 |
@@ -14,6 +14,7 @@ const topicsRoutes = require('./src/routes/topics')
|
|||||||
const tierListsRoutes = require('./src/routes/tierlists')
|
const tierListsRoutes = require('./src/routes/tierlists')
|
||||||
const usersRoutes = require('./src/routes/users')
|
const usersRoutes = require('./src/routes/users')
|
||||||
const adminRoutes = require('./src/routes/admin')
|
const adminRoutes = require('./src/routes/admin')
|
||||||
|
const shareRoutes = require('./src/routes/share')
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
const PORT = process.env.PORT ? Number(process.env.PORT) : 5179
|
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/tierlists', tierListsRoutes)
|
||||||
app.use('/api/users', usersRoutes)
|
app.use('/api/users', usersRoutes)
|
||||||
app.use('/api/admin', adminRoutes)
|
app.use('/api/admin', adminRoutes)
|
||||||
|
app.use('/share', shareRoutes)
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`[backend] listening on http://localhost:${PORT}`)
|
console.log(`[backend] listening on http://localhost:${PORT}`)
|
||||||
|
|||||||
@@ -1795,7 +1795,11 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
|||||||
const templateSrcSet = new Set(topicItemRows.map((row) => row.src).filter(Boolean))
|
const templateSrcSet = new Set(topicItemRows.map((row) => row.src).filter(Boolean))
|
||||||
const customSrcSet = new Set(customRows.map((row) => row.src).filter(Boolean))
|
const customSrcSet = new Set(customRows.map((row) => row.src).filter(Boolean))
|
||||||
const assetLibraryItems = assetRows
|
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) => ({
|
.map((row) => ({
|
||||||
id: `asset:${row.id}`,
|
id: `asset:${row.id}`,
|
||||||
assetId: 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,21 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 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
|
## 2026-04-03 v1.4.66
|
||||||
- 특정 템플릿은 같은 아이템을 조건별로 여러 칸에 동시에 배치해야 하므로, 기존 아이템을 직접 공유해서 재사용하기보다 우클릭으로 새 복제본을 만들어 미사용 풀에 넣는 방식이 더 자연스럽다고 판단했다.
|
- 특정 템플릿은 같은 아이템을 조건별로 여러 칸에 동시에 배치해야 하므로, 기존 아이템을 직접 공유해서 재사용하기보다 우클릭으로 새 복제본을 만들어 미사용 풀에 넣는 방식이 더 자연스럽다고 판단했다.
|
||||||
- 현재 티어표 저장 구조는 아이템 ID 기준으로 위치를 추적하므로, 복제본이 원본과 같은 ID를 쓰면 중복 배치가 불가능해진다. 따라서 복제 시 `dup-...` 형태의 새 ID를 발급해 원본과 복제본을 별도 인스턴스로 관리하기로 했다.
|
- 현재 티어표 저장 구조는 아이템 ID 기준으로 위치를 추적하므로, 복제본이 원본과 같은 ID를 쓰면 중복 배치가 불가능해진다. 따라서 복제 시 `dup-...` 형태의 새 ID를 발급해 원본과 복제본을 별도 인스턴스로 관리하기로 했다.
|
||||||
|
|||||||
@@ -254,6 +254,7 @@
|
|||||||
- 사용자 아이템은 사용 횟수(`usageCount`)를 표시하며, `미사용 아이템` 필터에서 저장 티어표/템플릿 참조가 더 이상 없는 항목만 개별/일괄 삭제할 수 있다. 사용자가 계정 탈퇴 등으로 삭제된 경우는 `custom_items.owner_id` 외래키로 레코드가 같이 사라지므로 보통 `미사용 아이템`으로 남지 않는다.
|
- 사용자 아이템은 사용 횟수(`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`)를 역추적해 프로필/썸네일 여부를 분류한다. 실제 템플릿 기본 항목은 `템플릿 아이템`, 사용자 커스텀 항목은 `사용자 아이템` 배지를 사용한다.
|
- `/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 전용 모달로 연다.
|
- 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 내부에서 `템플릿 요청 관리 / 전체 티어표 관리`를 분리해 볼 수 있고, 확인용 완성본은 탐색 UI 없는 preview 전용 모달로 연다.
|
||||||
- `전체 티어표 관리`에서는 공개 티어표를 `추천 지정 / 추천 해제`할 수 있고, 추천 지정된 티어표는 주제별 공개 목록 상단의 `추천 티어표` 섹션에 먼저 노출된다. 비공개 티어표는 추천 지정할 수 없고, 추천글을 비공개로 바꾸면 추천 상태도 함께 해제된다.
|
- `전체 티어표 관리`에서는 공개 티어표를 `추천 지정 / 추천 해제`할 수 있고, 추천 지정된 티어표는 주제별 공개 목록 상단의 `추천 티어표` 섹션에 먼저 노출된다. 비공개 티어표는 추천 지정할 수 없고, 추천글을 비공개로 바꾸면 추천 상태도 함께 해제된다.
|
||||||
- `전체 티어표 관리` 카드에는 받은 즐겨찾기 수를 함께 보여주며, 우측 운영 패널에서 즐겨찾기 많은 순 정렬과 최소 즐겨찾기 수 필터로 추천 후보를 좁힐 수 있다.
|
- `전체 티어표 관리` 카드에는 받은 즐겨찾기 수를 함께 보여주며, 우측 운영 패널에서 즐겨찾기 많은 순 정렬과 최소 즐겨찾기 수 필터로 추천 후보를 좁힐 수 있다.
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# 할 일 및 이슈
|
# 할 일 및 이슈
|
||||||
|
|
||||||
## 단기 확인
|
## 단기 확인
|
||||||
|
- `v1.4.68`에서 아이템 우클릭 처리를 `window` 캡처 단계로 보강했으므로, 보드에 배치된 아이템/미사용 풀 아이템/아이템 썸네일 이미지 위에서 각각 우클릭했을 때 브라우저 기본 메뉴 대신 `아이템 복제` 메뉴가 바로 뜨는지 QA한다.
|
||||||
|
- `v1.4.67`에서 같은 `src`가 프로필 아바타와 템플릿/사용자 아이템으로 동시에 쓰여도 자산 카드를 유지하도록 바꿨으므로, 운영 관리자 화면의 `전체 이미지`와 `프로필 이미지` 필터에서 실제 아바타가 보이고 상세 모달의 공유 참조 목록도 자연스럽게 읽히는지 QA한다.
|
||||||
- 아이템 우클릭 복제 기능을 추가했으므로, 템플릿 아이템 복제/커스텀 아이템 복제/이미 보드에 배치된 아이템 복제 각각에서 복제본이 미사용 풀 맨 앞에 생기고 원본과 복제본을 서로 다른 칸에 동시에 둘 수 있는지 QA한다.
|
- 아이템 우클릭 복제 기능을 추가했으므로, 템플릿 아이템 복제/커스텀 아이템 복제/이미 보드에 배치된 아이템 복제 각각에서 복제본이 미사용 풀 맨 앞에 생기고 원본과 복제본을 서로 다른 칸에 동시에 둘 수 있는지 QA한다.
|
||||||
- 복제본은 `dup-...` 새 ID로 저장되므로, 저장 후 재진입/티어표 복사본 생성/뷰어 모드 열람에서도 복제본이 그대로 유지되는지와, 템플릿 업데이트 요청에 복제된 커스텀 아이템이 포함될 때 운영상 이상이 없는지 확인한다.
|
- 복제본은 `dup-...` 새 ID로 저장되므로, 저장 후 재진입/티어표 복사본 생성/뷰어 모드 열람에서도 복제본이 그대로 유지되는지와, 템플릿 업데이트 요청에 복제된 커스텀 아이템이 포함될 때 운영상 이상이 없는지 확인한다.
|
||||||
- `v1.4.62`에서 NAS 배포 문서에 운영 DB 완전 초기화 절차를 추가했으므로, 실제 NAS에서 `git pull → docker compose ... down -v → up -d --build` 순서로 재배포했을 때 빈 DB가 현재 스키마로 다시 올라오고 `freeform`만 생성되는지 확인한다.
|
- `v1.4.62`에서 NAS 배포 문서에 운영 DB 완전 초기화 절차를 추가했으므로, 실제 NAS에서 `git pull → docker compose ... down -v → up -d --build` 순서로 재배포했을 때 빈 DB가 현재 스키마로 다시 올라오고 `freeform`만 생성되는지 확인한다.
|
||||||
|
|||||||
@@ -1,5 +1,27 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 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
|
## 2026-04-03 v1.4.66
|
||||||
- 티어표 편집 화면에서 보드 위 아이템이나 미사용 아이템 풀의 아이템을 우클릭하면 `아이템 복제` 메뉴가 뜨고, 선택한 아이템의 이미지/이름/출처를 유지한 새 복제본을 미사용 풀 맨 앞에 추가하도록 구현했다.
|
- 티어표 편집 화면에서 보드 위 아이템이나 미사용 아이템 풀의 아이템을 우클릭하면 `아이템 복제` 메뉴가 뜨고, 선택한 아이템의 이미지/이름/출처를 유지한 새 복제본을 미사용 풀 맨 앞에 추가하도록 구현했다.
|
||||||
- 기존 아이템 ID를 그대로 다시 쓰면 같은 항목을 서로 다른 칸에 동시에 둘 수 없으므로, 복제 시 `dup-...` 새 ID를 발급해 원본과 복제본을 별도 아이템 인스턴스로 저장하도록 정리했다.
|
- 기존 아이템 ID를 그대로 다시 쓰면 같은 항목을 서로 다른 칸에 동시에 둘 수 없으므로, 복제 시 `dup-...` 새 ID를 발급해 원본과 복제본을 별도 아이템 인스턴스로 저장하도록 정리했다.
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto https;
|
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/ {
|
location /uploads/ {
|
||||||
proxy_pass http://backend:5179/uploads/;
|
proxy_pass http://backend:5179/uploads/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ export function editorPath(topicId, tierListId, { preview = false } = {}) {
|
|||||||
return preview ? `${base}?preview=1` : base
|
return preview ? `${base}?preview=1` : base
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shareEditorPath(topicId, tierListId) {
|
||||||
|
return `/share/editor/${encodeSegment(topicId)}/${encodeSegment(tierListId)}`
|
||||||
|
}
|
||||||
|
|
||||||
export function mePath() {
|
export function mePath() {
|
||||||
return '/me'
|
return '/me'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import addPhotoAlternateIcon from '../assets/icons/add_photo_alternate.svg'
|
|||||||
import shareIcon from '../assets/icons/share.svg'
|
import shareIcon from '../assets/icons/share.svg'
|
||||||
import RightRailAd from '../components/RightRailAd.vue'
|
import RightRailAd from '../components/RightRailAd.vue'
|
||||||
import { api } from '../lib/api'
|
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 { toApiUrl } from '../lib/runtime'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useToast } from '../composables/useToast'
|
import { useToast } from '../composables/useToast'
|
||||||
@@ -141,7 +141,7 @@ const copiedFromLabel = computed(() => {
|
|||||||
return parts.join(' · ') || '복사해 온 티어표'
|
return parts.join(' · ') || '복사해 온 티어표'
|
||||||
})
|
})
|
||||||
const customItems = computed(() => getOrderedItems().filter((item) => item?.origin === 'custom'))
|
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 hasSavedTierList = computed(() => !!(persistedTierListId.value || (tierListId.value && tierListId.value !== 'new')))
|
||||||
const canRequestTemplateCreate = computed(
|
const canRequestTemplateCreate = computed(
|
||||||
() => canEdit.value && hasSavedTierList.value && templateId.value === 'freeform' && customItems.value.length > 0
|
() => canEdit.value && hasSavedTierList.value && templateId.value === 'freeform' && customItems.value.length > 0
|
||||||
@@ -155,8 +155,9 @@ const templateRequestTargetLabel = computed(() => (templateId.value === 'freefor
|
|||||||
const shareTierListUrl = computed(() => {
|
const shareTierListUrl = computed(() => {
|
||||||
const savedTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
|
const savedTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
|
||||||
if (!savedTierListId) return ''
|
if (!savedTierListId) return ''
|
||||||
if (typeof window === 'undefined') return editorPath(templateId.value, savedTierListId, { preview: true })
|
const sharePath = shareEditorPath(templateId.value, savedTierListId)
|
||||||
return new URL(editorPath(templateId.value, savedTierListId, { preview: true }), window.location.origin).toString()
|
if (typeof window === 'undefined') return sharePath
|
||||||
|
return new URL(sharePath, window.location.origin).toString()
|
||||||
})
|
})
|
||||||
watch(error, (message) => {
|
watch(error, (message) => {
|
||||||
if (!message) return
|
if (!message) return
|
||||||
@@ -164,6 +165,13 @@ watch(error, (message) => {
|
|||||||
error.value = ''
|
error.value = ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function normalizeSearchText(text) {
|
||||||
|
return String(text || '')
|
||||||
|
.normalize('NFC')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
function createAutoTierListTitle() {
|
function createAutoTierListTitle() {
|
||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||||
const pick = (size) => Array.from({ length: size }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
|
const pick = (size) => Array.from({ length: size }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
|
||||||
@@ -229,7 +237,7 @@ function isPoolItemVisible(itemId) {
|
|||||||
const query = normalizedPoolSearchQuery.value
|
const query = normalizedPoolSearchQuery.value
|
||||||
if (!query) return true
|
if (!query) return true
|
||||||
const item = itemsById.value[itemId]
|
const item = itemsById.value[itemId]
|
||||||
const label = String(item?.label || itemId || '').toLowerCase()
|
const label = normalizeSearchText(item?.label || itemId || '')
|
||||||
return label.includes(query)
|
return label.includes(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,13 +464,26 @@ function duplicateItemToPool() {
|
|||||||
pool.value = [clonedId, ...pool.value]
|
pool.value = [clonedId, ...pool.value]
|
||||||
selectedItemId.value = clonedId
|
selectedItemId.value = clonedId
|
||||||
closeItemContextMenu()
|
closeItemContextMenu()
|
||||||
toast.success('복제본을 미사용 아이템 목록에 추가했어요.')
|
toast.success('아이템 추가 완료')
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleGlobalContextMenu(event) {
|
function handleGlobalContextMenu(event) {
|
||||||
if (!itemContextMenu.value.open) return
|
|
||||||
const target = event?.target
|
const target = event?.target
|
||||||
if (target?.closest?.('[data-item-context-menu]') || target?.closest?.('[data-item-id]')) return
|
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()
|
closeItemContextMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,6 +626,7 @@ function createColumnName(index = columns.value.length) {
|
|||||||
|
|
||||||
function createCustomItemLabel(fileName = '') {
|
function createCustomItemLabel(fileName = '') {
|
||||||
const normalized = String(fileName || '')
|
const normalized = String(fileName || '')
|
||||||
|
.normalize('NFC')
|
||||||
.replace(/\.[^.]+$/, '')
|
.replace(/\.[^.]+$/, '')
|
||||||
.replace(/[_-]+/g, ' ')
|
.replace(/[_-]+/g, ' ')
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
@@ -1316,7 +1338,7 @@ watch(
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
window.addEventListener('pointerdown', handleGlobalPointerDown)
|
window.addEventListener('pointerdown', handleGlobalPointerDown)
|
||||||
window.addEventListener('contextmenu', handleGlobalContextMenu)
|
window.addEventListener('contextmenu', handleGlobalContextMenu, true)
|
||||||
window.addEventListener('blur', closeItemContextMenu)
|
window.addEventListener('blur', closeItemContextMenu)
|
||||||
window.addEventListener('scroll', closeItemContextMenu, true)
|
window.addEventListener('scroll', closeItemContextMenu, true)
|
||||||
})
|
})
|
||||||
@@ -1324,7 +1346,7 @@ onMounted(() => {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.removeEventListener('pointerdown', handleGlobalPointerDown)
|
window.removeEventListener('pointerdown', handleGlobalPointerDown)
|
||||||
window.removeEventListener('contextmenu', handleGlobalContextMenu)
|
window.removeEventListener('contextmenu', handleGlobalContextMenu, true)
|
||||||
window.removeEventListener('blur', closeItemContextMenu)
|
window.removeEventListener('blur', closeItemContextMenu)
|
||||||
window.removeEventListener('scroll', closeItemContextMenu, true)
|
window.removeEventListener('scroll', closeItemContextMenu, true)
|
||||||
}
|
}
|
||||||
@@ -1643,9 +1665,13 @@ onUnmounted(() => {
|
|||||||
:class="{ 'cell--selected': selectedItemId === id }"
|
:class="{ 'cell--selected': selectedItemId === id }"
|
||||||
:data-item-id="id"
|
:data-item-id="id"
|
||||||
@click.stop="selectItemByClick(id)"
|
@click.stop="selectItemByClick(id)"
|
||||||
@contextmenu.prevent.stop="openItemContextMenu(id, $event)"
|
|
||||||
>
|
>
|
||||||
<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>
|
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
|
||||||
<button
|
<button
|
||||||
v-if="canEdit && !isExporting"
|
v-if="canEdit && !isExporting"
|
||||||
@@ -1725,9 +1751,13 @@ onUnmounted(() => {
|
|||||||
}"
|
}"
|
||||||
:data-item-id="id"
|
:data-item-id="id"
|
||||||
@click.stop="selectItemByClick(id)"
|
@click.stop="selectItemByClick(id)"
|
||||||
@contextmenu.prevent.stop="openItemContextMenu(id, $event)"
|
|
||||||
>
|
>
|
||||||
<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 class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
|
||||||
<div v-if="!canEdit" class="poolItem__state">미배치</div>
|
<div v-if="!canEdit" class="poolItem__state">미배치</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3050,7 +3080,7 @@ onUnmounted(() => {
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
border: 1px solid var(--theme-border);
|
border: 1px solid var(--theme-border);
|
||||||
background: var(--theme-card-bg);
|
background: color-mix(in srgb, var(--theme-main-bg) 96%, transparent);
|
||||||
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.28);
|
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user