diff --git a/.cursor/rules/rule.mdc b/.cursor/rules/rule.mdc new file mode 100644 index 0000000..585fa10 --- /dev/null +++ b/.cursor/rules/rule.mdc @@ -0,0 +1,52 @@ +--- +description: +globs: +alwaysApply: true +--- +## Constructor Patterns + +### Args Structures +- For complex constructors, use `XXXArgs` struct to group parameters + +```go +type QUICArgs struct { + TLSConfig *tls.Config + Bootstrap *bootstrap.BootstrapAgent + StreamManager *wrtc.StreamManager + SessionManager *streamsession.SessionManager + RTPRouter *rtprouter.RTPRouter +} + +func NewQUICAcceptor(config *conf.Config, args QUICArgs) (*QUICAcceptor, error) { + // implementation +} +``` + +### Constructor Naming +- Always use `NewXXX` pattern for constructors +- Return pointer and error when validation is needed +- Return pointer only when no validation is required + +## Language and Documentation + +### Language Requirements +- **ALL comments, documentation, and messages MUST be in English** +- Function documentation must be in English +- Log messages must be in English +- Error messages must be in English +- Variable names and function names in English + +### Comment Style +- Write comments only when necessary for clarity or complex logic +- Avoid obvious comments that just repeat the code +- Focus on explaining WHY, not WHAT +- Use concise, clear English + +```go +// ValidateArgs checks if struct fields are nil or invalid interfaces +func ValidateArgs(args interface{}) error { + if !fieldVal.CanInterface() { + continue + } +} +``` \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a3e09bb --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +videos/* \ No newline at end of file diff --git a/front/.gitignore b/front/.gitignore index a547bf3..54f07af 100644 --- a/front/.gitignore +++ b/front/.gitignore @@ -21,4 +21,4 @@ dist-ssr *.ntvs* *.njsproj *.sln -*.sw? +*.sw? \ No newline at end of file diff --git a/front/src/App.tsx b/front/src/App.tsx index a5f336f..fff5148 100644 --- a/front/src/App.tsx +++ b/front/src/App.tsx @@ -6,6 +6,7 @@ import { import StreamList from "./pages/StreamList.tsx"; import Player from "./pages/Player.tsx"; import './App.css' +import './styles/common.css' function App() { return ( diff --git a/front/src/pages/Player.css b/front/src/pages/Player.css new file mode 100644 index 0000000..3fdb766 --- /dev/null +++ b/front/src/pages/Player.css @@ -0,0 +1,373 @@ +.player-container { + min-height: 100vh; + background: var(--primary-gradient); + font-family: var(--font-family); + color: var(--text-primary); +} + +.player-header { + display: flex; + align-items: center; + gap: 2rem; + padding: 2rem; + border-bottom: 1px solid var(--glass-border); +} + +.back-button { + background: var(--glass-bg); + color: var(--text-primary); + border: none; + padding: 0.75rem 1.5rem; + border-radius: var(--border-radius-small); + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; + text-decoration: none; + display: inline-flex; + align-items: center; + backdrop-filter: blur(10px); + border: 1px solid var(--glass-border); +} + +.back-button:hover { + background: rgba(255, 255, 255, 0.3); + transform: translateY(-2px); + box-shadow: var(--shadow-light); +} + +.stream-meta { + flex: 1; +} + +.stream-title { + font-size: 2.5rem; + font-weight: 700; + margin: 0 0 0.5rem 0; +} + +.stream-stats { + display: flex; + align-items: center; + gap: 1.5rem; + flex-wrap: wrap; +} + +.live-badge { + background: var(--live-gradient); + color: white; + padding: 0.5rem 1rem; + border-radius: var(--border-radius-large); + font-size: 0.9rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + animation: pulse-live 2s infinite; + box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3); +} + +@keyframes pulse-live { + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0.8; } +} + +.viewer-count { + font-size: 0.9rem; + font-weight: 500; +} + +.player-wrapper { + padding: 2rem; + max-width: 1400px; + margin: 0 auto; +} + +.video-container { + position: relative; + background: #000; + border-radius: var(--border-radius); + overflow: hidden; + box-shadow: var(--shadow-heavy); + aspect-ratio: 16/9; +} + +.video-player { + width: 100%; + height: 100%; + border-radius: var(--border-radius); +} + +.video-loading-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 10; + backdrop-filter: blur(5px); +} + +.video-loading-overlay p { + margin-top: 1rem; + font-size: 1.1rem; + color: white; +} + +.player-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 400px; +} + +.player-loading p { + font-size: 1.2rem; + margin: 0; + opacity: 0.9; +} + +.player-error { + text-align: center; + padding: 3rem; + background: var(--glass-bg); + border-radius: var(--border-radius); + backdrop-filter: blur(10px); + border: 1px solid var(--glass-border); +} + +.error-icon { + font-size: 4rem; + margin-bottom: 1rem; +} + +.player-error h2, +.player-error h3 { + color: #fca5a5; + margin-bottom: 1rem; + font-size: 2rem; +} + +.player-error p { + margin-bottom: 2rem; + font-size: 1.1rem; + opacity: 0.9; + line-height: 1.6; +} + +.error-actions { + display: flex; + gap: 1rem; + justify-content: center; + flex-wrap: wrap; +} + +.retry-button { + background: var(--button-primary); + color: white; + border: none; + padding: 0.75rem 2rem; + border-radius: var(--border-radius-small); + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; +} + +.retry-button:hover { + transform: translateY(-2px); + box-shadow: 0 8px 16px rgba(59, 130, 246, 0.3); +} + +.back-link { + background: var(--glass-bg); + color: var(--text-primary); + text-decoration: none; + padding: 0.75rem 2rem; + border-radius: var(--border-radius-small); + font-size: 1rem; + font-weight: 500; + transition: all 0.3s ease; + backdrop-filter: blur(10px); + border: 1px solid var(--glass-border); +} + +.back-link:hover { + background: rgba(255, 255, 255, 0.3); + transform: translateY(-2px); +} + +.player-info { + max-width: 1400px; + margin: 0 auto; + padding: 2rem; +} + +.stream-details, +.player-controls-info { + background: var(--glass-bg); + padding: 2rem; + border-radius: var(--border-radius); + backdrop-filter: blur(10px); + border: 1px solid var(--glass-border); +} + +.stream-details h3, +.player-controls-info h3 { + font-size: 1.5rem; + font-weight: 600; + margin: 0 0 1.5rem 0; + color: #e0e7ff; +} + +.detail-item { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding: 0.75rem 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.detail-item:last-child { + border-bottom: none; + margin-bottom: 0; +} + +.detail-label { + font-weight: 500; + opacity: 0.8; +} + +.detail-value { + font-weight: 600; +} + +.status-live { + color: #fca5a5; + font-weight: 600; +} + +.status-offline { + color: #9ca3af; + font-weight: 600; +} + +.player-controls-info ul { + list-style: none; + padding: 0; + margin: 0; +} + +.player-controls-info li { + padding: 0.75rem 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + align-items: center; + gap: 1rem; +} + +.player-controls-info li:last-child { + border-bottom: none; +} + +.player-controls-info kbd { + padding: 0.25rem 0.5rem; + border-radius: 6px; + font-size: 0.9rem; + font-weight: 600; + min-width: 3rem; + text-align: center; +} + +/* 반응형 디자인 */ +@media (max-width: 768px) { + .player-header { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + padding: 1.5rem; + } + + .stream-title { + font-size: 2rem; + } + + .stream-stats { + gap: 1rem; + } + + .player-wrapper { + padding: 1rem; + } + + .player-info { + padding: 1rem; + } + + .stream-details, + .player-controls-info { + padding: 1.5rem; + } + + .error-actions { + flex-direction: column; + align-items: center; + } +} + +@media (max-width: 480px) { + .stream-title { + font-size: 1.5rem; + } + + .live-badge, + .viewer-count { + font-size: 0.8rem; + padding: 0.4rem 0.8rem; + } + + .back-button { + padding: 0.6rem 1.2rem; + font-size: 0.9rem; + } + + .detail-item { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } +} + +/* 다크 모드 지원 */ +@media (prefers-color-scheme: dark) { + .video-container { + box-shadow: var(--shadow-heavy); + } +} + +/* 접근성 개선 */ +@media (prefers-reduced-motion: reduce) { + .live-badge { + animation: none; + } + + .back-button:hover, + .retry-button:hover, + .back-link:hover { + transform: none; + } +} + +/* 포커스 스타일 */ +.back-button:focus, +.retry-button:focus, +.back-link:focus { + outline: 3px solid #60a5fa; + outline-offset: 2px; +} \ No newline at end of file diff --git a/front/src/pages/Player.tsx b/front/src/pages/Player.tsx index f1e117f..8a00416 100644 --- a/front/src/pages/Player.tsx +++ b/front/src/pages/Player.tsx @@ -1,26 +1,160 @@ import MuxPlayer from "@mux/mux-video-react"; -import { useParams } from 'react-router-dom'; +import { useParams, Link, useNavigate } from 'react-router-dom'; +import { useState, useEffect } from 'react'; +import './Player.css'; + +interface StreamInfo { + id: string; + title: string; + isLive: boolean; + viewers?: number; +} function Player() { const { streamId } = useParams<{ streamId: string }>(); + const navigate = useNavigate(); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [streamInfo, setStreamInfo] = useState(null); + const [isPlayerReady, setIsPlayerReady] = useState(false); + + useEffect(() => { + if (!streamId) { + setError('Stream ID not provided'); + setIsLoading(false); + return; + } + + // 스트림 정보 설정 (실제로는 API에서 가져올 수 있음) + setStreamInfo({ + id: streamId, + title: streamId, + isLive: true, + viewers: Math.floor(Math.random() * 1000) + 1 + }); + + setIsLoading(false); + }, [streamId]); + + const handlePlayerLoadStart = () => { + setIsPlayerReady(false); + }; + + const handlePlayerLoadedData = () => { + setIsPlayerReady(true); + }; + + const handlePlayerError = () => { + setError('Failed to load stream. The stream may be offline or unavailable.'); + }; + + const handleGoBack = () => { + navigate('/'); + }; if (!streamId) { - return
Error: Stream ID not provided.
; + return ( +
+
+

Error

+

Stream ID not provided

+ + ← Back to Streams + +
+
+ ); } const playbackUrl = `${window.location.protocol}//${window.location.host}/hls/${streamId}/master.m3u8`; return ( -
-

Playing: {streamId}

- +
+
+ +
+

{streamInfo?.title || streamId}

+
+ {streamInfo?.isLive && ( + ● LIVE + )} + {streamInfo?.viewers && ( + + 👥 {streamInfo.viewers.toLocaleString()} viewers + + )} +
+
+
+ +
+ {isLoading ? ( +
+
+

Loading stream...

+
+ ) : error ? ( +
+
⚠️
+

Stream Unavailable

+

{error}

+
+ + + ← Back to Streams + +
+
+ ) : ( +
+ {!isPlayerReady && ( +
+
+

Loading video...

+
+ )} + +
+ )} +
+ +
+
+

Stream Information

+
+ Stream ID: + {streamId} +
+
+ Status: + + {streamInfo?.isLive ? ( + 🔴 Live + ) : ( + ⚫ Offline + )} + +
+
+ Quality: + Auto (HLS) +
+
+
); } diff --git a/front/src/pages/StreamList.css b/front/src/pages/StreamList.css new file mode 100644 index 0000000..b66e62f --- /dev/null +++ b/front/src/pages/StreamList.css @@ -0,0 +1,308 @@ +.stream-list-container { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; + min-height: 100vh; + background: var(--primary-gradient); + font-family: var(--font-family); +} + +.stream-list-header { + text-align: center; + margin-bottom: 3rem; + color: var(--text-primary); +} + +.stream-list-header h1 { + margin: 0 0 0.5rem 0; +} + +.stream-count { + opacity: 0.9; + margin: 0; + font-weight: 400; +} + +.streams-grid { + margin-bottom: 2rem; +} + +.stream-card { + background: white; + border-radius: var(--border-radius); + overflow: hidden; + box-shadow: var(--shadow-light); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + text-decoration: none; + color: inherit; + position: relative; + backdrop-filter: blur(10px); + border: 1px solid var(--glass-border); +} + +.stream-card:hover { + transform: translateY(-8px) scale(1.02); + box-shadow: var(--shadow-medium); +} + +.stream-card:active { + transform: translateY(-4px) scale(1.01); +} + +.stream-thumbnail { + width: 100%; + height: 200px; + position: relative; + overflow: hidden; + background: linear-gradient(45deg, #f0f0f0, #e0e0e0); +} + +.stream-thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.3s ease; +} + +.stream-card:hover .stream-thumbnail img { + transform: scale(1.05); +} + +.thumbnail-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: var(--primary-gradient); + position: relative; +} + +.play-icon { + font-size: 3rem; + color: white; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.05); } +} + +.stream-info { + padding: 1.5rem; +} + +.stream-title { + font-size: 1.25rem; + font-weight: 600; + margin: 0 0 0.5rem 0; + color: #1f2937; + line-height: 1.4; +} + +.stream-status { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.live-indicator { + background: var(--live-gradient); + color: white; + padding: 0.25rem 0.75rem; + border-radius: var(--border-radius-large); + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + animation: blink 2s infinite; +} + +@keyframes blink { + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0.7; } +} + +.loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 60vh; + color: var(--text-primary); +} + +.loading-spinner { + width: 50px; + height: 50px; + border: 4px solid rgba(255, 255, 255, 0.3); + border-top: 4px solid white; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 1rem; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.loading p { + font-size: 1.2rem; + margin: 0; + opacity: 0.9; +} + +.error { + text-align: center; + padding: 3rem; + background: var(--glass-bg); + border-radius: var(--border-radius); + color: var(--text-primary); + backdrop-filter: blur(10px); + border: 1px solid var(--glass-border); +} + +.error h2 { + color: #fca5a5; + margin-bottom: 1rem; + font-size: 2rem; +} + +.error p { + margin-bottom: 2rem; + font-size: 1.1rem; + opacity: 0.9; +} + +.error button { + background: var(--button-primary); + color: white; + border: none; + padding: 0.75rem 2rem; + border-radius: var(--border-radius-small); + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; +} + +.error button:hover { + transform: translateY(-2px); + box-shadow: 0 8px 16px rgba(59, 130, 246, 0.3); +} + +.empty-state { + text-align: center; + padding: 4rem 2rem; + color: var(--text-primary); +} + +.empty-icon { + font-size: 4rem; + margin-bottom: 1.5rem; + opacity: 0.8; +} + +.empty-state h2 { + margin-bottom: 1rem; + font-weight: 600; +} + +.empty-state p { + font-size: 1.2rem; + margin-bottom: 0.5rem; + opacity: 0.9; + line-height: 1.6; +} + +/* 반응형 디자인 */ +@media (max-width: 768px) { + .stream-list-container { + padding: 1rem; + } + + .stream-list-header h1 { + font-size: 2.5rem; + } + + .streams-grid { + grid-template-columns: 1fr; + gap: 1.5rem; + } + + .stream-thumbnail { + height: 180px; + } + + .empty-state { + padding: 3rem 1rem; + } + + .empty-state h2 { + font-size: 2rem; + } +} + +@media (max-width: 480px) { + .stream-list-header h1 { + font-size: 2rem; + } + + .stream-count { + font-size: 1rem; + } + + .stream-thumbnail { + height: 160px; + } + + .stream-info { + padding: 1rem; + } + + .stream-title { + font-size: 1.1rem; + } +} + +/* 다크 모드 지원 */ +@media (prefers-color-scheme: dark) { + .stream-card { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + } + + .stream-title { + color: #f9fafb; + } +} + +/* 접근성 개선 */ +@media (prefers-reduced-motion: reduce) { + .stream-card, + .stream-thumbnail img, + .play-icon, + .live-indicator, + .loading-spinner { + animation: none; + transition: none; + } + + .stream-card:hover { + transform: none; + } +} + +/* 포커스 스타일 */ +.stream-card:focus { + outline: 3px solid #3b82f6; + outline-offset: 2px; +} + +.error button:focus { + outline: 3px solid #60a5fa; + outline-offset: 2px; +} \ No newline at end of file diff --git a/front/src/pages/StreamList.tsx b/front/src/pages/StreamList.tsx index ee482e0..feb4caa 100644 --- a/front/src/pages/StreamList.tsx +++ b/front/src/pages/StreamList.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; +import './StreamList.css'; interface StreamsResponse { error_code: number; @@ -9,13 +10,53 @@ interface StreamsResponse { }; } +interface StreamCardProps { + streamId: string; +} + +function StreamCard({ streamId }: StreamCardProps) { + // 썸네일 이미지가 로드되지 않을 경우 기본 이미지를 보여주기 위한 상태 + const [imageError, setImageError] = useState(false); + + const handleImageError = () => { + setImageError(true); + }; + + return ( + +
+ {!imageError ? ( + {`${streamId} + ) : ( +
+
+
+ )} +
+
+

{streamId}

+
+ ● LIVE +
+
+ + ); +} + function StreamList() { const [streams, setStreams] = useState([]); const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); useEffect(() => { const fetchStreams = async () => { try { + setLoading(true); const response = await fetch('/streams'); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); @@ -26,32 +67,73 @@ function StreamList() { } else { throw new Error(result.message || 'Failed to fetch streams'); } - } catch (e: any) { - setError(e.message); - console.error("Error fetching streams:", e); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + setError(errorMessage); + console.error("Error fetching streams:", error); + } finally { + setLoading(false); } }; fetchStreams(); + + // 30초마다 스트림 목록을 새로고침 + const interval = setInterval(fetchStreams, 30000); + + return () => clearInterval(interval); }, []); + if (loading) { + return ( +
+
+
+

Loading streams...

+
+
+ ); + } + if (error) { - return
Error: {error}
; + return ( +
+
+

Error

+

{error}

+ +
+
+ ); } return ( -
-

Available Streams

+
+
+

Live Streams

+

+ {streams.length > 0 + ? `${streams.length} active stream${streams.length > 1 ? 's' : ''}` + : 'No active streams' + } +

+
+ {streams.length > 0 ? ( -
    +
    {streams.map(streamId => ( -
  • - {streamId} -
  • + ))} -
+
) : ( -

No active streams found.

+
+
📺
+

No Live Streams

+

There are no active streams at the moment.

+

Check back later or start streaming!

+
)}
); diff --git a/front/src/styles/common.css b/front/src/styles/common.css new file mode 100644 index 0000000..3d94b0b --- /dev/null +++ b/front/src/styles/common.css @@ -0,0 +1,246 @@ +/* 공통 변수 */ +:root { + --primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + --glass-bg: rgba(255, 255, 255, 0.1); + --glass-border: rgba(255, 255, 255, 0.2); + --text-primary: white; + --text-secondary: rgba(255, 255, 255, 0.9); + --text-muted: rgba(255, 255, 255, 0.7); + --live-gradient: linear-gradient(45deg, #ef4444, #dc2626); + --button-primary: linear-gradient(45deg, #3b82f6, #1d4ed8); + --shadow-light: 0 8px 32px rgba(0, 0, 0, 0.1); + --shadow-medium: 0 20px 40px rgba(0, 0, 0, 0.15); + --shadow-heavy: 0 20px 40px rgba(0, 0, 0, 0.3); + --border-radius: 16px; + --border-radius-small: 12px; + --border-radius-large: 20px; + --font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +/* 공통 컨테이너 */ +.app-container { + min-height: 100vh; + background: var(--primary-gradient); + font-family: var(--font-family); + color: var(--text-primary); +} + +/* 공통 글래스모피즘 스타일 */ +.glass-panel { + background: var(--glass-bg); + backdrop-filter: blur(10px); + border: 1px solid var(--glass-border); + border-radius: var(--border-radius); +} + +.glass-button { + background: var(--glass-bg); + color: var(--text-primary); + border: none; + padding: 0.75rem 1.5rem; + border-radius: var(--border-radius-small); + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; + text-decoration: none; + display: inline-flex; + align-items: center; + backdrop-filter: blur(10px); + border: 1px solid var(--glass-border); +} + +.glass-button:hover { + background: rgba(255, 255, 255, 0.3); + transform: translateY(-2px); + box-shadow: var(--shadow-light); +} + +/* 공통 로딩 스피너 */ +.loading-spinner { + width: 50px; + height: 50px; + border: 4px solid rgba(255, 255, 255, 0.3); + border-top: 4px solid white; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* 공통 라이브 배지 */ +.live-badge { + background: var(--live-gradient); + color: white; + padding: 0.5rem 1rem; + border-radius: var(--border-radius-large); + font-size: 0.9rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + animation: pulse-live 2s infinite; + box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3); +} + +@keyframes pulse-live { + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0.8; } +} + +/* 공통 버튼 스타일 */ +.btn-primary { + background: var(--button-primary); + color: white; + border: none; + padding: 0.75rem 2rem; + border-radius: var(--border-radius-small); + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 8px 16px rgba(59, 130, 246, 0.3); +} + +.btn-secondary { + background: var(--glass-bg); + color: var(--text-primary); + border: 1px solid var(--glass-border); + padding: 0.75rem 2rem; + border-radius: var(--border-radius-small); + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(10px); +} + +.btn-secondary:hover { + background: rgba(255, 255, 255, 0.3); + transform: translateY(-2px); +} + +/* 공통 타이포그래피 */ +.title-gradient { + background: linear-gradient(45deg, #fff, #e0e7ff); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); +} + +.text-large { + font-size: 3rem; + font-weight: 700; + line-height: 1.2; +} + +.text-medium { + font-size: 2rem; + font-weight: 600; + line-height: 1.3; +} + +.text-normal { + font-size: 1.2rem; + font-weight: 400; + line-height: 1.5; +} + +/* 공통 에러 스타일 */ +.error-container { + text-align: center; + padding: 3rem; + background: var(--glass-bg); + border-radius: var(--border-radius); + backdrop-filter: blur(10px); + border: 1px solid var(--glass-border); +} + +.error-container h2, +.error-container h3 { + color: #fca5a5; + margin-bottom: 1rem; +} + +.error-container p { + margin-bottom: 2rem; + font-size: 1.1rem; + opacity: 0.9; + line-height: 1.6; +} + +/* 공통 그리드 */ +.grid-responsive { + display: grid; + gap: 2rem; +} + +.grid-1 { grid-template-columns: 1fr; } +.grid-2 { grid-template-columns: repeat(2, 1fr); } +.grid-3 { grid-template-columns: repeat(3, 1fr); } +.grid-auto { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); } + +/* 공통 반응형 */ +@media (max-width: 1024px) { + .grid-3 { grid-template-columns: repeat(2, 1fr); } + .grid-2 { grid-template-columns: 1fr; } +} + +@media (max-width: 768px) { + .text-large { font-size: 2.5rem; } + .text-medium { font-size: 1.8rem; } + .grid-responsive { gap: 1.5rem; } + .grid-auto { grid-template-columns: 1fr; } +} + +@media (max-width: 480px) { + .text-large { font-size: 2rem; } + .text-medium { font-size: 1.5rem; } + .text-normal { font-size: 1rem; } + .grid-responsive { gap: 1rem; } +} + +/* 접근성 */ +@media (prefers-reduced-motion: reduce) { + .loading-spinner, + .live-badge { + animation: none; + } + + .glass-button:hover, + .btn-primary:hover, + .btn-secondary:hover { + transform: none; + } +} + +/* 포커스 스타일 */ +.glass-button:focus, +.btn-primary:focus, +.btn-secondary:focus { + outline: 3px solid #60a5fa; + outline-offset: 2px; +} + +/* 다크 모드 지원 */ +@media (prefers-color-scheme: dark) { + :root { + --glass-bg: rgba(255, 255, 255, 0.05); + --glass-border: rgba(255, 255, 255, 0.1); + } +} \ No newline at end of file diff --git a/main.go b/main.go index 4bbddec..198ff81 100644 --- a/main.go +++ b/main.go @@ -113,7 +113,10 @@ func main() { api.GET("/assets/*", func(c echo.Context) error { return c.File("front/dist" + c.Request().URL.Path) }) - api.GET("/", func(c echo.Context) error { + + // SPA fallback - serve index.html for all unmatched routes + // This must be registered LAST to act as a catch-all + api.GET("/*", func(c echo.Context) error { return c.File("front/dist/index.html") })