diff --git a/app/package.json b/app/package.json index 9125df9..0b66e3f 100644 --- a/app/package.json +++ b/app/package.json @@ -11,6 +11,7 @@ "@til-alarm/shared": "workspace:*", "axios": "^1.12.2", "firebase": "^10.4.0", + "github-markdown-css": "^5.8.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-indexed-db-hook": "^1.0.14", diff --git a/app/src/index.css b/app/src/index.css index 4cc15ae..216d439 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -377,71 +377,8 @@ button.primary, width: 1px; } -.card-player { - padding-top: 20px; -} - -.card-player .slick-list { - z-index: 1; -} - -.card-player .flashcard { - display: flex !important; - align-items: center; - justify-content: center; - font-size: 2rem; - word-break: break-all; - text-align: left; - height: 78vh !important; - margin: 20px; - border : 2px solid black; - border-radius: 20px; - overflow: auto; - transform-style: preserve-3d; - transition: transform 0.4s; - padding: 1rem; -} - -.card-player .flashcard.flipped { - align-items: stretch; - justify-content: stretch; - transform: rotateY(360deg); - overflow: auto; -} - +/* FlashCard 스타일은 FlashCardViewer.css로 이동됨 */ .slick-slide>div { display: flex; justify-content: center; } - -.card-player .button-wrapper { - margin-top: auto; - display: flex; - justify-content: center; -} - -/* button */ -.button-circle { - background-color: #00A6ED; - color: #ffffff; - font-size: 2rem; - border: none; - border-radius: 2rem; - cursor: pointer; - transition: background-color 0.3s ease; - flex-grow: 1; - margin: 1rem; -} - -.button-oval { - background-color: #00A6ED; - color: #ffffff; - font-size: 2rem; - border: none; - border-radius: 2rem; - cursor: pointer; - transition: background-color 0.3s ease; - flex-grow: 2; - margin-top: 1rem; - margin-bottom: 1rem; -} diff --git a/app/src/markdown.css b/app/src/markdown.css deleted file mode 100644 index 47deaf0..0000000 --- a/app/src/markdown.css +++ /dev/null @@ -1,14 +0,0 @@ -.markdown-table { - font-size: 10px; -} - -.markdown-table table { - width: 100%; - border: 1px solid #ddd; /* 원하는 테두리 스타일 및 색상을 지정 */ -} - -.markdown-table th, .markdown-table td { - border: 1px solid #ddd; /* 테이블 셀의 테두리 스타일 및 색상을 지정 */ - padding: 8px; - text-align: left; -} diff --git a/app/src/pages/FlashCardViewer.css b/app/src/pages/FlashCardViewer.css new file mode 100644 index 0000000..ef86ba7 --- /dev/null +++ b/app/src/pages/FlashCardViewer.css @@ -0,0 +1,484 @@ +.flashcard-container { + min-height: 100vh; + display: flex; + flex-direction: column; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + padding: 20px; + position: relative; + overflow: hidden; +} + +/* 배경 애니메이션 효과 */ +.flashcard-container::before { + content: ''; + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 1px, transparent 1px); + background-size: 50px 50px; + animation: float-bg 20s linear infinite; + pointer-events: none; +} + +@keyframes float-bg { + 0% { + transform: translate(0, 0); + } + 100% { + transform: translate(50px, 50px); + } +} + +.card-player { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + max-width: 1200px; + margin: 0 auto; + width: 100%; + position: relative; + z-index: 1; +} + +.card-player .slick-list { + z-index: 1; + overflow: visible; +} + +.card-player .slick-track { + display: flex; + align-items: center; +} + +/* 플래시카드 스타일 */ +.card-player .flashcard { + display: flex !important; + align-items: center; + justify-content: center; + font-size: 1.5rem; + text-align: center; + height: 65vh !important; + max-height: 600px; + margin: 20px; + background: white; + border: none; + border-radius: 24px; + overflow: auto; + transition: all 0.6s cubic-bezier(0.68, -0.55, 0.265, 1.55); + padding: 2.5rem; + box-shadow: + 0 20px 60px rgba(0, 0, 0, 0.3), + 0 0 0 1px rgba(255, 255, 255, 0.1); + position: relative; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.card-player .flashcard::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 24px; + padding: 2px; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.1)); + mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + -webkit-mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + mask-composite: exclude; + -webkit-mask-composite: xor; + pointer-events: none; +} + +/* 질문 상태 (앞면) */ +.card-player .flashcard:not(.flipped) p { + font-size: 1.8rem; + font-weight: 600; + color: #2d3748; + line-height: 1.6; + padding: 20px; + word-break: keep-all; + overflow-wrap: break-word; +} + +/* 답변 상태 (뒤면) */ +.card-player .flashcard.flipped { + align-items: flex-start; + justify-content: flex-start; + text-align: left; + box-shadow: + 0 25px 70px rgba(0, 0, 0, 0.4), + 0 0 0 1px rgba(118, 75, 162, 0.2); + padding: 0; + overflow: hidden; + animation: flip-in 0.3s ease-out; +} + +@keyframes flip-in { + 0% { + opacity: 0.7; + transform: scale(0.98); + } + 100% { + opacity: 1; + transform: scale(1); + } +} + +/* 뒤집힌 카드 내부의 마크다운/코드 diff 콘텐츠 */ +.card-player .flashcard.flipped .markdown-body { + width: 100%; + height: 100%; + overflow-y: auto; + padding: 2rem; + box-sizing: border-box; + background: white; + text-align: left; +} + +/* GitHub diff 컨테이너 스타일 */ +.github-diff-container { + margin: 12px 0; + border: 1px solid #d0d7de; + border-radius: 6px; + overflow: hidden; +} + +.github-diff-container pre { + margin: 0 !important; +} + +/* 슬라이더 점(dots) 커스터마이징 */ +.card-player .slick-dots { + position: relative; + bottom: -30px; +} + +.card-player .slick-dots li { + margin: 0 8px; +} + +.card-player .slick-dots li button:before { + font-size: 12px; + color: rgba(255, 255, 255, 0.5); + transition: all 0.3s ease; +} + +.card-player .slick-dots li.slick-active button:before { + color: white; + transform: scale(1.3); +} + +.card-player .slick-dots li button:hover:before { + color: rgba(255, 255, 255, 0.8); +} + +/* 버튼 래퍼 */ +.card-player .button-wrapper { + margin-top: 60px; + display: flex; + justify-content: center; + align-items: center; + gap: 16px; + padding: 0 20px; +} + +/* 버튼 공통 스타일 */ +.button-circle, +.button-oval { + background: white; + color: #667eea; + font-size: 1.8rem; + border: none; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55); + box-shadow: + 0 8px 20px rgba(0, 0, 0, 0.2), + 0 0 0 1px rgba(255, 255, 255, 0.1); + position: relative; + overflow: hidden; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; +} + +/* 버튼 호버 효과 */ +.button-circle::before, +.button-oval::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + border-radius: 50%; + background: rgba(102, 126, 234, 0.1); + transform: translate(-50%, -50%); + transition: width 0.6s, height 0.6s; +} + +.button-circle:hover::before, +.button-oval:hover::before { + width: 300px; + height: 300px; +} + +.button-circle:hover, +.button-oval:hover { + transform: translateY(-4px) scale(1.05); + box-shadow: + 0 12px 30px rgba(102, 126, 234, 0.4), + 0 0 0 1px rgba(102, 126, 234, 0.2); +} + +.button-circle:active, +.button-oval:active { + transform: translateY(-2px) scale(1.02); +} + +/* 원형 버튼 (이전/다음) */ +.button-circle { + width: 70px; + height: 70px; + border-radius: 50%; + flex-shrink: 0; +} + +/* 타원형 버튼 (뒤집기) */ +.button-oval { + min-width: 180px; + height: 70px; + border-radius: 35px; + padding: 0 32px; + flex-grow: 1; + max-width: 300px; + font-size: 1.5rem; +} + +/* 버튼 비활성화 상태 */ +.button-circle:disabled, +.button-oval:disabled { + opacity: 0.4; + cursor: not-allowed; + transform: none; +} + +.button-circle:disabled:hover, +.button-oval:disabled:hover { + transform: none; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2); +} + +/* 진행 상황 표시 */ +.progress-indicator { + position: absolute; + top: 30px; + left: 50%; + transform: translateX(-50%); + background: rgba(255, 255, 255, 0.95); + padding: 12px 24px; + border-radius: 50px; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15); + font-size: 1rem; + font-weight: 600; + color: #667eea; + z-index: 10; + backdrop-filter: blur(10px); + animation: slide-down 0.5s ease-out; +} + +/* 키보드 단축키 힌트 */ +.keyboard-hint { + position: absolute; + bottom: 30px; + left: 50%; + transform: translateX(-50%); + background: rgba(255, 255, 255, 0.9); + padding: 10px 20px; + border-radius: 20px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + font-size: 0.85rem; + color: #718096; + z-index: 10; + backdrop-filter: blur(10px); + display: flex; + gap: 16px; + align-items: center; + animation: fade-in 0.8s ease-out; +} + +.keyboard-hint-item { + display: flex; + align-items: center; + gap: 6px; +} + +.keyboard-hint kbd { + background: #edf2f7; + border: 1px solid #cbd5e0; + border-radius: 4px; + padding: 2px 8px; + font-family: 'Consolas', 'Monaco', monospace; + font-size: 0.75rem; + color: #2d3748; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +@keyframes slide-down { + from { + opacity: 0; + transform: translateX(-50%) translateY(-20px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} + +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/* 스크롤바 커스터마이징 */ +.card-player .flashcard::-webkit-scrollbar { + width: 8px; +} + +.card-player .flashcard::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.05); + border-radius: 10px; +} + +.card-player .flashcard::-webkit-scrollbar-thumb { + background: rgba(102, 126, 234, 0.3); + border-radius: 10px; +} + +.card-player .flashcard::-webkit-scrollbar-thumb:hover { + background: rgba(102, 126, 234, 0.5); +} + +/* 반응형 디자인 */ +@media (max-width: 768px) { + .flashcard-container { + padding: 10px; + } + + .card-player .flashcard { + height: 60vh !important; + margin: 10px; + padding: 1.5rem; + font-size: 1.2rem; + border-radius: 20px; + } + + .card-player .flashcard:not(.flipped) p { + font-size: 1.4rem; + padding: 10px; + } + + .button-circle, + .button-oval { + font-size: 1.5rem; + } + + .button-circle { + width: 60px; + height: 60px; + } + + .button-oval { + min-width: 140px; + height: 60px; + padding: 0 24px; + } + + .card-player .button-wrapper { + margin-top: 40px; + gap: 12px; + padding: 0 10px; + } + + .progress-indicator { + top: 20px; + padding: 10px 20px; + font-size: 0.9rem; + } + + /* 모바일에서는 키보드 힌트 숨김 */ + .keyboard-hint { + display: none; + } + + /* 모바일 마크다운 스타일 조정 */ + .card-player .flashcard.flipped .markdown-body { + padding: 1.5rem; + font-size: 0.95rem; + } +} + +@media (max-width: 480px) { + .card-player .flashcard { + height: 55vh !important; + margin: 5px; + padding: 1rem; + font-size: 1rem; + border-radius: 16px; + } + + .card-player .flashcard:not(.flipped) p { + font-size: 1.2rem; + } + + .button-circle { + width: 55px; + height: 55px; + font-size: 1.3rem; + } + + .button-oval { + min-width: 120px; + height: 55px; + font-size: 1.3rem; + padding: 0 20px; + } + + .card-player .button-wrapper { + gap: 10px; + } + + /* 작은 모바일 마크다운 스타일 조정 */ + .card-player .flashcard.flipped .markdown-body { + padding: 1rem; + font-size: 0.9rem; + } +} + +/* 로딩 애니메이션 */ +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.flashcard.loading { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + diff --git a/app/src/pages/FlashCardViewer.tsx b/app/src/pages/FlashCardViewer.tsx index 7d888b6..5731e7a 100644 --- a/app/src/pages/FlashCardViewer.tsx +++ b/app/src/pages/FlashCardViewer.tsx @@ -3,6 +3,7 @@ import Slider from "react-slick"; import "slick-carousel/slick/slick.css"; import "slick-carousel/slick/slick-theme.css"; +import "./FlashCardViewer.css"; import MarkdownBlock from '../templates/MarkdownBlock'; import CodeDiffBlock from '../templates/CodeDiffBlock'; @@ -27,6 +28,7 @@ interface DBData { const FlashCardViewer: React.FC = () => { const [cards, setCards] = useState([]); const [flipped, setFlipped] = useState(false); + const [currentSlide, setCurrentSlide] = useState(0); let sliderRef = useRef(null); @@ -94,55 +96,124 @@ const FlashCardViewer: React.FC = () => { return () => clearInterval(interval); }, [getAll]); + // 키보드 단축키 추가 + useEffect(() => { + const handleKeyPress = (e: KeyboardEvent) => { + // 입력 필드에서는 작동하지 않도록 + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + return; + } + + switch(e.key) { + case 'ArrowLeft': + if (currentSlide > 0) { + previous(); + } + break; + case 'ArrowRight': + if (currentSlide < cards.length - 1) { + next(); + } + break; + case 'ArrowUp': + case 'ArrowDown': + case ' ': + e.preventDefault(); + flipCard(); + break; + } + }; + + window.addEventListener('keydown', handleKeyPress); + return () => window.removeEventListener('keydown', handleKeyPress); + }, [currentSlide, cards.length, flipped]); + // 이제 App.tsx에서 로딩과 데이터 없음을 처리하므로 여기서는 빈 배열일 때만 처리 if (cards.length === 0) { return null; // App.tsx에서 처리됨 } return ( -
-
- { - (sliderRef as any).current = slider; - }} - dots={true} - arrows={false} - swipe={false} - infinite={false} - appendDots={(dots) => ( -
-
    {dots}
-
- )} - > - {cards.map((card, i) => { - const contentType = card.contentType || 'markdown'; - - return ( -
- {flipped ? ( - contentType === 'code-diff' ? ( - - ) : ( - - ) - ) : ( -

{card.question}

- )} -
- ); - })} -
+
+
+ {currentSlide + 1} / {cards.length} +
+ +
+
+ + + 이동 +
+
+ Space + 뒤집기 +
-
- - - + +
+
+ { + (sliderRef as any).current = slider; + }} + dots={true} + arrows={false} + swipe={false} + infinite={false} + beforeChange={(_, next) => { + setCurrentSlide(next); + }} + appendDots={(dots) => ( +
+
    {dots}
+
+ )} + > + {cards.map((card, i) => { + const contentType = card.contentType || 'markdown'; + + return ( +
+ {flipped && currentSlide === i ? ( + contentType === 'code-diff' ? ( + + ) : ( + + ) + ) : ( +

{card.question}

+ )} +
+ ); + })} +
+
+
+ + + +
); diff --git a/app/src/templates/CodeDiffBlock.tsx b/app/src/templates/CodeDiffBlock.tsx index b27c8fc..cb5c88e 100644 --- a/app/src/templates/CodeDiffBlock.tsx +++ b/app/src/templates/CodeDiffBlock.tsx @@ -1,8 +1,9 @@ import React from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; -import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; -import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import { Light as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { github } from 'react-syntax-highlighter/dist/esm/styles/hljs'; +import 'github-markdown-css/github-markdown.css'; interface CodeDiffBlockProps { diffContent: string; @@ -10,7 +11,7 @@ interface CodeDiffBlockProps { const CodeDiffBlock: React.FC = ({ diffContent }) => { return ( -
+
= ({ diffContent }) => { return (
= ({ diffContent }) => { // 일반 코드 블록 return ( = ({ diffContent }) => { {diffContent} -
); }; diff --git a/app/src/templates/MarkdownBlock.tsx b/app/src/templates/MarkdownBlock.tsx index e129084..48071fa 100644 --- a/app/src/templates/MarkdownBlock.tsx +++ b/app/src/templates/MarkdownBlock.tsx @@ -1,8 +1,9 @@ import React from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; -import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; -import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import { Light as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { github } from 'react-syntax-highlighter/dist/esm/styles/hljs'; +import 'github-markdown-css/github-markdown.css'; interface MarkdownBlockProps { markdown: string; @@ -10,26 +11,29 @@ interface MarkdownBlockProps { const MarkdownBlock: React.FC = ({ markdown }) => { return ( -
+
- {String(children).replace(/\n$/, '')} - - ) : ( - - {children} - - ); + if (match) { + return ( + + ); + } else { + return ( + + {children} + + ); + } }, }} > diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4c3899..f4bace4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: firebase: specifier: ^10.4.0 version: 10.14.1 + github-markdown-css: + specifier: ^5.8.1 + version: 5.8.1 react: specifier: ^18.2.0 version: 18.3.1 @@ -2147,6 +2150,10 @@ packages: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} + github-markdown-css@5.8.1: + resolution: {integrity: sha512-8G+PFvqigBQSWLQjyzgpa2ThD9bo7+kDsriUIidGcRhXgmcaAWUIpCZf8DavJgc+xifjbCG+GvMyWr0XMXmc7g==} + engines: {node: '>=10'} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -6172,6 +6179,8 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 + github-markdown-css@5.8.1: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3