플레이어에 가사 보기·공유하기 + 응원가 전용 OG 공유 카드

- 하단 MusicBar: 가사 패널 토글(바 위로 펼침)·공유 버튼(네이티브 공유,
  폴백 클립보드) — 응원가 트랙에만 노출
- 공유 링크 /song/:matchId/:teamCode — 크롤러(카톡·페북)는 "오늘의 OO
  응원가" 제목 + Suno 앨범아트 OG 카드, 사람은 경기 페이지로 리다이렉트
  후 그 팀 응원가 자동 로드(?song=)
- nginx /song/ 크롤러 분기 추가

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jwkim 2026-08-25 10:20:54 +09:00
parent dbd08e62c7
commit 3a5fc36ee8
6 changed files with 199 additions and 4 deletions

View File

@ -19,7 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings from ..config import settings
from ..database import get_db from ..database import get_db
from ..models import Match from ..models import Match, Song
from ..schedule_data import TEAMS from ..schedule_data import TEAMS
router = APIRouter() router = APIRouter()
@ -54,6 +54,59 @@ def _team_label(code: str | None) -> str:
return _KOR_NAME.get(code, code) return _KOR_NAME.get(code, code)
@router.get("/song/{match_id}/{team_code}", response_class=HTMLResponse)
async def song_share(
match_id: str, team_code: str, db: AsyncSession = Depends(get_db)
) -> HTMLResponse:
"""응원가 공유 OG — '오늘의 OO 응원가' 제목 + Suno 앨범아트 썸네일."""
s = (
await db.execute(
select(Song).where(Song.match_id == match_id, Song.team_code == team_code)
)
).scalar_one_or_none()
team = (s.team_name if s else "") or _team_label(team_code) or team_code
song_title = (s.title if s else "") or f"{team} 응원가"
title = f"오늘의 {team} 응원가 — {song_title}"
desc = (
"AI가 오늘 경기(순위·선발 라인업·전날 결과)를 반영해 만든 응원가. "
"듣고 나서 GPT·Claude·Gemini와 승부예측도 겨뤄보세요!"
)
origin = settings.public_origin.rstrip("/")
# 썸네일: Suno 앨범아트(외부 CDN 절대 URL) → 없으면 기본 카드
cover = ""
if s and s.tracks:
cover = (s.tracks[0] or {}).get("imageUrl") or ""
img_url = cover if cover.startswith("http") else f"{origin}{DEFAULT_OG}?v=2"
page_url = f"{origin}/song/{html.escape(match_id)}/{html.escape(team_code)}"
t = html.escape(title)
d = html.escape(desc)
page = f"""<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<title>{t}</title>
<meta name="description" content="{d}" />
<meta property="og:type" content="music.song" />
<meta property="og:site_name" content="TriplePick" />
<meta property="og:title" content="{t}" />
<meta property="og:description" content="{d}" />
<meta property="og:url" content="{page_url}" />
<meta property="og:image" content="{img_url}" />
<meta property="og:image:secure_url" content="{img_url}" />
<meta property="og:locale" content="ko_KR" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{t}" />
<meta name="twitter:description" content="{d}" />
<meta name="twitter:image" content="{img_url}" />
<link rel="canonical" href="{page_url}" />
</head>
<body><a href="{page_url}">TriplePick — {t}</a></body>
</html>"""
return HTMLResponse(page)
@router.get("/match/{match_id}", response_class=HTMLResponse) @router.get("/match/{match_id}", response_class=HTMLResponse)
async def match_share(match_id: str, db: AsyncSession = Depends(get_db)) -> HTMLResponse: async def match_share(match_id: str, db: AsyncSession = Depends(get_db)) -> HTMLResponse:
a, b = _parse_codes(match_id) a, b = _parse_codes(match_id)

View File

