+
+
+ ${safeTitle}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`
+}
+
+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
diff --git a/docs/history.md b/docs/history.md
index 7730a71..d841512 100644
--- a/docs/history.md
+++ b/docs/history.md
@@ -1,5 +1,10 @@
# 의사결정 이력
+## 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`로 맞춰 이후 신규 업로드 항목까지 같은 규칙을 타게 정리했다.
diff --git a/docs/update.md b/docs/update.md
index 643a120..30277e6 100644
--- a/docs/update.md
+++ b/docs/update.md
@@ -1,5 +1,12 @@
# 업데이트 로그
+## 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`로 정규화하도록 바꾸고, 커스텀 이미지 파일명에서 기본 라벨을 만들 때도 같은 정규화를 거쳐 한글 조합형 차이로 검색이 빗나가는 상황을 줄였다.
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
index d83a404..8c09279 100644
--- a/frontend/nginx.conf
+++ b/frontend/nginx.conf
@@ -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;
diff --git a/frontend/src/lib/paths.js b/frontend/src/lib/paths.js
index 9c8de43..347b393 100644
--- a/frontend/src/lib/paths.js
+++ b/frontend/src/lib/paths.js
@@ -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'
}
diff --git a/frontend/src/views/TierEditorView.vue b/frontend/src/views/TierEditorView.vue
index 89e72c9..665d3f3 100644
--- a/frontend/src/views/TierEditorView.vue
+++ b/frontend/src/views/TierEditorView.vue
@@ -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'
@@ -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