更新至 v0.6.0_20251125 版本
This commit is contained in:
198
README.md
198
README.md
@@ -1,20 +1,192 @@
|
||||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||
</div>
|
||||
# Sakura Sensei 🌸 - AI Japanese Tutor
|
||||
|
||||
# Run and deploy your AI Studio app
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
This contains everything you need to run your app locally.
|
||||
**Sakura Sensei** is an immersive, all-in-one Japanese learning platform powered by Google's latest Gemini models. It provides a personalized tutor, realistic roleplay scenarios, custom reading/listening materials, and creative tools to make learning Japanese engaging and effective.
|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/drive/1MdpOjnvh39r0kvYmztzlvr-cTY1iF2tW
|
||||
[English](#english) | [日本語](#japanese) | [中文](#chinese)
|
||||
|
||||
## Run Locally
|
||||
---
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
<a name="english"></a>
|
||||
## 🇬🇧 English
|
||||
|
||||
### ✨ Features
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
* **Tutor Dojo (Chat):**
|
||||
* Free chat with Sakura (AI Tutor) using `gemini-3-pro-preview` (Reasoning) or `gemini-2.5-flash`.
|
||||
* **Voice Interaction:** Real-time Speech-to-Text (STT) and high-quality Text-to-Speech (TTS).
|
||||
* **Thinking Mode:** Visualize the AI's reasoning process for complex grammar explanations.
|
||||
* **Share:** Export chat history as Text, File, or Image (screenshot).
|
||||
* **Reading Hall:**
|
||||
* Generates custom reading lessons based on your topic and JLPT level (Beginner N5 - Advanced N1).
|
||||
* Includes vocabulary lists, grammar analysis, and translations.
|
||||
* **Contextual Tutor:** Ask questions specifically about the generated text.
|
||||
* **Listening Lab:**
|
||||
* AI-generated conversations and monologues with comprehension quizzes.
|
||||
* Audio playback with speed controls and transcript toggles.
|
||||
* **Roleplay (Speaking):**
|
||||
* Practice realistic scenarios (Cafe, Hotel, Immigration, etc.).
|
||||
* **AI Feedback:** Receive instant scoring on pronunciation, fluency, and grammar corrections.
|
||||
* **Creative Atelier:**
|
||||
* **Paint:** Generate images using `imagen-4.0` to visualize vocabulary.
|
||||
* **Dream Video:** Generate short videos using `veo-3.1` (requires specific API access).
|
||||
* **Toolbox:**
|
||||
* **Scanner (OCR):** Upload or snap photos of Japanese text for instant analysis and study notes.
|
||||
* **Translator:** Text and Image translation with audio support.
|
||||
* **Data Management:**
|
||||
* Local storage for history (Chat, Reading, Listening, OCR).
|
||||
* Backup and Restore functionality (JSON).
|
||||
|
||||
### 🚀 Getting Started
|
||||
|
||||
1. **Clone the repository:**
|
||||
```bash
|
||||
git clone https://github.com/yourusername/sakura-sensei.git
|
||||
cd sakura-sensei
|
||||
```
|
||||
|
||||
2. **Install dependencies:**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Set up API Key:**
|
||||
* Get your API key from [Google AI Studio](https://aistudio.google.com/).
|
||||
* Create a `.env` file in the root directory:
|
||||
```env
|
||||
VITE_API_KEY=your_gemini_api_key_here
|
||||
```
|
||||
* *Alternatively, you can enter the API Key directly in the app's Settings menu.*
|
||||
|
||||
4. **Run the app:**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 🛠 Tech Stack
|
||||
|
||||
* **Frontend:** React 19, TypeScript, Vite
|
||||
* **Styling:** Tailwind CSS, Lucide React (Icons)
|
||||
* **AI Integration:** `@google/genai` SDK
|
||||
* **Models Used:**
|
||||
* Text/Reasoning: `gemini-3-pro-preview`, `gemini-2.5-flash`
|
||||
* Audio: `gemini-2.5-flash-preview-tts`
|
||||
* Vision/OCR: `gemini-2.5-flash`
|
||||
* Image Gen: `imagen-4.0-generate-001`, `gemini-2.5-flash-image`
|
||||
* Video: `veo-3.1-fast-generate-preview`
|
||||
|
||||
---
|
||||
|
||||
<a name="japanese"></a>
|
||||
## 🇯🇵 日本語
|
||||
|
||||
**さくら先生**は、Googleの最新Geminiモデルを搭載した没入型の日本語学習プラットフォームです。文法解説、ロールプレイ、読解・聴解練習など、あらゆる学習ニーズに対応します。
|
||||
|
||||
### ✨ 主な機能
|
||||
|
||||
* **学習道場 (チャット):**
|
||||
* `gemini-3-pro-preview` を活用した高度な推論能力を持つAIチューターとの会話。
|
||||
* 音声入力・音声再生対応。思考モード(Thinking Mode)でAIの考え方を可視化。
|
||||
* チャット履歴の画像共有機能。
|
||||
* **読書の間:**
|
||||
* トピックと難易度(N5〜N1)を指定して、オリジナルの読み物を生成。
|
||||
* 単語リスト、文法解説、翻訳付き。テキストについてAIに質問可能。
|
||||
* **聴解ラボ:**
|
||||
* リスニング練習用の会話スクリプトとクイズを自動生成。
|
||||
* 音声再生、スクリプトの表示/非表示切り替え。
|
||||
* **会話道場 (ロールプレイ):**
|
||||
* カフェ、駅、入国審査などのリアルなシナリオで会話練習。
|
||||
* 発音、流暢さ、文法ミスに対する即時フィードバック機能。
|
||||
* **アトリエ:**
|
||||
* 画像生成 (`imagen-4.0`) や動画生成 (`veo-3.1`) で学習を視覚的にサポート。
|
||||
* **ツールボックス:**
|
||||
* **スキャナー (OCR):** カメラや画像から日本語テキストを抽出し、解説を生成。
|
||||
* **翻訳機:** テキスト・画像の翻訳と音声再生。
|
||||
* **設定・データ:**
|
||||
* 学習履歴のローカル保存とバックアップ/復元機能。
|
||||
|
||||
### 🚀 始め方
|
||||
|
||||
1. **リポジトリのクローン:**
|
||||
```bash
|
||||
git clone https://github.com/yourusername/sakura-sensei.git
|
||||
```
|
||||
|
||||
2. **依存関係のインストール:**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **APIキーの設定:**
|
||||
* [Google AI Studio](https://aistudio.google.com/) でAPIキーを取得してください。
|
||||
* `.env` ファイルを作成するか、アプリ内の「設定」メニューからキーを入力します。
|
||||
|
||||
4. **起動:**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<a name="chinese"></a>
|
||||
## 🇨🇳 中文
|
||||
|
||||
**樱花老师 (Sakura Sensei)** 是一个基于 Google Gemini 模型的全能型 AI 日语学习助手。它集成了对话练习、阅读生成、听力训练和实时纠错功能,为您提供沉浸式的日语学习体验。
|
||||
|
||||
### ✨ 主要功能
|
||||
|
||||
* **学习道场 (Tutor Chat):**
|
||||
* 与 AI 导师自由对话,支持 `gemini-3-pro` 深度推理模式。
|
||||
* 支持语音输入 (STT) 和高质量语音朗读 (TTS)。
|
||||
* 支持将对话记录导出为图片、文本或文件。
|
||||
* **阅读室:**
|
||||
* 根据您感兴趣的主题和 JLPT 等级 (N5-N1) 生成阅读文章。
|
||||
* 自动提取词汇表、语法点和翻译。支持针对文章内容的提问。
|
||||
* **听力实验室:**
|
||||
* 生成包含理解测验的听力对话脚本。
|
||||
* 支持音频播放控制和脚本隐藏/显示。
|
||||
* **对话道场 (Roleplay):**
|
||||
* 在真实场景(如便利店、机场、酒店)中进行角色扮演。
|
||||
* AI 会对您的发音、流利度和语法进行打分并提供建议。
|
||||
* **创意工作室:**
|
||||
* 使用 AI 生成图片或视频来辅助记忆单词和场景。
|
||||
* **实用工具:**
|
||||
* **扫描仪 (OCR):** 拍照识别日语文本,生成学习笔记。
|
||||
* **翻译机:** 支持文本和图片翻译,带发音功能。
|
||||
* **数据隐私:**
|
||||
* 所有聊天和学习记录均存储在本地浏览器中 (LocalStorage)。
|
||||
* 支持数据的备份与恢复 (JSON 格式)。
|
||||
|
||||
### 🚀 快速开始
|
||||
|
||||
1. **克隆项目:**
|
||||
```bash
|
||||
git clone https://github.com/yourusername/sakura-sensei.git
|
||||
```
|
||||
|
||||
2. **安装依赖:**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **配置 API Key:**
|
||||
* 前往 [Google AI Studio](https://aistudio.google.com/) 获取 Gemini API Key。
|
||||
* 在项目根目录创建 `.env` 文件,或直接在应用“设置”中输入 Key。
|
||||
|
||||
4. **运行应用:**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT License.
|
||||
|
||||
Powered by [Google Gemini API](https://ai.google.dev/).
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
|
||||
import React, { Component, ErrorInfo, ReactNode } from "react";
|
||||
import { AlertCircle, RefreshCw, Trash2 } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -12,10 +11,13 @@ interface State {
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
public state: State = {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
public static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
|
||||
BIN
releases/HTY1024-APP-SKR-0.6.0_20251125.zip
Normal file
BIN
releases/HTY1024-APP-SKR-0.6.0_20251125.zip
Normal file
Binary file not shown.
@@ -396,7 +396,7 @@ class GeminiService {
|
||||
const targetLangName = LANGUAGE_MAP[language];
|
||||
const prompt = `Create a complete Japanese reading lesson on "${topic}", level ${difficulty}.
|
||||
The 'japaneseContent' MUST be a complete article or story (at least 300 characters).
|
||||
Output JSON with title, japaneseContent, translation (${targetLangName}), vocabulary, and grammarPoints (list of key grammar used in the text with explanations).`;
|
||||
Output JSON with title, japaneseContent, translation (in ${targetLangName}), vocabulary (meanings in ${targetLangName}), and grammarPoints (explanations in ${targetLangName}).`;
|
||||
|
||||
return this.retryOperation(async () => {
|
||||
const response = await ai.models.generateContent({
|
||||
@@ -430,9 +430,9 @@ class GeminiService {
|
||||
- title
|
||||
- script (The full Japanese text of the conversation/monologue)
|
||||
- translation (The full text in ${targetLangName})
|
||||
- vocabulary (Key words)
|
||||
- vocabulary (Key words with meanings in ${targetLangName})
|
||||
- questions (3 multiple choice comprehension questions in ${targetLangName})
|
||||
- Each question needs: question, options (array of 3 strings), correctIndex (0-2), explanation.
|
||||
- Each question needs: question, options (array of 3 strings), correctIndex (0-2), explanation (in ${targetLangName}).
|
||||
`;
|
||||
|
||||
return this.retryOperation(async () => {
|
||||
@@ -530,7 +530,7 @@ class GeminiService {
|
||||
const ai = this.getAi();
|
||||
const cleanBase64 = base64.replace(/^data:image\/(png|jpeg|jpg|webp|heic|heif);base64,/i, "");
|
||||
const targetLang = LANGUAGE_MAP[language];
|
||||
const prompt = `OCR and analyze text. Explain in ${targetLang}. JSON: extractedText, detectedLanguage, summary, vocabulary, grammarPoints.`;
|
||||
const prompt = `OCR and analyze text. Explain in ${targetLang}. JSON: extractedText, detectedLanguage, summary (in ${targetLang}), vocabulary (meanings in ${targetLang}), grammarPoints (explanations in ${targetLang}).`;
|
||||
|
||||
return this.retryOperation(async () => {
|
||||
const res = await ai.models.generateContent({
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Language, ListeningLesson, ListeningLessonRecord, ReadingDifficulty, ChatMessage, Role, MessageType } from '../types';
|
||||
import { geminiService, decodeAudioData } from '../services/geminiService';
|
||||
import { processAndDownloadAudio } from '../utils/audioUtils';
|
||||
import { Headphones, Loader2, Send, Eye, EyeOff, List, HelpCircle, ChevronLeft, History, Trash2, X, PanelRightClose, PanelRightOpen, Volume2, Square, Play, Pause, CheckCircle, AlertCircle, FileText, MessageCircle, Download, RotateCcw } from 'lucide-react';
|
||||
import { Headphones, Loader2, Send, Eye, EyeOff, List, HelpCircle, ChevronLeft, History, Trash2, X, PanelRightClose, PanelRightOpen, Volume2, Square, Play, Pause, CheckCircle, AlertCircle, FileText, MessageCircle, Download, RotateCcw, Copy, Check } from 'lucide-react';
|
||||
import { translations } from '../utils/localization';
|
||||
import ChatBubble from '../components/ChatBubble';
|
||||
|
||||
@@ -17,6 +15,28 @@ interface ListeningViewProps {
|
||||
onDeleteHistoryItem: (id: string) => void;
|
||||
}
|
||||
|
||||
// Internal Copy Button Component
|
||||
const CopyButton: React.FC<{ text: string; label?: string }> = ({ text, label }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-colors flex items-center gap-1"
|
||||
title={label || "Copy"}
|
||||
>
|
||||
{copied ? <Check size={16} className="text-emerald-500" /> : <Copy size={16} />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSaveToHistory, onUpdateHistory, onClearHistory, onDeleteHistoryItem }) => {
|
||||
const t = translations[language].listening;
|
||||
const tCommon = translations[language].common;
|
||||
@@ -603,15 +623,21 @@ const ListeningView: React.FC<ListeningViewProps> = ({ language, history, onSave
|
||||
{/* Script Reveal */}
|
||||
{showScript && (
|
||||
<div className="animate-fade-in-up">
|
||||
<div className="bg-white p-6 md:p-8 rounded-2xl border border-slate-200 shadow-sm mb-6">
|
||||
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider mb-4">{t.scriptTitle}</h4>
|
||||
<div className="bg-white p-6 md:p-8 rounded-2xl border border-slate-200 shadow-sm mb-6 relative">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider">{t.scriptTitle}</h4>
|
||||
<CopyButton text={lesson.script || ''} label={tCommon.copy} />
|
||||
</div>
|
||||
<p className="text-lg md:text-xl leading-loose font-serif text-slate-800 whitespace-pre-wrap">
|
||||
{lesson.script || <span className="text-red-400 italic">{t.scriptMissing}</span>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50 p-6 rounded-2xl border border-slate-200 mb-6">
|
||||
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider mb-2">{translations[language].reading.translationLabel}</h4>
|
||||
<div className="bg-slate-50 p-6 rounded-2xl border border-slate-200 mb-6 relative">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider">{translations[language].reading.translationLabel}</h4>
|
||||
<CopyButton text={lesson.translation || ''} label={tCommon.copy} />
|
||||
</div>
|
||||
<p className="text-slate-700 leading-relaxed">{lesson.translation}</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Language, ReadingLesson, ReadingDifficulty, ChatMessage, Role, MessageType, ReadingLessonRecord } from '../types';
|
||||
import { geminiService, decodeAudioData } from '../services/geminiService';
|
||||
import { processAndDownloadAudio } from '../utils/audioUtils';
|
||||
import { BookOpen, Loader2, Send, ToggleLeft, ToggleRight, List, HelpCircle, ChevronLeft, RotateCcw, History, Trash2, X, PanelRightClose, PanelRightOpen, Volume2, Square, MessageCircle, FileText, PenTool, Download } from 'lucide-react';
|
||||
import { BookOpen, Loader2, Send, ToggleLeft, ToggleRight, List, HelpCircle, ChevronLeft, RotateCcw, History, Trash2, X, PanelRightClose, PanelRightOpen, Volume2, Square, MessageCircle, FileText, PenTool, Download, Copy, Check } from 'lucide-react';
|
||||
import { translations } from '../utils/localization';
|
||||
import ChatBubble from '../components/ChatBubble';
|
||||
|
||||
@@ -15,6 +16,28 @@ interface ReadingViewProps {
|
||||
onDeleteHistoryItem: (id: string) => void;
|
||||
}
|
||||
|
||||
// Internal Copy Button Component
|
||||
const CopyButton: React.FC<{ text: string; label?: string }> = ({ text, label }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-colors flex items-center gap-1"
|
||||
title={label || "Copy"}
|
||||
>
|
||||
{copied ? <Check size={16} className="text-emerald-500" /> : <Copy size={16} />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const ReadingView: React.FC<ReadingViewProps> = ({ language, history, onSaveToHistory, onUpdateHistory, onClearHistory, onDeleteHistoryItem }) => {
|
||||
const t = translations[language].reading;
|
||||
const tCommon = translations[language].common;
|
||||
@@ -502,6 +525,9 @@ const ReadingView: React.FC<ReadingViewProps> = ({ language, history, onSaveToHi
|
||||
<span className="hidden sm:inline">{t.translationLabel}</span>
|
||||
</button>
|
||||
|
||||
{/* Copy Content Button - New */}
|
||||
<CopyButton text={lesson.japaneseContent || ''} label={tCommon.copy} />
|
||||
|
||||
{/* Sidebar Toggle In Lesson View */}
|
||||
<button
|
||||
onClick={() => setIsHistoryOpen(!isHistoryOpen)}
|
||||
@@ -524,26 +550,31 @@ const ReadingView: React.FC<ReadingViewProps> = ({ language, history, onSaveToHi
|
||||
</p>
|
||||
</div>
|
||||
{showTranslation && (
|
||||
<div className="mb-12 p-6 bg-slate-50 rounded-2xl border border-slate-200 animate-scale-in">
|
||||
<h4 className="text-xs font-bold text-slate-400 uppercase mb-3">{t.translationLabel}</h4>
|
||||
<div className="mb-12 p-6 bg-slate-50 rounded-2xl border border-slate-200 animate-scale-in relative">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-xs font-bold text-slate-400 uppercase">{t.translationLabel}</h4>
|
||||
<CopyButton text={lesson.translation || ''} label={tCommon.copy} />
|
||||
</div>
|
||||
<p className="text-lg leading-relaxed text-slate-600">{lesson.translation || <span className="text-slate-400 italic">{t.translationMissing}</span>}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-emerald-50/50 rounded-2xl p-6 border border-emerald-100/50 animate-fade-in-up delay-300">
|
||||
|
||||
{/* Vocabulary Section */}
|
||||
<div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm animate-fade-in-up delay-300">
|
||||
<h4 className="text-sm font-bold text-emerald-800 mb-4 flex items-center gap-2">
|
||||
<List size={18} /> {t.vocabTitle}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{lesson.vocabulary?.map((v, i) => (
|
||||
<div key={i} className="bg-white p-3 rounded-xl shadow-sm border border-emerald-100 hover:shadow-md transition-shadow relative group">
|
||||
<div className="flex items-baseline justify-between mb-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<div key={i} className="bg-emerald-50 p-3 rounded-xl border border-emerald-100 flex flex-col group relative">
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0">
|
||||
<span className="text-lg font-bold text-slate-800">{v.word}</span>
|
||||
<span className="text-sm text-slate-500">({v.reading})</span>
|
||||
{v.reading && <span className="text-sm text-slate-500">({v.reading})</span>}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => playVocab(v.word)}
|
||||
className={`p-1.5 rounded-full transition-colors ${playingVocabWord === v.word ? 'bg-pink-100 text-pink-500' : 'text-slate-300 hover:bg-emerald-50 hover:text-emerald-600'}`}
|
||||
className={`p-1.5 rounded-full transition-colors flex-shrink-0 ml-2 ${playingVocabWord === v.word ? 'bg-pink-100 text-pink-500' : 'text-emerald-300 hover:bg-emerald-100 hover:text-emerald-600'}`}
|
||||
>
|
||||
{playingVocabWord === v.word ? <Loader2 size={14} className="animate-spin" /> : <Volume2 size={14} />}
|
||||
</button>
|
||||
@@ -556,13 +587,13 @@ const ReadingView: React.FC<ReadingViewProps> = ({ language, history, onSaveToHi
|
||||
|
||||
{/* Grammar Section */}
|
||||
{lesson.grammarPoints && lesson.grammarPoints.length > 0 && (
|
||||
<div className="bg-emerald-50/50 rounded-2xl p-6 border border-emerald-100/50 animate-fade-in-up delay-400 mt-6">
|
||||
<div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm animate-fade-in-up delay-400 mt-6">
|
||||
<h4 className="text-sm font-bold text-emerald-800 mb-4 flex items-center gap-2">
|
||||
<PenTool size={18} /> {t.grammarHeader}
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
{lesson.grammarPoints.map((g, i) => (
|
||||
<div key={i} className="bg-white p-4 rounded-xl border border-emerald-100">
|
||||
<div key={i} className="bg-emerald-50/50 p-4 rounded-xl border border-emerald-100">
|
||||
<h5 className="font-bold text-emerald-900 mb-1">{g.point}</h5>
|
||||
<p className="text-sm text-emerald-700 leading-relaxed">{g.explanation}</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user