diff --git a/backend/app/routers/share.py b/backend/app/routers/share.py
index e6641d5..be2132d 100644
--- a/backend/app/routers/share.py
+++ b/backend/app/routers/share.py
@@ -19,7 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings
from ..database import get_db
-from ..models import Match
+from ..models import Match, Song
from ..schedule_data import TEAMS
router = APIRouter()
@@ -54,6 +54,59 @@ def _team_label(code: str | None) -> str:
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"""
+
+
+
+{t}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+TriplePick — {t}
+"""
+ return HTMLResponse(page)
+
+
@router.get("/match/{match_id}", response_class=HTMLResponse)
async def match_share(match_id: str, db: AsyncSession = Depends(get_db)) -> HTMLResponse:
a, b = _parse_codes(match_id)
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
index 0382a0f..d908028 100644
--- a/frontend/nginx.conf
+++ b/frontend/nginx.conf
@@ -29,6 +29,12 @@ server {
if ($is_share_crawler) { return 418; }
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 {
proxy_pass http://api:8000;
proxy_set_header Host $host;
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index e932955..2e61a7f 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,5 +1,5 @@
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 MatchDetail from "./pages/MatchDetail";
import Leaderboard from "./pages/Leaderboard";
@@ -8,6 +8,13 @@ import { recordVisit } from "./lib/api";
import { PlayerProvider } from "./lib/usePlayer";
import MusicBar from "./components/MusicBar";
+// 응원가 공유 링크(/song/:matchId/:teamCode) — 크롤러는 nginx 가 OG 프리렌더로
+// 보내고, 사람은 여기로 와서 경기 페이지 + 해당 팀 응원가 자동 로드로 이동.
+function SongShareRedirect() {
+ const { matchId = "", teamCode = "" } = useParams();
+ return ;
+}
+
export default function App() {
// 접속 시 1회 방문 기록 (하루 1기기 1회 집계는 백엔드가 처리)
useEffect(() => {
@@ -19,6 +26,7 @@ export default function App() {
} />
} />
+ } />
} />
} />
diff --git a/frontend/src/components/MatchupHUD.tsx b/frontend/src/components/MatchupHUD.tsx
index b08f5c3..2645c96 100644
--- a/frontend/src/components/MatchupHUD.tsx
+++ b/frontend/src/components/MatchupHUD.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from "react";
+import { useEffect, useRef, useState } from "react";
import type { Match, TeamStanding } from "@/lib/types";
import { kickoffDisplay } from "@/lib/format";
import { type Lang, dict, teamShort, roundLabel as tRound } from "@/lib/i18n";
@@ -30,6 +30,18 @@ function CheerSongButton({
};
}, [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;
const active = !!currentTrack && tracks.some((t) => t.src === currentTrack.src);
const label = active
diff --git a/frontend/src/components/MusicBar.tsx b/frontend/src/components/MusicBar.tsx
index 7950fd4..36d5b6e 100644
--- a/frontend/src/components/MusicBar.tsx
+++ b/frontend/src/components/MusicBar.tsx
@@ -1,4 +1,4 @@
-import { useEffect } from "react";
+import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import { usePlayer } from "@/lib/usePlayer";
import { useLang } from "@/lib/useLang";
@@ -53,6 +53,27 @@ function IconMusic() {
);
}
+function IconLyrics() {
+ return (
+
+ );
+}
+function IconShare() {
+ return (
+
+ );
+}
+function IconCheck() {
+ return (
+
+ );
+}
function IconVolume({ level }: { level: number }) {
if (level === 0) {
return (
@@ -151,6 +172,36 @@ export default function MusicBar() {
loadPlaylist(getTracksForPath(pathname));
}, [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;
@@ -227,6 +278,29 @@ export default function MusicBar() {
+ {/* 가사 패널 — 바 위로 펼침 */}
+ {showLyrics && currentTrack?.lyrics && (
+
+
+
+
+ ♪ {currentTrack.title}
+
+
+
+
+ {currentTrack.lyrics}
+
+
+
+ )}
+
+ {/* 가사 보기 · 공유 — 응원가(가사·공유 정보 보유 트랙)만 노출 */}
+ {canLyrics && (
+
+ )}
+ {canShare && (
+
+ )}
diff --git a/frontend/src/lib/playlist.ts b/frontend/src/lib/playlist.ts
index 8118321..3088bf1 100644
--- a/frontend/src/lib/playlist.ts
+++ b/frontend/src/lib/playlist.ts
@@ -7,6 +7,11 @@ export type Track = {
src: string;
/** 앨범 아트 이미지 경로 또는 URL */
cover?: string;
+ /** 가사 (오늘의 응원가) — 있으면 플레이어에 '가사 보기' 노출 */
+ lyrics?: string;
+ /** 공유 링크·문구 — 있으면 플레이어에 '공유하기' 노출 */
+ shareUrl?: string;
+ shareText?: string;
};
// 경로별 플레이리스트 — 키가 표시할 경로, 값이 해당 경로의 트랙 목록
@@ -64,6 +69,9 @@ function fetchSongsCached(): Promise {
function songToTracks(s: SongOut): Track[] {
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 || [])
.filter((t) => t.audioUrl)
.map((t, i) => ({
@@ -71,6 +79,9 @@ function songToTracks(s: SongOut): Track[] {
artist: `오늘의 ${s.teamName} 응원가`,
src: t.audioUrl,
cover: t.imageUrl,
+ lyrics: s.lyrics || undefined,
+ shareUrl,
+ shareText,
}));
}