diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 9349998..d7fbadb 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,18 +1,34 @@ -## JIRA 이슈 키 - +## 🔑 JIRA 이슈 키 + +--- -## JIRA 이슈 키 +## ✒ 진행한 작업 +- [x] 진행한 작업 +- [x] 진행한 작업 +- [x] 진행한 작업 + +--- + +## 💡 생겼던 문제 및 해결법 1️⃣ 요약 -문제: -해결: +- 문제: +- 해결: + +--- +## 📢 아쉬운 부분 및 개선점 +- ... -## 스크린샷 +--- +## 📚 개발에 참고한 자료 및 포인트 +- 참고한 부분 요약 및 포인트 + - link -## 아쉬운 부분 및 개선점 +--- +## 📸 스크린샷 +- ... -## 개발에 참고한 자료 및 포인트 - \ No newline at end of file +--- \ No newline at end of file diff --git a/app/api/deepgram/transcribe/route.ts b/app/api/deepgram/transcribe/route.ts new file mode 100644 index 0000000..e118e2d --- /dev/null +++ b/app/api/deepgram/transcribe/route.ts @@ -0,0 +1,63 @@ +import { createClient } from '@deepgram/sdk'; +import { NextRequest, NextResponse } from 'next/server'; + +export const POST = async (req: NextRequest) => { + try { + const deepgram = createClient(process.env.DEEPGRAM_API_KEY!); + + // JSON으로 Base64 보내는 경우 + const contentType = req.headers.get('content-type') || ''; + let audioBuffer: Buffer | null = null; + + if (contentType.includes('application/json')) { + const { audioBase64 } = await req.json(); + if (!audioBase64) { + return NextResponse.json( + { error: 'No audioBase64 provided' }, + { status: 400 }, + ); + } + audioBuffer = Buffer.from(audioBase64, 'base64'); + } + // FormData로 보내는 경우 + else if (contentType.includes('multipart/form-data')) { + const formData = await req.formData(); + const audioFile = formData.get('file') as File; + if (!audioFile) { + return NextResponse.json( + { error: 'No audio file provided' }, + { status: 400 }, + ); + } + const arrayBuffer = await audioFile.arrayBuffer(); + audioBuffer = Buffer.from(arrayBuffer); + } else { + return NextResponse.json( + { error: 'Unsupported Content-Type' }, + { status: 400 }, + ); + } + + // Deepgram STT + const { result } = await deepgram.listen.prerecorded.transcribeFile( + audioBuffer, + { + model: 'nova-2', + language: 'ko', + smart_format: true, + }, + ); + + // 안전하게 transcript 추출 + const transcript = + result?.results?.channels?.[0]?.alternatives?.[0]?.transcript || ''; + + return NextResponse.json({ transcript }); + } catch (err) { + console.error('STT Error:', err); + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Unknown error' }, + { status: 500 }, + ); + } +}; diff --git a/app/favicon.ico b/app/favicon.ico index 718d6fe..c17f1e0 100644 Binary files a/app/favicon.ico and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css index a2dc41e..51874cf 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,15 +1,17 @@ @import "tailwindcss"; :root { + --font-gothic-a1: 'Gothic A1', sans-serif; + --font-mplus-2: 'M PLUS 2', sans-serif; + --font-noto-sc: 'Noto Sans SC',sans-serif; + --font-sans: var(--font-gothic-a1), var(--font-mplus-2), var(--font-noto-sc), system-ui, sans-serif; + --background: #ffffff; --foreground: #171717; -} -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); + .scrollbar-hide::-webkit-scrollbar { + display: none; +} } @media (prefers-color-scheme: dark) { @@ -19,8 +21,170 @@ } } +@theme inline { + /* 폰트 */ + --font-sans: var(--font-sans); + --font-gothic: var(--font-gothic-a1); + --font-mplus: var(--font-mplus-2); + --font-noto: var(--font-noto-sc); + + /* 컬러 */ + --color-background: var(--background); + --color-foreground: var(--foreground); + + --color-primary: #F7DD4A; + --color-primary-900: #FEF9DD; + --color-primary-800: #FBEFAC; + --color-primary-dimensional: #F6B831; + + --color-secondary: #BFE2F3; + --color-secondary-400: #248EC1; + --color-secondary-300: #1C6E96; + + --color-bg-solid: #E6F4FA; + + --color-error: #F74A4A; + --color-error-dimensional: #D51111; + + --color-black: #1B1B1B; + --color-black-70: rgba(27, 27, 27, 0.7); + + --color-gray-300: #4F4F4F; + --color-gray-500: #828282; + --color-gray-700: #B5B5B5; + --color-gray-900: #E8E8E8; + --color-gray-950: #F2F2F2; + + --color-white: #FEFEFE; + --color-white-70: rgba(254, 254, 254, 0.7); + + /* H1 Bold */ + --text-h1-bold: 24px; + --text-h1-bold--line-height: 160%; + --text-h1-bold--font-weight: 700; + --text-h1-bold--font-family: var(--font-gothic-a1); + + /* H1 Regular */ + --text-h1-regular: 24px; + --text-h1-regular--line-height: 160%; + --text-h1-regular--font-weight: 400; + --text-h1-regular--font-family: var(--font-gothic-a1); + + /* H2 Bold */ + --text-h2-bold: 20px; + --text-h2-bold--line-height: 160%; + --text-h2-bold--font-weight: 700; + --text-h2-bold--font-family: var(--font-gothic-a1); + + /* H2 Regular */ + --text-h2-regular: 20px; + --text-h2-regular--line-height: 160%; + --text-h2-regular--font-weight: 400; + --text-h2-regular--font-family: var(--font-gothic-a1); + + /* H3 Bold */ + --text-h3-bold: 18px; + --text-h3-bold--line-height: 160%; + --text-h3-bold--font-weight: 700; + --text-h3-bold--font-family: var(--font-gothic-a1); + + /* H3 Regular */ + --text-h3-regular: 18px; + --text-h3-regular--line-height: 160%; + --text-h3-regular--font-weight: 400; + --text-h3-regular--font-family: var(--font-gothic-a1); + + /* Body1 Bold */ + --text-bd1-bold: 16px; + --text-bd1-bold--line-height: 160%; + --text-bd1-bold--font-weight: 700; + --text-bd1-bold--font-family: var(--font-gothic-a1); + + /* Body1 Regular */ + --text-bd1-regular: 16px; + --text-bd1-regular--line-height: 160%; + --text-bd1-regular--font-weight: 400; + --text-bd1-regular--font-family: var(--font-gothic-a1); + + /* Body2 Bold */ + --text-bd2-bold: 14px; + --text-bd2-bold--line-height: 160%; + --text-bd2-bold--font-weight: 700; + --text-bd2-bold--font-family: var(--font-gothic-a1); + + /* Body2 Regular */ + --text-bd2-regular: 14px; + --text-bd2-regular--line-height: 160%; + --text-bd2-regular--font-weight: 400; + --text-bd2-regular--font-family: var(--font-gothic-a1); + + /* Caption1 Bold */ + --text-cp1-bold: 12px; + --text-cp1-bold--line-height: 160%; + --text-cp1-bold--font-weight: 700; + --text-cp1-bold--font-family: var(--font-gothic-a1); + + /* Caption1 Regular */ + --text-cp1-regular: 12px; + --text-cp1-regular--line-height: 160%; + --text-cp1-regular--font-weight: 400; + --text-cp1-regular--font-family: var(--font-gothic-a1); + + /* Caption2 Bold */ + --text-cp2-bold: 10px; + --text-cp2-bold--line-height: 160%; + --text-cp2-bold--font-weight: 700; + --text-cp2-bold--font-family: var(--font-gothic-a1); + + /* Caption2 Regular */ + --text-cp2-regular: 10px; + --text-cp2-regular--line-height: 160%; + --text-cp2-regular--font-weight: 400; + --text-cp2-regular--font-family: var(--font-gothic-a1); + + /* Translate Caption1 Regular */ + --text-trans-cp1-regular: 14px; + --text-cp1-regular--line-height: 160%; + --text-cp1-regular--font-weight: 400; + --text-cp1-regular--font-family: var(--font-mplus-2); + + /* Translate Caption1 Regular */ + --text-trans-cp1-regular: 14px; + --text-trans-cp1-regular--line-height: 160%; + --text-trans-cp1-regular--font-weight: 400; + --text-trans-cp1-regular--font-family: var(--font-mplus-2); + + /* Translate Caption2 Regular */ + --text-trans-cp2-regular: 12px; + --text-trans-cp2-regular--line-height: 120%; + --text-trans-cp2-regular--font-weight: 400; + --text-trans-cp2-regular--font-family: var(--font-mplus-2); + + /* Translate Caption3 Regular */ + --text-trans-cp3-regular: 10px; + --text-trans-cp3-regular--line-height: 120%; + --text-trans-cp3-regular--font-weight: 400; + --text-trans-cp3-regular--font-family: var(--font-mplus-2); +} + +@layer utilities { + /* 그라디언트 */ + .bg-gradient { + background-image: linear-gradient(to bottom, #FEFEFE, #E6F4FA); + } + .bg-gradient-reverse { + background-image: linear-gradient(to bottom, #E6F4FA, #FEFEFE); + } + /* 스크롤바 숨기기 */ + .scrollbar-hide { + &::-webkit-scrollbar { + display: none; + } + scrollbar-width: none; + } +} + body { background: var(--background); color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; } diff --git a/app/layout.tsx b/app/layout.tsx index 1e8468c..df4091c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,16 +1,24 @@ import type { Metadata } from 'next'; -import { Geist, Geist_Mono } from 'next/font/google'; +import { Gothic_A1, M_PLUS_2, Noto_Sans_SC } from 'next/font/google'; import './globals.css'; import { ReactNode } from 'react'; -const geistSans = Geist({ - variable: '--font-geist-sans', +const gothic = Gothic_A1({ + weight: ['400', '700'], subsets: ['latin'], + variable: '--font-gothic-a1', }); -const geistMono = Geist_Mono({ - variable: '--font-geist-mono', +const mplus = M_PLUS_2({ + weight: ['400'], + subsets: ['latin', 'vietnamese', 'latin-ext'], + variable: '--font-mplus-2', +}); + +const noto = Noto_Sans_SC({ + weight: ['400'], subsets: ['latin'], + variable: '--font-noto-sc', }); // TODO: 메타데이터 전체 수정 @@ -24,16 +32,13 @@ export const metadata: Metadata = { }, }; -export default function RootLayout({ - children, -}: Readonly<{ - children: ReactNode; -}>) { +export default function RootLayout({ children }: { children: ReactNode }) { return ( - - + + {children} diff --git a/app/main/_components/NavigationBar.tsx b/app/main/_components/NavigationBar.tsx new file mode 100644 index 0000000..67ed688 --- /dev/null +++ b/app/main/_components/NavigationBar.tsx @@ -0,0 +1,62 @@ +'use client'; + +import Image from 'next/image'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { ROUTES } from '@/constants/routes'; + +export default function NavigationBar() { + const pathname = usePathname(); + + const navItems = [ + { + label: '나의 학습', + href: ROUTES.MAIN.MY_LEARNING.ROOT, + icon: '/icons/nav-book.svg', + activeIcon: '/icons/nav-book-blue.svg', // active용 아이콘 + }, + { + label: '대화', + href: ROUTES.MAIN.CONVERSATION.ROOT, + icon: '/icons/nav-phone.svg', + activeIcon: '/icons/nav-phone-blue.svg', + }, + { + label: '마이페이지', + href: ROUTES.MAIN.MY_PAGE, + icon: '/icons/nav-user.svg', + activeIcon: '/icons/nav-user-blue.svg', + }, + ]; + + return ( + + ); +} diff --git a/app/main/conversation/_components/SwipeButton.tsx b/app/main/conversation/_components/SwipeButton.tsx new file mode 100644 index 0000000..ce2527c --- /dev/null +++ b/app/main/conversation/_components/SwipeButton.tsx @@ -0,0 +1,106 @@ +'use client'; + +import { ROUTES } from '@/constants/routes'; +import { + motion, + useAnimation, + useMotionValue, + useMotionValueEvent, +} from 'framer-motion'; +import Image from 'next/image'; +import { useEffect, useRef, useState } from 'react'; +import { usePathname } from 'next/navigation'; + +export default function SwipeButton() { + const pathname = usePathname(); + + const trackRef = useRef(null); + const knobRef = useRef(null); + + const controls = useAnimation(); + const x = useMotionValue(0); + + const [maxX, setMaxX] = useState(180); // fallback + const [completed, setCompleted] = useState(false); + + // 트랙과 노란원 실제 너비로 최대 이동거리 계산 + useEffect(() => { + const measure = () => { + const trackW = trackRef.current?.offsetWidth ?? 244; + const knobW = knobRef.current?.offsetWidth ?? 56; // w-14 = 56px + setMaxX(trackW - knobW); + }; + measure(); + window.addEventListener('resize', measure); + return () => window.removeEventListener('resize', measure); + }, []); + + // 라우트 바뀔 때/초기 마운트 때 항상 리셋 + useEffect(() => { + setCompleted(false); + x.set(0); + controls.set({ x: 0 }); + }, [pathname, controls, x]); + + // bfcache로 뒤로가기 복귀 시에도 리셋 + useEffect(() => { + const onPageShow = () => { + setCompleted(false); + x.set(0); + controls.set({ x: 0 }); + }; + window.addEventListener('pageshow', onPageShow); + return () => window.removeEventListener('pageshow', onPageShow); + }, [controls, x]); + + // x 변화 구독: 끝에 '도착'하면 트리거 + useMotionValueEvent(x, 'change', latest => { + if (!completed && latest >= maxX - 1) { + setCompleted(true); + window.location.href = ROUTES.MAIN.CONVERSATION.CALLING; + } + }); + + return ( +
+
+ {/* 회색 트랙 */} +
+ + 밀어서 시작하기 → + +
+ + {/* 노란 원 아이콘 */} + { + if (!completed) { + // 끝에 못 닿았으면 '툭!' 하고 복귀 + controls.start({ + x: 0, + transition: { duration: 0.15, ease: 'easeOut' }, + }); + } + }} + animate={controls} + className="absolute top-1/2 -translate-y-1/2 w-14 h-14 rounded-full bg-primary flex items-center justify-center cursor-pointer" + > + phone + +
+
+ ); +} diff --git a/app/main/conversation/_locales/text.json b/app/main/conversation/_locales/text.json new file mode 100644 index 0000000..c44133e --- /dev/null +++ b/app/main/conversation/_locales/text.json @@ -0,0 +1,35 @@ +{ + "conversation": { + "title": "코디에게 전화를 걸어\n대화할까요?", + "subText": { + "vt": "Chọn một ngôn ngữ!\nChúng tôi sẽ sử dụng nó để dịch.", + "en": "Lets call Kody and have a conversation!", + "jp": "言語を選択してください!\n翻訳に使用します。", + "chn": "选择一种语言!\n我们将用它进行翻译。" + }, + "phrase1": { + "id": 1, + "kor": "너는 취미가 뭐야?", + "vt": "Không có ngôn ngữ mà tôi muốn.", + "en": "What is your hobby?", + "jp": "私の欲しい言語がありません。", + "chn": "没有我想要的语言。" + }, + "phrase2": { + "id": 2, + "kor": "너는 이름이 뭐야?", + "vt": "Không có ngôn ngữ mà tôi muốn.", + "en": "What is your name?", + "jp": "あなたの名前は何ですか?", + "chn": "你叫什么名字?" + }, + "phrase3": { + "id": 3, + "kor": "가장 좋아하는게 뭐야?", + "vt": "Không có ngôn ngữ mà tôi muốn.", + "en": "What is your favorite thing?", + "jp": "あなたの名前は何ですか?", + "chn": "你叫什么名字?" + } + } +} \ No newline at end of file diff --git a/app/main/conversation/calling/layout.tsx b/app/main/conversation/calling/layout.tsx new file mode 100644 index 0000000..9d83ac7 --- /dev/null +++ b/app/main/conversation/calling/layout.tsx @@ -0,0 +1,12 @@ +import { ReactNode } from 'react'; + +export default function CallingLayout({ children }: { children: ReactNode }) { + return ( +
+
{children}
+
+ ); +} diff --git a/app/main/conversation/calling/page.tsx b/app/main/conversation/calling/page.tsx new file mode 100644 index 0000000..fc3dc31 --- /dev/null +++ b/app/main/conversation/calling/page.tsx @@ -0,0 +1,123 @@ +'use client'; + +import Image from 'next/image'; +import { useEffect, useState } from 'react'; +import { motion } from 'framer-motion'; +import { useRouter } from 'next/navigation'; +import { ROUTES } from '@/constants/routes'; +import { useCallStore } from '@/stores/callStore'; + +export default function CallingPage() { + const router = useRouter(); + const decrementCall = useCallStore(state => state.decrementCall); + const [showConnecting, setShowConnecting] = useState(true); + const [callTime, setCallTime] = useState(0); + + useEffect(() => { + const timer = setTimeout(() => { + setShowConnecting(false); + setCallTime(0); + }, 5000); + return () => clearTimeout(timer); + }, []); + + useEffect(() => { + let interval: number | undefined; + + if (!showConnecting) { + interval = window.setInterval(() => { + setCallTime(prev => prev + 1); + }, 1000); + } + + return () => { + if (interval !== undefined) { + window.clearInterval(interval); + } + }; + }, [showConnecting]); + + const formatTime = (seconds: number) => { + const m = Math.floor(seconds / 60) + .toString() + .padStart(1, '0'); + const s = (seconds % 60).toString().padStart(2, '0'); + return `${m}:${s}`; + }; + + const handleEndCall = () => { + decrementCall(); + router.replace(ROUTES.MAIN.CONVERSATION.ROOT); + }; + + return ( +
+ {showConnecting ? ( + // 연결 중 모션 + +

코디에게 연결 중...

+

Connecting the call to Kody...

+
+ ) : ( + <> + {/* 통화 시작 텍스트 모션 */} + +

{formatTime(callTime)}

+

코디

+

Kody

+
+ + {/* 캐릭터 번역 텍스트 모션 */} + +

+ 안녕! 오늘은 어떤 얘기할래? +

+
+

+ What do you want to talk about today? +

+
+ + )} + + {/* 이미지 영역 */} + + {'character + + {'call + +
+ ); +} diff --git a/app/main/conversation/page.tsx b/app/main/conversation/page.tsx new file mode 100644 index 0000000..3340f56 --- /dev/null +++ b/app/main/conversation/page.tsx @@ -0,0 +1,34 @@ +'use client'; + +import TitleText from '@/components/TitleText'; +import text from './_locales/text.json'; +import CharacterText from '@/components/CharacterText'; +import SwipeButton from './_components/SwipeButton'; +import MotionFadeIn from '@/components/_animations/MotionFadeIn'; +import { useCallStore } from '@/stores/callStore'; + +export default function ConversationPage() { + const { remainingCalls } = useCallStore(); + const { title, subText } = text.conversation; + return ( + +

+ 오늘 남은 통화 횟수: {remainingCalls} +

+ + +
+ +
+
+ ); +} diff --git a/app/main/layout.tsx b/app/main/layout.tsx new file mode 100644 index 0000000..d5ed944 --- /dev/null +++ b/app/main/layout.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { usePathname } from 'next/navigation'; +import { ReactNode } from 'react'; +import NavigationBar from './_components/NavigationBar'; + +interface MainLayoutProps { + children: ReactNode; +} + +export default function MainLayout({ children }: MainLayoutProps) { + const pathname = usePathname(); + + // 네비바를 보여줄 루트 경로 + const showNavbarRoots = [ + '/main/my-learning', + '/main/conversation', + '/main/my-page', + ]; + + // 네비바를 숨길 하위 경로 패턴 + const hideNavbarPaths = ['/main/my-learning/level-']; + + // show 조건: 루트 경로 포함 + 숨김 패턴 미포함 + const showNavbar = + showNavbarRoots.some(root => pathname === root) && + !hideNavbarPaths.some(pattern => pathname.startsWith(pattern)); + + return ( +
+
{children}
+ {showNavbar && } +
+ ); +} diff --git a/app/main/my-learning/[level]/ending/layout.tsx b/app/main/my-learning/[level]/ending/layout.tsx new file mode 100644 index 0000000..bdc6458 --- /dev/null +++ b/app/main/my-learning/[level]/ending/layout.tsx @@ -0,0 +1,13 @@ +import { ReactNode } from 'react'; + +export default function LevelCompleteLayout({ + children, +}: { + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/app/main/my-learning/[level]/ending/page.tsx b/app/main/my-learning/[level]/ending/page.tsx new file mode 100644 index 0000000..b389872 --- /dev/null +++ b/app/main/my-learning/[level]/ending/page.tsx @@ -0,0 +1,57 @@ +'use client'; + +import TitleText from '@/components/TitleText'; +import { useLanguageStore } from '@/stores/languageStore'; +import { useUserStore } from '@/stores/userStore'; +import { useRouter } from 'next/navigation'; +import { ROUTES } from '@/constants/routes'; +import Button from '@/components/buttons/_index'; +import MotionFadeIn from '@/components/_animations/MotionFadeIn'; +import CharacterText from '@/components/CharacterText'; +import { useLevelParam } from '@/hooks/useLevelParam'; + +export default function LevelCompletionPage() { + const router = useRouter(); + const { currentLanguage } = useLanguageStore(); + const levelParam = useLevelParam(); + + const handleBtnClick = () => { + router.push(ROUTES.MAIN.ROOT); + }; + + return ( + <> + + + Lv. {levelParam} + 완료! + + } + subText={{ + en: `Level ${levelParam} Done!`, + jp: `レベル ${levelParam} 完了!`, + vt: `Hoàn thành Level ${levelParam}!`, + chn: `第 ${levelParam} 关完成!`, + }} + lang={currentLanguage.code} + className="mt-26 mb-8" + /> + + + {/* 하단 버튼 */} +
+
+ + ); +} diff --git a/app/main/my-learning/[level]/intro1/layout.tsx b/app/main/my-learning/[level]/intro1/layout.tsx new file mode 100644 index 0000000..6ee3a39 --- /dev/null +++ b/app/main/my-learning/[level]/intro1/layout.tsx @@ -0,0 +1,15 @@ +import TopAppBar from '@/components/TopAppBar'; +import { ReactNode } from 'react'; + +export default function LevelIntro1Layout({ + children, +}: { + children: ReactNode; +}) { + return ( +
+ + {children} +
+ ); +} diff --git a/app/main/my-learning/[level]/intro1/page.tsx b/app/main/my-learning/[level]/intro1/page.tsx new file mode 100644 index 0000000..a2704c9 --- /dev/null +++ b/app/main/my-learning/[level]/intro1/page.tsx @@ -0,0 +1,64 @@ +'use client'; + +import MotionFadeIn from '@/components/_animations/MotionFadeIn'; +import ProgressBar from '@/components/ProgressBar'; +import TitleText from '@/components/TitleText'; +import { useLanguageStore } from '@/stores/languageStore'; +import CharacterLevelText from '../../_components/CharacterLevelText'; +import text from '../../_locales/text.json'; +import IntroPhrasesList from '../../_components/step1/IntroPhrasesList'; +import Button from '@/components/buttons/_index'; +import { useRouter } from 'next/navigation'; +import { ROUTES } from '@/constants/routes'; +import { useLevelParam } from '@/hooks/useLevelParam'; + +export default function LevelIntro1Page() { + const router = useRouter(); + const levelParam = useLevelParam(); + const { currentLanguage } = useLanguageStore(); + const { title, subText, characterText, characterSubText } = text.intro; + + // TODO: API 연결 + + const phrasesArray = Object.values(text.intro).filter( + // @ts-ignore + value => value?.id !== undefined, + ); + + const handleBtnCLick = () => { + router.push(ROUTES.MAIN.MY_LEARNING.getStep(levelParam, 'step1')); + }; + + return ( +
+ + {/* 텍스트 */} + + + + + {/* 캐릭터 텍스트 */} + + {/* 문장 리스트 */} + {/* @ts-ignore */} + + + {/* 하단 버튼 */} +
+
+
+ ); +} diff --git a/app/main/my-learning/[level]/intro2/layout.tsx b/app/main/my-learning/[level]/intro2/layout.tsx new file mode 100644 index 0000000..44e3003 --- /dev/null +++ b/app/main/my-learning/[level]/intro2/layout.tsx @@ -0,0 +1,15 @@ +import TopAppBar from '@/components/TopAppBar'; +import { ReactNode } from 'react'; + +export default function LevelIntro1Layout({ + children, +}: { + children: ReactNode; +}) { + return ( +
+ + {children} +
+ ); +} diff --git a/app/main/my-learning/[level]/intro2/page.tsx b/app/main/my-learning/[level]/intro2/page.tsx new file mode 100644 index 0000000..90f55b6 --- /dev/null +++ b/app/main/my-learning/[level]/intro2/page.tsx @@ -0,0 +1,63 @@ +'use client'; + +import MotionFadeIn from '@/components/_animations/MotionFadeIn'; +import ProgressBar from '@/components/ProgressBar'; +import TitleText from '@/components/TitleText'; +import { useLanguageStore } from '@/stores/languageStore'; +import CharacterLevelText from '../../_components/CharacterLevelText'; +import text from '../../_locales/text.json'; +import Button from '@/components/buttons/_index'; +import { useRouter } from 'next/navigation'; +import { ROUTES } from '@/constants/routes'; +import { useLevelParam } from '@/hooks/useLevelParam'; +import IntroPhrasesListStep2 from '../../_components/step2/IntroPhrasesListStep2'; + +export default function LevelIntro2Page() { + const router = useRouter(); + const levelParam = useLevelParam(); + const { currentLanguage } = useLanguageStore(); + const { title, subText, characterText, characterSubText } = text.intro2; + + // TODO: API 연결 + const phrasesArray = Object.values(text.intro2).filter( + // @ts-ignore + value => value?.id !== undefined, + ); + + const handleBtnCLick = () => { + router.push(ROUTES.MAIN.MY_LEARNING.getStep(levelParam, 'step2')); + }; + + return ( +
+ + {/* 텍스트 */} + + + + + {/* 캐릭터 텍스트 */} + + {/* 문장 리스트 */} + {/* @ts-ignore */} + + + {/* 하단 버튼 */} +
+
+
+ ); +} diff --git a/app/main/my-learning/[level]/step1/layout.tsx b/app/main/my-learning/[level]/step1/layout.tsx new file mode 100644 index 0000000..f3dc62d --- /dev/null +++ b/app/main/my-learning/[level]/step1/layout.tsx @@ -0,0 +1,15 @@ +import TopAppBar from '@/components/TopAppBar'; +import { ReactNode } from 'react'; + +export default function LevelStep1Layout({ + children, +}: { + children: ReactNode; +}) { + return ( +
+ + {children} +
+ ); +} diff --git a/app/main/my-learning/[level]/step1/page.tsx b/app/main/my-learning/[level]/step1/page.tsx new file mode 100644 index 0000000..c2a6aff --- /dev/null +++ b/app/main/my-learning/[level]/step1/page.tsx @@ -0,0 +1,227 @@ +'use client'; + +import MotionFadeIn from '@/components/_animations/MotionFadeIn'; +import ProgressBar from '@/components/ProgressBar'; +import { useLanguageStore } from '@/stores/languageStore'; +import text from '../../_locales/text.json'; +import Button from '@/components/buttons/_index'; +import { useRouter } from 'next/navigation'; +import { ROUTES } from '@/constants/routes'; +import { useLevelParam } from '@/hooks/useLevelParam'; +import PhrasePracticeText from '../../_components/step1/PhrasePracticeText'; +import { useState } from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; +import VoiceKeyboard from '@/components/buttons/VoiceKeyboard'; + +export default function LevelStep1Page() { + const router = useRouter(); + const levelParam = useLevelParam(); + const { currentLanguage } = useLanguageStore(); + const { title, subText } = text.step1; + + const [audioBlob, setAudioBlob] = useState(null); + const [inputType, setInputType] = useState<'mic' | 'keyboard'>('keyboard'); + + //TODO: API 연결 + const phrases = [ + { + phrase: '너는 취미가 뭐야?', + romanization: 'Neo nun / chui mi ga / muh ya', + translation: 'What is your hobby?', + }, + { + phrase: '오늘 날씨 어때?', + romanization: 'Oneul nalssi eottae?', + translation: 'How is the weather today?', + }, + { + phrase: '무슨 음식을 좋아해?', + romanization: 'Museun eumsigeul joahae?', + translation: 'What food do you like?', + }, + ]; + + // 상태 + const [currentIndex, setCurrentIndex] = useState(0); + const [inputText, setInputText] = useState(''); + const [showInput, setShowInput] = useState(false); + const [showEvaluation, setShowEvaluation] = useState(false); + const [resetKey, setResetKey] = useState(0); + + const handleComplete = () => { + if (inputText.trim() !== '') { + setShowEvaluation(true); + setShowInput(false); + } + }; + + const handleRetry = () => { + setInputText(''); + setShowInput(false); + setShowEvaluation(false); + setResetKey(prev => prev + 1); + }; + + const handleNext = () => { + if (currentIndex < phrases.length - 1) { + setCurrentIndex(prev => prev + 1); + setInputText(''); + setShowInput(false); + setShowEvaluation(false); + setResetKey(prev => prev + 1); + } else { + // 마지막 문장이면 step2로 이동 + router.push(ROUTES.MAIN.MY_LEARNING.getStep(levelParam, 'intro2')); + } + }; + + const handleVoiceSubmit = async (blob: Blob) => { + setAudioBlob(blob); + setShowInput(true); + + try { + const base64 = await blobToBase64(blob); + + const res = await fetch('/api/deepgram/transcribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ audioBase64: base64 }), + }); + + const data = await res.json(); + + console.log('STT Response:', data); + + if (data.transcript) { + setInputText(data.transcript); + console.log('Recognized Text:', data.transcript); + } else { + console.error('STT 실패:', data.error); + } + } catch (err) { + console.error('STT 에러:', err); + } finally { + setShowEvaluation(true); + } + }; + + // Blob → Base64 변환 + function blobToBase64(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => { + const result = reader.result as string; + // data:audio/mp4;base64,... 부분 제거 + const base64 = result.split(',')[1]; + resolve(base64); + }; + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + } + + return ( +
+ + + +

+ {title} +

+

+ {subText[currentLanguage.code]} +

+
+ + + + + + {!showInput && !showEvaluation && ( +
+ + { + if (mode === 'keyboard' && typeof data === 'string') { + setInputText(data); + setInputType('keyboard'); + setShowInput(true); + setShowEvaluation(true); + setAudioBlob(null); // 키보드 입력이면 오디오 초기화 + } + if (mode === 'mic' && data instanceof Blob) { + console.log('녹음된 오디오:', data); + setInputType('mic'); + handleVoiceSubmit(data); + } + }} + /> + +
+ )} + + +
+ {showEvaluation && ( + +
+
+ {/* 녹음된 오디오 재생 / 다운로드 */} + {/* {audioBlob && ( +
+
+ )} */} +
+ ); +} diff --git a/app/main/my-learning/[level]/step2/layout.tsx b/app/main/my-learning/[level]/step2/layout.tsx new file mode 100644 index 0000000..95c56e5 --- /dev/null +++ b/app/main/my-learning/[level]/step2/layout.tsx @@ -0,0 +1,15 @@ +import TopAppBar from '@/components/TopAppBar'; +import { ReactNode } from 'react'; + +export default function LevelStep2Layout({ + children, +}: { + children: ReactNode; +}) { + return ( +
+ + {children} +
+ ); +} diff --git a/app/main/my-learning/[level]/step2/page.tsx b/app/main/my-learning/[level]/step2/page.tsx new file mode 100644 index 0000000..789abc1 --- /dev/null +++ b/app/main/my-learning/[level]/step2/page.tsx @@ -0,0 +1,232 @@ +'use client'; + +import ProgressBar from '@/components/ProgressBar'; +import { useEffect, useRef, useState } from 'react'; +import VoiceKeyboard from '@/components/buttons/VoiceKeyboard'; +import ChatLesson from '../../_components/step2/chat/ChatLesson'; +import { Message } from '../../_components/step2/chat/ChatMessageList'; +import { useVoiceSubmit } from '@/hooks/useVoiceSubmit'; +import Button from '@/components/buttons/_index'; +import BottomSheet from '@/app/onboarding/signin/components/BottomSheet'; +import Image from 'next/image'; +import { useRouter } from 'next/navigation'; +import { ROUTES } from '@/constants/routes'; +import { useLevelParam } from '@/hooks/useLevelParam'; + +type QuestionItem = { + question: string; + questionTrans: string; + hint: string; + translation?: string; + correction?: string; + explanation?: string; + answer: string; +}; + +const questions: QuestionItem[] = [ + { + question: '안녕 너는 좋아하는 게 뭐야?', + questionTrans: 'What do you like?', + hint: '취미가 뭐야? 라고 말해 봐!', + translation: 'What is your hobby?', + correction: '안녕! 너는 취미가 뭐야?', + explanation: '문장 시작에 ~ 자연스러운 대화가 됩니다.', + answer: '그렇구나! 나도 이거 좋아해!', + }, + { + question: '너는 주말에 뭐 해?', + questionTrans: 'What do you do on weekends?', + hint: '주말에 뭐 해? 라고 말해 봐!', + translation: 'What do you do on weekends?', + correction: '너는 주말에 주로 뭐 해?', + explanation: '조금 더 자연스러운 질문으로 ~', + answer: '재밌었겠다!', + }, + { + question: '네가 제일 좋아하는 음식은 뭐야?', + questionTrans: 'What is your most favorite food?', + hint: '좋아하는 음식을 말해 봐!', + translation: 'What is your favorite food?', + correction: '네가 가장 좋아하는 음식은 뭐야?', + explanation: '"가장"이 더 자연스러운 표현입니다.', + answer: '맛있겠다!', + }, +]; + +export default function LevelStep2Page() { + const router = useRouter(); + const levelParam = useLevelParam(); + const { handleVoiceSubmit } = useVoiceSubmit(); + const [messages, setMessages] = useState([ + { + id: 'm1', + role: 'ai', + text: questions[0].question, + questionTrans: questions[0].questionTrans, + round: 1, + isQuestion: true, + }, + ]); + const [currentRound, setCurrentRound] = useState(1); + const [showSummaryModal, setShowSummaryModal] = useState(false); + + const handleUserSubmit = (text: string) => { + const newRound = currentRound; + const q = questions[newRound - 1]; + + setMessages(prev => [ + ...prev, + { + id: Date.now().toString() + '_sys', + role: 'system', + round: newRound, + feedback: { + userInput: text, + translation: q.translation ?? '(번역)', + correction: q.correction ?? '(올바른 한국어 예시)', + explanation: q.explanation ?? '(왜 맞고 틀린지 설명)', + }, + isQuestion: false, + }, + ]); + + setTimeout(() => { + setMessages(prev => [ + ...prev, + { + id: Date.now().toString() + '_ai', + role: 'ai', + text: q.answer ?? '그에 대한 간단한 답변', + round: newRound, + isQuestion: false, + }, + ]); + }, 2000); + + // 마지막 라운드 체크 + if (newRound === questions.length) { + setTimeout(() => { + setShowSummaryModal(true); + }, 5000); // 답변 보여주고 잠깐 후 모달 + } else { + const nextRound = newRound + 1; + setTimeout(() => { + setMessages(prev => [ + ...prev, + { + id: Date.now().toString() + '_next', + role: 'ai', + text: questions[nextRound - 1].question, + questionTrans: questions[nextRound - 1].questionTrans, + round: nextRound, + isQuestion: true, + }, + ]); + setCurrentRound(nextRound); + }, 4000); + } + }; + + // messages가 바뀔 때마다 스크롤 + const chatContainerRef = useRef(null); + + useEffect(() => { + const timeout = setTimeout(() => { + const container = chatContainerRef.current; + if (container) { + container.scrollTo({ + top: + container.scrollTop + + (container.scrollHeight - container.clientHeight) * 0.6, + behavior: 'smooth', + }); + } + }, 500); + + return () => clearTimeout(timeout); + }, [messages]); + + return ( +
+ + +
+
+ q.hint)} + /> +
+
+ +
+ { + if (mode === 'mic' && data instanceof Blob) { + const text = await handleVoiceSubmit(data); + if (text) handleUserSubmit(text); + } + if (mode === 'keyboard' && typeof data === 'string') { + handleUserSubmit(data); + } + }} + /> +
+ + setShowSummaryModal(false)} + title={'잘했어요! 오늘 대화를 요약했어요.'} + subText={"Good job! I summarized today's conversation."} + > +
+ {questions.map((q, i) => ( +
+
+ Flag +
+
+ {q.question} + + {q.translation} + +
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/app/main/my-learning/[level]/step3/layout.tsx b/app/main/my-learning/[level]/step3/layout.tsx new file mode 100644 index 0000000..d9988ec --- /dev/null +++ b/app/main/my-learning/[level]/step3/layout.tsx @@ -0,0 +1,15 @@ +import TopAppBar from '@/components/TopAppBar'; +import { ReactNode } from 'react'; + +export default function LevelStep3Layout({ + children, +}: { + children: ReactNode; +}) { + return ( +
+ + {children} +
+ ); +} diff --git a/app/main/my-learning/[level]/step3/page.tsx b/app/main/my-learning/[level]/step3/page.tsx new file mode 100644 index 0000000..7f25c63 --- /dev/null +++ b/app/main/my-learning/[level]/step3/page.tsx @@ -0,0 +1,140 @@ +'use client'; + +import MotionFadeIn from '@/components/_animations/MotionFadeIn'; +import ProgressBar from '@/components/ProgressBar'; +import TitleText from '@/components/TitleText'; +import { useLanguageStore } from '@/stores/languageStore'; +import text from '../../_locales/text.json'; +import IntroPhrasesList from '../../_components/step1/IntroPhrasesList'; +import Button from '@/components/buttons/_index'; +import { useRouter } from 'next/navigation'; +import { ROUTES } from '@/constants/routes'; +import { useLevelParam } from '@/hooks/useLevelParam'; +import Image from 'next/image'; +import BottomSheet from '../../_components/step3/BottomSheet'; +import SelectButton from '@/components/buttons/select'; +import { useState } from 'react'; + +export default function LevelStep3Page() { + const router = useRouter(); + const levelParam = useLevelParam(); + const { currentLanguage } = useLanguageStore(); + const { title, subText } = text.step3; + + const [isBottomSheetOpen, setIsBottomSheetOpen] = useState(false); + const [selectedDifficulty, setSelectedDifficulty] = useState( + null, + ); + + // TODO: API 연결 + const phrasesArray = Object.values(text.intro).filter( + // @ts-ignore + value => value?.id !== undefined, + ); + + const handleBtnCLick = () => { + setIsBottomSheetOpen(true); // BottomSheet 열기 + }; + + // 난이도 옵션 + const difficulties = [ + { + text: '쉬웠음', + subText: 'easy', + }, + { + text: '보통', + subText: 'normal', + }, + { + text: '어려웠음', + subText: 'hard', + }, + ]; + + return ( +
+ + {/* 텍스트 */} + + + + + {/* 문장 리스트 */} +
오늘의 표현
+
+ Today's expression +
+ {/* @ts-ignore */} + + + {/* 단어 리스트 */} +
오늘의 단어
+
+ Today's word +
+ +
+ {[1, 2, 3].map((item, idx) => ( +
+
+

단어{item}

+ {'bookmark +
+
+ 단어 뜻 {item} +
+
+ ))} +
+ + {/* 하단 버튼 */} +
+ ); +} diff --git a/app/main/my-learning/_components/CharacterFrontText.tsx b/app/main/my-learning/_components/CharacterFrontText.tsx new file mode 100644 index 0000000..de071c1 --- /dev/null +++ b/app/main/my-learning/_components/CharacterFrontText.tsx @@ -0,0 +1,52 @@ +import { FONT_CLASS } from '@/constants/languages'; +import { useLanguageStore } from '@/stores/languageStore'; +import Image from 'next/image'; + +interface CharacterTextProps { + title: string; + subtitle: string; + audio?: boolean; + image?: string; // 이미지 URL +} + +export default function CharacterFrontText({ + title, + subtitle, + audio = false, + image = '/character/default.webp', // 기본 이미지 URL +}: CharacterTextProps) { + const { currentLanguage } = useLanguageStore(); + + return ( +
+ {/* 캐릭터 이미지 */} + {image && ( +
+ character +
+ )} + {/* 텍스트 영역 */} +
+

+ {title} + {audio && ( + audio icon + )} +

+ {/* 구분선 */} +
+

+ {subtitle} +

+
+
+ ); +} diff --git a/app/main/my-learning/_components/CharacterLevelText.tsx b/app/main/my-learning/_components/CharacterLevelText.tsx new file mode 100644 index 0000000..5033f2f --- /dev/null +++ b/app/main/my-learning/_components/CharacterLevelText.tsx @@ -0,0 +1,56 @@ +import { FONT_CLASS } from '@/constants/languages'; +import { useLanguageStore } from '@/stores/languageStore'; +import Image from 'next/image'; + +interface CharacterTextProps { + title: string; + subtitle: string; + audio?: boolean; + image?: string; + className?: string; +} + +export default function CharacterLevelText({ + title, + subtitle, + audio = false, + image = '/character/default.webp', + className, +}: CharacterTextProps) { + const { currentLanguage } = useLanguageStore(); + + return ( +
+ {/* 캐릭터 이미지 */} + {image && ( +
+ character +
+ )} + {/* 텍스트 영역 */} +
+

+ {title} + {audio && ( + audio icon + )} +

+ {/* 구분선 */} +
+

+ {subtitle} +

+
+
+ ); +} diff --git a/app/main/my-learning/_components/roadmap/LevelButton.tsx b/app/main/my-learning/_components/roadmap/LevelButton.tsx new file mode 100644 index 0000000..e81d2a8 --- /dev/null +++ b/app/main/my-learning/_components/roadmap/LevelButton.tsx @@ -0,0 +1,122 @@ +import { MouseEventHandler, TouchEventHandler, useState } from 'react'; +import LevelModal from './LevelModal'; +import Toast from '@/components/Toast'; + +interface ButtonProps { + text: string; + subText?: string; + status: 'current' | 'complete' | 'locked'; + levelNum: number; + className?: string; +} + +export default function LevelButton({ + text, + subText, + levelNum, + status = 'locked', + className, +}: ButtonProps) { + const [isPressed, setIsPressed] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isToastOpen, setIsToastOpen] = useState(false); + + const handleTouchStart: TouchEventHandler = () => + setIsPressed(true); + const handleTouchEnd: TouchEventHandler = () => + setIsPressed(false); + + const handleClick: MouseEventHandler = e => { + setIsPressed(true); + if (status === 'locked') { + setIsToastOpen(true); + setIsPressed(false); + return; + } + // 1초 뒤에 모달 열기 + setTimeout(() => { + setIsModalOpen(true); + setIsPressed(false); + }, 300); + }; + + const statusStyles = { + current: { + bg: 'bg-primary', + shadowBg: 'bg-primary-dimensional', + levelBg: 'bg-white', + textColor: 'text-black', + subTextColor: 'text-gray-300', + levelTextColor: 'text-secondary-300', + }, + complete: { + bg: 'bg-white', + shadowBg: 'bg-primary', + levelBg: 'bg-primary-800', + textColor: 'text-black', + subTextColor: 'text-gray-500', + levelTextColor: 'text-secondary-300', + }, + locked: { + bg: 'bg-bg-solid', + shadowBg: 'bg-bg-solid', + levelBg: 'bg-white', + textColor: 'text-secondary-300', + subTextColor: 'text-secondary-400', + levelTextColor: 'text-secondary-300', + }, + }; + + const { bg, shadowBg, levelBg, textColor, subTextColor, levelTextColor } = + statusStyles[status]; + + return ( + <> +
+ {/* 그림자 */} +
+ + {/* 실제 버튼 */} + +
+ + {/* 모달 */} + {isModalOpen && ( + setIsModalOpen(false)} + /> + )} + {/* 토스트 */} + setIsToastOpen(false)} + /> + + ); +} diff --git a/app/main/my-learning/_components/roadmap/LevelModal.tsx b/app/main/my-learning/_components/roadmap/LevelModal.tsx new file mode 100644 index 0000000..954fbea --- /dev/null +++ b/app/main/my-learning/_components/roadmap/LevelModal.tsx @@ -0,0 +1,92 @@ +'use client'; + +import Button from '@/components/buttons/_index'; +import { motion, AnimatePresence } from 'framer-motion'; +import LevelModalList from './LevelModalList'; +import { useRouter } from 'next/navigation'; +import { ROUTES } from '@/constants/routes'; + +interface LevelModalProps { + levelNum: number; + title: string; + onClose: () => void; + showButton?: boolean; +} + +export default function LevelModal({ + levelNum, + title, + onClose, + showButton = false, +}: LevelModalProps) { + const router = useRouter(); + + // TODO: API 연동 + const levelGoals = [ + { + goal: '반갑게 인사하기', + sub: "Sharing each other's hobbies.", + }, + { + goal: '좋아하는 음식 말하기', + sub: 'Talk about your favorite foods.', + }, + { + goal: '하루 일과 소개하기', + sub: 'Introduce your daily routine.', + }, + ]; + + const handleStart = () => { + router.push(ROUTES.MAIN.MY_LEARNING.getStep(levelNum, 'intro1')); + }; + + return ( + + + e.stopPropagation()} // 내부 클릭 시 닫히지 않게 + > + {/* 상단 제목 */} +
+

+ Lv. {levelNum} +

+

{title}

+
+ {/* 리스트 */} +
+ {levelGoals.map((item, idx) => ( + + ))} +
+ {/* 버튼 */} + {showButton && ( + +
+ ); +} diff --git a/app/main/my-learning/_components/step1/IntroPhrasesList.tsx b/app/main/my-learning/_components/step1/IntroPhrasesList.tsx new file mode 100644 index 0000000..bc12bc2 --- /dev/null +++ b/app/main/my-learning/_components/step1/IntroPhrasesList.tsx @@ -0,0 +1,78 @@ +import { useState } from 'react'; +import Image from 'next/image'; +import { useLanguageStore } from '@/stores/languageStore'; +import { FONT_CLASS } from '@/constants/languages'; + +interface Phrase { + id: number; + kor: string; + [key: string]: any; +} + +interface IntroPhrasesListProps { + phrases: Phrase[]; + initialBookmarks?: Record; +} + +export default function IntroPhrasesList({ + phrases, + initialBookmarks = {}, +}: IntroPhrasesListProps) { + const { currentLanguage } = useLanguageStore(); + + // 북마크 상태를 phrase.id 기준으로 관리 + const [bookmarks, setBookmarks] = useState(() => { + const initState: Record = {}; + phrases.forEach(phrase => { + initState[phrase.id] = initialBookmarks[phrase.id] || false; + }); + return initState; + }); + + const toggleBookmark = (id: number) => { + setBookmarks(prev => ({ + ...prev, + [id]: !prev[id], + })); + }; + + return ( +
+
+ {phrases.map(phrase => ( +
+
+

{phrase.kor}

+ +
+ + {phrase?.[currentLanguage.code] || ( + 번역 없음 + )} + +
+ ))} +
+
+ ); +} diff --git a/app/main/my-learning/_components/step1/PhrasePracticeText.tsx b/app/main/my-learning/_components/step1/PhrasePracticeText.tsx new file mode 100644 index 0000000..209a392 --- /dev/null +++ b/app/main/my-learning/_components/step1/PhrasePracticeText.tsx @@ -0,0 +1,159 @@ +import Image from 'next/image'; +import { useLanguageStore } from '@/stores/languageStore'; +import { useState } from 'react'; +import { + RATING_THRESHOLDS, + SPELLING_RATING_THRESHOLDS, +} from '@/constants/rating'; +import { similarity } from '@/utils/similarity'; + +interface WordPercentage { + text: string; + percentage: number; +} + +interface PhrasePracticeTextProps { + phrase: string; + romanization: string; + translation: string; + className?: string; + inputText: string; + showEvaluation: boolean; + evaluationType?: 'mic' | 'keyboard'; +} + +export default function PhrasePracticeText({ + phrase, + romanization, + translation, + className, + inputText, + showEvaluation, + evaluationType, +}: PhrasePracticeTextProps) { + const { currentLanguage } = useLanguageStore(); + const [isBookmarked, setIsBookmarked] = useState(false); + + const typedWords = inputText.trim() === '' ? [] : inputText.trim().split(' '); + const words = phrase.split(' '); + + const wordPercentages: WordPercentage[] = words.map((w, idx) => ({ + text: w, + percentage: typedWords[idx] ? similarity(w, typedWords[idx]) : 0, + })); + + const avg = + wordPercentages.reduce((sum, w) => sum + w.percentage, 0) / + wordPercentages.length; + + const thresholds = + evaluationType === 'mic' ? RATING_THRESHOLDS : SPELLING_RATING_THRESHOLDS; + + const matched = thresholds.find(t => avg >= t.min)!; + + const evaluationResult = { + rating: typedWords.length > 0 ? matched.rating : 0, + feedback: typedWords.length > 0 ? matched.feedback : '', + feedbackEn: typedWords.length > 0 ? matched.feedbackEn : '', + }; + + const getHighlightColor = (percentage: number) => { + if (percentage >= 80) return 'bg-primary-dimensional'; + if (percentage >= 20) return 'bg-primary'; + return 'bg-primary-900'; + }; + + const renderHighlightedPhrase = () => + words.map((word, idx) => { + const percentage = wordPercentages[idx].percentage; + const highlightClass = + typedWords.length > 0 && showEvaluation + ? getHighlightColor(percentage) + : 'bg-transparent'; + + return ( +
+ {word} + + {typedWords.length > 0 && showEvaluation && ( +
+ {percentage}% +
+ )} +
+ ); + }); + + return ( +
+
+

+ {romanization} +

+
+

+ {renderHighlightedPhrase()} +

+ {'listen'} 0 && showEvaluation ? 'pb-5.5' : 'pb-1.5' + } + /> + +
+ {typedWords.length > 0 && showEvaluation && ( +
+

+ {evaluationResult.feedback} +

+

+ {evaluationResult.feedbackEn} +

+
+ {Array.from({ length: 5 }, (_, i) => ( + star + ))} +
+
+ )} +
+ +

+ {translation} +

+
+ ); +} diff --git a/app/main/my-learning/_components/step2/IntroPhrasesListStep2.tsx b/app/main/my-learning/_components/step2/IntroPhrasesListStep2.tsx new file mode 100644 index 0000000..edd3bfa --- /dev/null +++ b/app/main/my-learning/_components/step2/IntroPhrasesListStep2.tsx @@ -0,0 +1,55 @@ +import { useState } from 'react'; +import Image from 'next/image'; +import { useLanguageStore } from '@/stores/languageStore'; +import { FONT_CLASS } from '@/constants/languages'; + +interface Phrase { + id: number; + kor: string; + [key: string]: any; +} + +interface IntroPhrasesListProps { + phrases: Phrase[]; +} + +export default function IntroPhrasesListStep2({ + phrases, +}: IntroPhrasesListProps) { + const { currentLanguage } = useLanguageStore(); + + return ( +
+
+ {phrases.map(phrase => ( +
+
+ {'flag +
+
+

{phrase.kor}

+ + {phrase?.[currentLanguage.code] || ( + 번역 없음 + )} + +
+
+ ))} +
+
+ ); +} diff --git a/app/main/my-learning/_components/step2/chat/ChatBubble.tsx b/app/main/my-learning/_components/step2/chat/ChatBubble.tsx new file mode 100644 index 0000000..b1e3dea --- /dev/null +++ b/app/main/my-learning/_components/step2/chat/ChatBubble.tsx @@ -0,0 +1,66 @@ +'use client'; + +import Image from 'next/image'; +import { useState } from 'react'; +import { Role } from './ChatMessageList'; + +export default function ChatBubble({ + role, + text, + translation, + showAudio, + showTranslate, +}: { + role: Role; + text: string; + translation?: string; + showAudio?: boolean; + showTranslate?: boolean; +}) { + const [showTranslation, setShowTranslation] = useState(false); + + return ( +
+ character profile + +
+ {/* 원문 대신 번역 표시 */} + {showTranslation && translation ? translation : text} + + {(showAudio || showTranslate) && ( +
+ {showAudio && ( + listen + )} + {showTranslate && translation && ( + + )} +
+ )} +
+
+ ); +} diff --git a/app/main/my-learning/_components/step2/chat/ChatLesson.tsx b/app/main/my-learning/_components/step2/chat/ChatLesson.tsx new file mode 100644 index 0000000..d91a7a7 --- /dev/null +++ b/app/main/my-learning/_components/step2/chat/ChatLesson.tsx @@ -0,0 +1,62 @@ +import Image from 'next/image'; +import { Message } from './ChatMessageList'; +import ChatMessageList from './ChatMessageList'; + +type ChatLessonProps = { + totalRounds: number; + currentRound: number; + messages: Message[]; + questionHints: string[]; +}; + +export default function ChatLesson({ + totalRounds, + currentRound, + messages, + questionHints, +}: ChatLessonProps) { + return ( +
+ {Array.from({ length: currentRound }).map((_, roundIndex) => { + const roundMessages = messages.filter(m => m.round === roundIndex + 1); + + return ( +
+ {/* 라운드 구분선 */} +
+
+ + 대화 횟수: {roundIndex + 1}/{totalRounds} + +
+
+ + {roundMessages.map(msg => ( +
+ + + {/* AI 질문 메시지 뒤에만 힌트 표시 */} + {msg.role === 'ai' && + msg.isQuestion && + questionHints[roundIndex] && ( +
+ hint +
+ {questionHints[roundIndex]} +
+
+ )} +
+ ))} +
+ ); + })} +
+ ); +} diff --git a/app/main/my-learning/_components/step2/chat/ChatMessageList.tsx b/app/main/my-learning/_components/step2/chat/ChatMessageList.tsx new file mode 100644 index 0000000..94d0602 --- /dev/null +++ b/app/main/my-learning/_components/step2/chat/ChatMessageList.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { Fragment } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import ChatBubble from './ChatBubble'; +import UserFeedbackCard from './UserFeedbackCard'; + +export type Role = 'ai' | 'user' | 'system'; + +export type Message = { + isQuestion: boolean; + id: string; + role: Role; + text?: string; // 기존 일반 텍스트 + questionTrans?: string; + feedback?: { + userInput: string; + translation: string; + correction: string; + explanation: string; + }; + round?: number; + showAudio?: boolean; + showTranslate?: boolean; +}; + +export type OptionItem = { label: string; muted?: string }; + +export default function ChatMessageList({ + messages, + options, + onSelectOption, + onRetry, +}: { + messages: Message[]; + options?: OptionItem[]; + onSelectOption?: (i: number) => void; + onRetry?: () => void; +}) { + return ( + + {messages.map(m => ( + + + {m.feedback ? ( + + ) : ( + + )} + + + ))} + + ); +} diff --git a/app/main/my-learning/_components/step2/chat/UserFeedbackCard.tsx b/app/main/my-learning/_components/step2/chat/UserFeedbackCard.tsx new file mode 100644 index 0000000..11a2cb5 --- /dev/null +++ b/app/main/my-learning/_components/step2/chat/UserFeedbackCard.tsx @@ -0,0 +1,48 @@ +import Image from 'next/image'; + +interface FeedbackCardProps { + userInput: string; + translation: string; + correction: string; + explanation: string; +} + +export default function FeedbackCard({ + userInput, + translation, + correction, + explanation, +}: FeedbackCardProps) { + return ( +
+ {'bookmark'} +
+ {/* user text*/} +
+ {userInput} +
+ + {/* 번역 */} +
+ {translation} +
+ + {/* 올바른 한국어 */} +
+ {correction} +
+ + {/* 설명 */} +
+ {explanation} +
+
+
+ ); +} diff --git a/app/main/my-learning/_components/step3/BottomSheet.tsx b/app/main/my-learning/_components/step3/BottomSheet.tsx new file mode 100644 index 0000000..172e0bf --- /dev/null +++ b/app/main/my-learning/_components/step3/BottomSheet.tsx @@ -0,0 +1,112 @@ +'use client'; + +import { ReactNode, useRef, useEffect, useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { bottomSheetVariants } from '@/components/_animations/bottomSheetMotion'; + +interface BottomSheetProps { + isOpen: boolean; + onClose: () => void; + title: string; + subText: string; + children?: ReactNode; +} + +export default function BottomSheet({ + isOpen, + onClose, + title, + subText, + children, +}: BottomSheetProps) { + const sheetRef = useRef(null); + const [sheetHeight, setSheetHeight] = useState('auto'); + + // 내용 높이 자동 감지 + useEffect(() => { + if (sheetRef.current) { + const contentHeight = sheetRef.current.scrollHeight; + const maxHeight = window.innerHeight * 0.9; // 화면 최대 90% + setSheetHeight(contentHeight > maxHeight ? maxHeight : contentHeight); + } + }, [children, isOpen]); + + // body 스크롤 막기 + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = 'unset'; + } + return () => { + document.body.style.overflow = 'unset'; + }; + }, [isOpen]); + + // 스크롤 조금만 내려도 닫기 + useEffect(() => { + const el = sheetRef.current; + if (!el) return; + + const handleScroll = () => { + if (el.scrollTop > 10) { + onClose(); + } + }; + + el.addEventListener('scroll', handleScroll); + return () => { + el.removeEventListener('scroll', handleScroll); + }; + }, [onClose, isOpen]); + + return ( + + {isOpen && ( + + {/* 배경 */} +