Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ ANTHROPIC_API_KEY=
# Google Cloud Translation Basic (v2, server-side only)
GOOGLE_CLOUD_TRANSLATION_API_KEY=

# Azure AI Translator F0/free tier (server-side only)
AZURE_TRANSLATOR_KEY=
AZURE_TRANSLATOR_REGION=
AZURE_TRANSLATOR_ENDPOINT=https://api.cognitive.microsofttranslator.com

# LibreTranslate self-hosted or trusted instance (server-side only)
LIBRETRANSLATE_URL=
LIBRETRANSLATE_API_KEY=

# Stripe Billing
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

# testing
/coverage
/.playwright-cli/

# next.js
/.next/
Expand Down
55 changes: 42 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ providers in one account-isolated application.
- Difficult-question review queue persisted per learner.
- AI Tutor with saved conversations, document context and answer feedback.
- Reading, listening, speaking, writing, translation, quiz and vocabulary tools.
- Google Cloud Translation with automatic language detection and server-side keys.
- Azure, Google and LibreTranslate machine translation with automatic language
detection and server-side keys.
- Flashcards with spaced review and learner-owned vocabulary.
- PDF, DOCX and TXT extraction with AI learning tools.
- XP, tokens, daily/weekly challenges, levels and competition leaderboards.
Expand Down Expand Up @@ -174,6 +175,11 @@ OPENAI_API_KEY=
OPENROUTER_API_KEY=
ANTHROPIC_API_KEY=
GOOGLE_CLOUD_TRANSLATION_API_KEY=
AZURE_TRANSLATOR_KEY=
AZURE_TRANSLATOR_REGION=
AZURE_TRANSLATOR_ENDPOINT=https://api.cognitive.microsofttranslator.com
LIBRETRANSLATE_URL=
LIBRETRANSLATE_API_KEY=

STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
Expand Down Expand Up @@ -242,28 +248,51 @@ prefix, model name or custom Base URL.
Retryable provider failures such as HTTP `429`, `500`, `502`, `503` and `504`
use bounded retries and return actionable messages to the interface.

### Google Cloud Translation
### Machine Translation

The Translation workspace uses Google Cloud Translation Basic (v2) when
`GOOGLE_CLOUD_TRANSLATION_API_KEY` is configured. The key stays on the server;
the browser only calls Lingora's authenticated `/api/translation/google`
route. If the key is absent, Lingora falls back to the configured AI provider.
The Translation workspace uses a server-side provider chain:

```text
Azure AI Translator → Google Cloud Translation → LibreTranslate → Lingora AI fallback
```

The browser only calls Lingora's authenticated `/api/translation` route. API
keys stay on the server, and translation text is not stored in learning-event
metadata.

Recommended free/low-cost setup:

1. Create an Azure account and enable **Azure AI Translator** on the F0 tier.
2. Copy the resource key and region into `.env.local`:

```dotenv
AZURE_TRANSLATOR_KEY=your_azure_translator_key
AZURE_TRANSLATOR_REGION=your_resource_region
AZURE_TRANSLATOR_ENDPOINT=https://api.cognitive.microsofttranslator.com
```

Google Cloud Translation can be used as a second provider:

1. Create or select a project in Google Cloud Console.
2. Enable **Cloud Translation API** and attach a billing account.
3. Create an API key under **APIs & Services → Credentials**.
4. Restrict the key to **Cloud Translation API**. For production, also apply
the network or application restrictions appropriate for the deployment.
5. Add the key to `.env.local`, then restart the Next.js server:
4. Restrict the key to **Cloud Translation API**.
5. Add the key to `.env.local`:

```dotenv
GOOGLE_CLOUD_TRANSLATION_API_KEY=your_server_side_key
```

The integration supports automatic source-language detection, explicit
source/target selection, language swapping, copy-to-clipboard and private
usage events. Translation text itself is not stored in learning-event
metadata.
LibreTranslate can be self-hosted or pointed at a trusted instance:

```dotenv
LIBRETRANSLATE_URL=http://localhost:5000
LIBRETRANSLATE_API_KEY=
```

After changing translation environment variables, restart the Next.js server.
The UI supports automatic source-language detection, explicit source/target
selection, language swapping and copy-to-clipboard.

