From 3a5fc36ee8b634391c122f83e90c9f1d47e09884 Mon Sep 17 00:00:00 2001 From: jwkim Date: Tue, 25 Aug 2026 10:20:54 +0900 Subject: [PATCH] =?UTF-8?q?=ED=94=8C=EB=A0=88=EC=9D=B4=EC=96=B4=EC=97=90?= =?UTF-8?q?=20=EA=B0=80=EC=82=AC=20=EB=B3=B4=EA=B8=B0=C2=B7=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0=ED=95=98=EA=B8=B0=20+=20=EC=9D=91=EC=9B=90=EA=B0=80?= =?UTF-8?q?=20=EC=A0=84=EC=9A=A9=20OG=20=EA=B3=B5=EC=9C=A0=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 하단 MusicBar: 가사 패널 토글(바 위로 펼침)·공유 버튼(네이티브 공유, 폴백 클립보드) — 응원가 트랙에만 노출 - 공유 링크 /song/:matchId/:teamCode — 크롤러(카톡·페북)는 "오늘의 OO 응원가" 제목 + Suno 앨범아트 OG 카드, 사람은 경기 페이지로 리다이렉트 후 그 팀 응원가 자동 로드(?song=) - nginx /song/ 크롤러 분기 추가 Co-Authored-By: Claude Fable 5 --- backend/app/routers/share.py | 55 ++++++++++++- frontend/nginx.conf | 6 ++ frontend/src/App.tsx | 10 ++- frontend/src/components/MatchupHUD.tsx | 14 +++- frontend/src/components/MusicBar.tsx | 107 ++++++++++++++++++++++++- frontend/src/lib/playlist.ts | 11 +++ 6 files changed, 199 insertions(+), 4 deletions(-) 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, })); }