Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28c6dafa02 | |||
| f98524390b | |||
| da37fe9fc9 |
@@ -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}`)
|
||||
|
||||
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,14 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 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는 카드 영역과 썸네일 이미지 중 어디를 눌러도 같은 동작이어야 하므로, 개별 카드의 버블링 이벤트만 믿기보다 전역 캡처 단계에서 아이템 우클릭을 먼저 가로채는 방식이 더 안전하다고 판단했다.
|
||||
- 편집기에서는 아이템 이미지를 브라우저 기본 이미지처럼 드래그하거나 저장 메뉴로 여는 것보다 보드 조작이 우선이므로, 썸네일 이미지의 기본 드래그도 명시적으로 꺼두는 편이 맞다고 정리했다.
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 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]` 대상 우클릭을 먼저 잡아 기본 메뉴를 막고 커스텀 복제 메뉴를 열도록 바꿨다.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -141,7 +141,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
|
||||
@@ -155,8 +155,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
|
||||
@@ -164,6 +165,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('')
|
||||
@@ -229,7 +237,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)
|
||||
}
|
||||
|
||||
@@ -456,7 +464,7 @@ function duplicateItemToPool() {
|
||||
pool.value = [clonedId, ...pool.value]
|
||||
selectedItemId.value = clonedId
|
||||
closeItemContextMenu()
|
||||
toast.success('복제본을 미사용 아이템 목록에 추가했어요.')
|
||||
toast.success('아이템 추가 완료')
|
||||
}
|
||||
|
||||
function handleGlobalContextMenu(event) {
|
||||
@@ -618,6 +626,7 @@ function createColumnName(index = columns.value.length) {
|
||||
|
||||
function createCustomItemLabel(fileName = '') {
|
||||
const normalized = String(fileName || '')
|
||||
.normalize('NFC')
|
||||
.replace(/\.[^.]+$/, '')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
@@ -3071,7 +3080,7 @@ onUnmounted(() => {
|
||||
padding: 8px;
|
||||
border-radius: 16px;
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user