플레이어에 가사 보기·공유하기 + 응원가 전용 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:
parent
dbd08e62c7
commit
3a5fc36ee8
@ -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"""<!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)
|
||||
async def match_share(match_id: str, db: AsyncSession = Depends(get_db)) -> HTMLResponse:
|
||||
a, b = _parse_codes(match_id)
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 <Navigate to={`/match/${matchId}?song=${teamCode}`} replace />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
// 접속 시 1회 방문 기록 (하루 1기기 1회 집계는 백엔드가 처리)
|
||||
useEffect(() => {
|
||||
@ -19,6 +26,7 @@ export default function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/match/:matchId" element={<MatchDetail />} />
|
||||
<Route path="/song/:matchId/:teamCode" element={<SongShareRedirect />} />
|
||||
<Route path="/leaderboard" element={<Leaderboard />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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() {
|
||||
</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 }) {
|
||||
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() {
|
||||
<div className="relative">
|
||||
<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
|
||||
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)" }}
|
||||
@ -323,6 +397,37 @@ export default function MusicBar() {
|
||||
style={{ width: `${displayVolume * 100}%`, background: "rgba(255,255,255,0.85)" }}
|
||||
/>
|
||||
</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>
|
||||
|
||||
@ -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<SongOut[]> {
|
||||
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user