@ -29,6 +29,12 @@ server {
if ($is_share_crawler) { return 418; } if ($is_share_crawler) { return 418; }
try_files $uri /index.html; try_files $uri /index.html;
} }
# 응원가 공유 링크(/song/:matchId/:teamCode) — 동일 패턴
location /song/ {
error_page 418 = @og_prerender;
if ($is_share_crawler) { return 418; }
try_files $uri /index.html;
}
location @og_prerender { location @og_prerender {
proxy_pass http://api:8000; proxy_pass http://api:8000;
proxy_set_header Host $host; proxy_set_header Host $host;

View File

@ -1,5 +1,5 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { Routes, Route } from "react-router-dom"; import { Routes, Route, Navigate, useParams } from "react-router-dom";
import Dashboard from "./pages/Dashboard"; import Dashboard from "./pages/Dashboard";
import MatchDetail from "./pages/MatchDetail"; import MatchDetail from "./pages/MatchDetail";
import Leaderboard from "./pages/Leaderboard"; import Leaderboard from "./pages/Leaderboard";
@ -8,6 +8,13 @@ import { recordVisit } from "./lib/api";
import { PlayerProvider } from "./lib/usePlayer"; import { PlayerProvider } from "./lib/usePlayer";
import MusicBar from "./components/MusicBar"; import MusicBar from "./components/MusicBar";
// 응원가 공유 링크(/song/:matchId/:teamCode) — 크롤러는 nginx 가 OG 프리렌더로
// 보내고, 사람은 여기로 와서 경기 페이지 + 해당 팀 응원가 자동 로드로 이동.
function SongShareRedirect() {
const { matchId = "", teamCode = "" } = useParams();
return <Navigate to={`/match/${matchId}?song=${teamCode}`} replace />;
}
export default function App() { export default function App() {
// 접속 시 1회 방문 기록 (하루 1기기 1회 집계는 백엔드가 처리) // 접속 시 1회 방문 기록 (하루 1기기 1회 집계는 백엔드가 처리)
useEffect(() => { useEffect(() => {
@ -19,6 +26,7 @@ export default function App() {
<Routes> <Routes>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/match/:matchId" element={<MatchDetail />} /> <Route path="/match/:matchId" element={<MatchDetail />} />
<Route path="/song/:matchId/:teamCode" element={<SongShareRedirect />} />
<Route path="/leaderboard" element={<Leaderboard />} /> <Route path="/leaderboard" element={<Leaderboard />} />
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { Match, TeamStanding } from "@/lib/types"; import type { Match, TeamStanding } from "@/lib/types";
import { kickoffDisplay } from "@/lib/format"; import { kickoffDisplay } from "@/lib/format";
import { type Lang, dict, teamShort, roundLabel as tRound } from "@/lib/i18n"; import { type Lang, dict, teamShort, roundLabel as tRound } from "@/lib/i18n";
@ -30,6 +30,18 @@ function CheerSongButton({
}; };
}, [matchId, teamCode]); }, [matchId, teamCode]);
// 공유 링크(/song/...→ ?song=팀코드) 진입 시 그 팀 응원가 자동 로드 (1회)
const autoLoaded = useRef(false);
useEffect(() => {
if (autoLoaded.current || tracks.length === 0) return;
const q = new URLSearchParams(window.location.search).get("song");
if (q === teamCode) {
autoLoaded.current = true;
loadPlaylist(tracks);
open();
}
}, [tracks, teamCode, loadPlaylist, open]);
if (tracks.length === 0) return null; if (tracks.length === 0) return null;
const active = !!currentTrack && tracks.some((t) => t.src === currentTrack.src); const active = !!currentTrack && tracks.some((t) => t.src === currentTrack.src);
const label = active const label = active

View File

@ -1,4 +1,4 @@
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { usePlayer } from "@/lib/usePlayer"; import { usePlayer } from "@/lib/usePlayer";
import { useLang } from "@/lib/useLang"; import { useLang } from "@/lib/useLang";
@ -53,6 +53,27 @@ function IconMusic() {
</svg> </svg>
); );
} }
function IconLyrics() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 5h18v2H3V5zm0 4h12v2H3V9zm0 4h18v2H3v-2zm0 4h12v2H3v-2z" />
</svg>
);
}
function IconShare() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z" />
</svg>
);
}
function IconCheck() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
);
}
function IconVolume({ level }: { level: number }) { function IconVolume({ level }: { level: number }) {
if (level === 0) { if (level === 0) {
return ( return (
@ -151,6 +172,36 @@ export default function MusicBar() {
loadPlaylist(getTracksForPath(pathname)); loadPlaylist(getTracksForPath(pathname));
}, [pathname, loadPlaylist]); }, [pathname, loadPlaylist]);
// 가사 패널·공유 상태
const [showLyrics, setShowLyrics] = useState(false);
const [shared, setShared] = useState(false);
const canLyrics = !!currentTrack?.lyrics;
const canShare = !!currentTrack?.shareUrl;
useEffect(() => {
if (!canLyrics) setShowLyrics(false);
}, [canLyrics]);
const doShare = async () => {
const tr = currentTrack;
if (!tr?.shareUrl) return;
const text = tr.shareText ?? tr.title;
if (navigator.share) {
try {
await navigator.share({ title: text, text, url: tr.shareUrl });
} catch {
/* cancelled */
}
} else {
try {
await navigator.clipboard.writeText(`${text}\n${tr.shareUrl}`);
setShared(true);
setTimeout(() => setShared(false), 1800);
} catch {
/* noop */
}
}
};
// 자동이든 버튼이든 플레이리스트가 실리면 바 노출, 비면 숨김 // 자동이든 버튼이든 플레이리스트가 실리면 바 노출, 비면 숨김
if (!hasTrack) return null; if (!hasTrack) return null;
@ -227,6 +278,29 @@ export default function MusicBar() {
<div className="relative"> <div className="relative">
<CollapseTab minimized={false} onToggle={close} /> <CollapseTab minimized={false} onToggle={close} />
{/* 가사 패널 — 바 위로 펼침 */}
{showLyrics && currentTrack?.lyrics && (
<div className="border-t border-[var(--line-d)] bg-[var(--bg2)]/95 backdrop-blur-sm">
<div className="mx-auto max-w-5xl px-4 py-3 md:px-6">
<div className="mb-1.5 flex items-center justify-between">
<p className="text-[13px] font-bold text-[var(--green)]">
♪ {currentTrack.title}
</p>
<button
onClick={() => setShowLyrics(false)}
aria-label={lang === "en" ? "Close lyrics" : "가사 닫기"}
className="px-1 text-[18px] leading-none text-white/50 hover:text-white/80"
>
×
</button>
</div>
<pre className="max-h-[40vh] overflow-y-auto whitespace-pre-wrap font-sans text-[13px] leading-relaxed text-white/80">
{currentTrack.lyrics}
</pre>
</div>
</div>
)}
<div <div
className="border-t border-[var(--line-d)] bg-[var(--bg2)]/95 backdrop-blur-sm" className="border-t border-[var(--line-d)] bg-[var(--bg2)]/95 backdrop-blur-sm"
style={{ boxShadow: "0 -4px 24px rgba(0,0,0,0.45)" }} style={{ boxShadow: "0 -4px 24px rgba(0,0,0,0.45)" }}
@ -323,6 +397,37 @@ export default function MusicBar() {
style={{ width: `${displayVolume * 100}%`, background: "rgba(255,255,255,0.85)" }} style={{ width: `${displayVolume * 100}%`, background: "rgba(255,255,255,0.85)" }}
/> />
</div> </div>
{/* 가사 보기 · 공유 — 응원가(가사·공유 정보 보유 트랙)만 노출 */}
{canLyrics && (
<button
onClick={() => setShowLyrics((v) => !v)}
aria-label={lang === "en" ? "Lyrics" : "가사 보기"}
title={lang === "en" ? "Lyrics" : "가사 보기"}
className={`p-1 shrink-0 transition-colors ${
showLyrics
? "text-[var(--green)]"
: "text-white/45 hover:text-white/80"
}`}
>
<IconLyrics />
</button>
)}
{canShare && (
<button
onClick={doShare}
aria-label={lang === "en" ? "Share" : "공유하기"}
title={
shared
? lang === "en" ? "Link copied!" : "링크 복사됨!"
: lang === "en" ? "Share" : "공유하기"
}
className={`p-1 shrink-0 transition-colors ${
shared ? "text-[var(--green)]" : "text-white/45 hover:text-white/80"
}`}
>
{shared ? <IconCheck /> : <IconShare />}
</button>
)}
</div> </div>
</div> </div>

View File

@ -7,6 +7,11 @@ export type Track = {
src: string; src: string;
/** 앨범 아트 이미지 경로 또는 URL */ /** 앨범 아트 이미지 경로 또는 URL */
cover?: string; cover?: string;
/** 가사 (오늘의 응원가) — 있으면 플레이어에 '가사 보기' 노출 */
lyrics?: string;
/** 공유 링크·문구 — 있으면 플레이어에 '공유하기' 노출 */
shareUrl?: string;
shareText?: string;
}; };
// 경로별 플레이리스트 — 키가 표시할 경로, 값이 해당 경로의 트랙 목록 // 경로별 플레이리스트 — 키가 표시할 경로, 값이 해당 경로의 트랙 목록
@ -64,6 +69,9 @@ function fetchSongsCached(): Promise<SongOut[]> {
function songToTracks(s: SongOut): Track[] { function songToTracks(s: SongOut): Track[] {
const base = s.title || `${s.teamName} 응원가`; const base = s.title || `${s.teamName} 응원가`;
// 공유는 응원가 전용 링크 — 크롤러에 "오늘의 OO 응원가" OG 카드가 나간다
const shareUrl = `${window.location.origin}/song/${s.matchId}/${s.teamCode}`;
const shareText = `오늘의 ${s.teamName} 응원가 「${base}」 — TriplePick`;
return (s.tracks || []) return (s.tracks || [])
.filter((t) => t.audioUrl) .filter((t) => t.audioUrl)
.map((t, i) => ({ .map((t, i) => ({
@ -71,6 +79,9 @@ function songToTracks(s: SongOut): Track[] {
artist: `오늘의 ${s.teamName} 응원가`, artist: `오늘의 ${s.teamName} 응원가`,
src: t.audioUrl, src: t.audioUrl,
cover: t.imageUrl, cover: t.imageUrl,
lyrics: s.lyrics || undefined,
shareUrl,
shareText,
})); }));
} }