## Speech Recognition

Expand Down
13 changes: 12 additions & 1 deletion next.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
/* config options here */
poweredByHeader: false,
compress: true,
images: {
formats: ["image/avif", "image/webp"],
minimumCacheTTL: 60 * 60 * 24 * 30,
},
compiler: {
removeConsole:
process.env.NODE_ENV === "production"
? { exclude: ["error", "warn"] }
: false,
},
};

export default nextConfig;
129 changes: 129 additions & 0 deletions src/app/api/translation/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getOptionalViewer } from "@/lib/auth";
import { consumeAiQuota } from "@/lib/billing";
import { hasFeatureAccess } from "@/lib/economy";
import {
MachineTranslationError,
translateWithMachineProvider,
} from "@/lib/machine-translation";
import {
isTranslationLanguage,
translationLanguages,
} from "@/lib/translation-languages";
import { createClient } from "@/lib/supabase/server";

const languageCodes = translationLanguages.map((language) => language.code) as [
string,
...string[],
];

const schema = z.object({
text: z.string().trim().min(1).max(10_000),
source: z.enum(languageCodes).nullable().optional(),
target: z.enum(languageCodes),
});

const providerLabels = {
azure: "Azure AI Translator",
google: "Google Cloud Translation",
libretranslate: "LibreTranslate",
} as const;

export async function POST(request: Request) {
try {
const viewer = await getOptionalViewer();
if (!viewer) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!(await hasFeatureAccess(viewer.id, "translation", "basic"))) {
return NextResponse.json(
{
error: "Dịch thuật yêu cầu gói Basic, Plus hoặc Pro.",
code: "PLAN_UPGRADE_REQUIRED",
requiredPlan: "basic",
},
{ status: 403 },
);
}

const input = schema.parse(await request.json());
if (input.source && input.source === input.target) {
return NextResponse.json(
{ error: "Ngôn ngữ nguồn và đích phải khác nhau." },
{ status: 400 },
);
}
if (
(input.source && !isTranslationLanguage(input.source)) ||
!isTranslationLanguage(input.target)
) {
return NextResponse.json(
{ error: "Ngôn ngữ chưa được hỗ trợ." },
{ status: 400 },
);
}

const usage = await consumeAiQuota(viewer.id);
if (!usage.allowed) {
return NextResponse.json(
{
error: `Bạn đã dùng hết ${usage.quota} lượt AI hôm nay.`,
code: "AI_QUOTA_EXCEEDED",
},
{ status: 429 },
);
}

const result = await translateWithMachineProvider({
text: input.text,
source: input.source ?? undefined,
target: input.target,
});
const supabase = await createClient();
await supabase.from("learning_events").insert({
user_id: viewer.id,
event_type: "translation",
skill: "translation",
duration_seconds: 0,
metadata: {
provider: result.provider,
source: result.detectedSourceLanguage,
target: input.target,
characters: input.text.length,
},
});

return NextResponse.json(
{
...result,
providerLabel: providerLabels[result.provider],
usage: {
used: usage.used,
quota: usage.quota,
remaining: Math.max(0, usage.quota - usage.used),
},
},
{ headers: { "Cache-Control": "private, no-store" } },
);
} catch (error) {
const message =
error instanceof z.ZodError
? "Dữ liệu dịch không hợp lệ."
: error instanceof Error
? error.message
: "Không thể dịch nội dung.";
const status =
error instanceof MachineTranslationError
? error.status
: error instanceof z.ZodError
? 400
: 500;
const code =
error instanceof MachineTranslationError ? error.code : undefined;
return NextResponse.json(
{ error: message, code },
{ status, headers: { "Cache-Control": "private, no-store" } },
);
}
}
7 changes: 6 additions & 1 deletion src/components/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
MascotSprite,
openMascotChat,
} from "@/components/lingora-mascot";
import { canShowMascotCompanion } from "@/lib/mascot-visibility";

const navigation = [
{ key: "dashboard", href: "/", icon: LayoutDashboard },
Expand Down Expand Up @@ -98,6 +99,7 @@ function Navigation({
isAdmin: boolean;
}) {
const pathname = usePathname();
const { showMascot } = useExperience();

return (
<nav className="min-h-0 flex-1 overflow-y-auto pr-1 [scrollbar-width:thin]">
Expand All @@ -113,7 +115,10 @@ function Navigation({
key={item.href}
href={item.href}
onClick={(event) => {
if (item.key === "tutor") {
if (
item.key === "tutor" &&
canShowMascotCompanion(pathname, showMascot)
) {
event.preventDefault();
openMascotChat();
}
Expand Down
9 changes: 3 additions & 6 deletions src/components/auth-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -231,11 +231,11 @@ export function AuthForm({
/>
<Button
type="submit"
className="mt-1 h-12 rounded-2xl bg-gradient-to-r from-sky-500 to-cyan-500 text-[15px] font-bold shadow-lg shadow-sky-200 transition hover:-translate-y-0.5 hover:from-sky-600 hover:to-cyan-600"
className="relative mt-1 h-12 rounded-2xl bg-gradient-to-r from-sky-500 to-cyan-500 text-center text-[15px] font-bold shadow-lg shadow-sky-200 transition hover:-translate-y-0.5 hover:from-sky-600 hover:to-cyan-600"
disabled={loginPending || registerPending}
>
{mode === "login" ? "Đăng nhập" : "Đăng ký"}
<ArrowRight className="ml-auto" />
<span className="mx-auto">{mode === "login" ? "Đăng nhập" : "Đăng ký"}</span>
<ArrowRight className="absolute right-4" />
</Button>
</form>
</TabsContent>
Expand Down Expand Up @@ -276,9 +276,6 @@ export function AuthForm({
<ShieldCheck className="size-3.5 text-emerald-500" />
Thông tin đăng nhập được mã hóa và bảo vệ
</div>
<Link href="/setup" className="text-center text-xs font-medium text-slate-400 hover:text-sky-600">
Hướng dẫn cấu hình hệ thống
</Link>
</CardContent>
</Card>
</main>
Expand Down
19 changes: 10 additions & 9 deletions src/components/learning-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,7 @@ export function LearningWorkspace({
setLoading(true);
try {
if (mode === "translation") {
const googleResponse = await fetch("/api/translation/google", {
const translationResponse = await fetch("/api/translation", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
Expand All @@ -586,24 +586,25 @@ export function LearningWorkspace({
target: translationTarget,
}),
});
const googlePayload = (await googleResponse.json()) as {
const translationPayload = (await translationResponse.json()) as {
text?: string;
detectedSourceLanguage?: string | null;
provider?: string;
providerLabel?: string;
error?: string;
code?: string;
};
if (googleResponse.ok) {
setResult(googlePayload.text ?? "");
setDetectedLanguage(googlePayload.detectedSourceLanguage ?? null);
setTranslationProvider("Google Cloud Translation");
toast.success("Đã dịch bằng Google Cloud Translation.");
if (translationResponse.ok) {
setResult(translationPayload.text ?? "");
setDetectedLanguage(translationPayload.detectedSourceLanguage ?? null);
setTranslationProvider(translationPayload.providerLabel ?? "Dịch máy");
toast.success(`Đã dịch bằng ${translationPayload.providerLabel ?? "dịch máy"}.`);
play("complete");
router.refresh();
return;
}
if (googlePayload.code !== "GOOGLE_TRANSLATION_NOT_CONFIGURED") {
throw new Error(googlePayload.error ?? "Google Translation không phản hồi.");
if (translationPayload.code !== "NOT_CONFIGURED") {
throw new Error(translationPayload.error ?? "Dịch máy không phản hồi.");
}
}

Expand Down
8 changes: 2 additions & 6 deletions src/components/lingora-mascot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { readClientAiConfig } from "@/lib/client-ai-config";
import type { MascotMood } from "@/lib/gamification";
import { canShowMascotCompanion } from "@/lib/mascot-visibility";
import { cn } from "@/lib/utils";
import { useExperience } from "@/components/experience-provider";

Expand Down Expand Up @@ -142,12 +143,7 @@ export function MascotCompanion() {
}
}

if (
!showMascot ||
pathname.startsWith("/admin") ||
pathname.startsWith("/learn/") ||
pathname.startsWith("/ai-tutor")
) {
if (!canShowMascotCompanion(pathname, showMascot)) {
return null;
}

Expand Down
Loading
Loading