├── .env.example
├── .eslintrc.json
├── .gitignore
├── .vscode
└── settings.json
├── README.md
├── actions
├── deleteTranslation.ts
└── translate.ts
├── app
├── favicon.ico
├── globals.css
├── layout.tsx
├── page.tsx
├── transcribeAudio
│ └── route.ts
├── translate
│ └── page.tsx
└── translationHistory
│ └── route.ts
├── components.json
├── components
├── DeleteTranslationButton.tsx
├── Header.tsx
├── LanguageSelect.tsx
├── Recorder.tsx
├── SubmitButton.tsx
├── TimeAgoText.tsx
├── TranslationForm.tsx
├── TranslationHistory.tsx
└── ui
│ ├── button.tsx
│ ├── select.tsx
│ └── textarea.tsx
├── lib
└── utils.ts
├── middleware.ts
├── mongodb
├── db.ts
└── models
│ └── User.ts
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
├── next.svg
└── vercel.svg
├── tailwind.config.ts
└── tsconfig.json
/.env.example:
--------------------------------------------------------------------------------
1 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=put_key_here
2 | CLERK_SECRET_KEY=put_key_here
3 | AZURE_TEXT_TRANSLATION=put_key_here
4 | AZURE_TEXT_TRANSLATION_KEY=put_key_here
5 | AZURE_TEXT_LOCATION=put_key_here
6 |
7 | AZURE_API_KEY=put_key_here
8 | AZURE_ENDPOINT=put_key_here
9 | AZURE_DEPLOYMENT_NAME=put_key_here
10 |
11 | MONGO_DB_USERNAME=put_key_here
12 | MONGO_DB_PASSWORD=put_key_here
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "node_modules/typescript/lib"
3 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
2 |
3 | ## Getting Started
4 |
5 | First, run the development server:
6 |
7 | ```bash
8 | npm run dev
9 | # or
10 | yarn dev
11 | # or
12 | pnpm dev
13 | # or
14 | bun dev
15 | ```
16 |
17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18 |
19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
20 |
21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
22 |
23 | ## Learn More
24 |
25 | To learn more about Next.js, take a look at the following resources:
26 |
27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29 |
30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
31 |
32 | ## Deploy on Vercel
33 |
34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
35 |
36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
37 |
--------------------------------------------------------------------------------
/actions/deleteTranslation.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { removeTranslation } from "@/mongodb/models/User";
4 | import { auth } from "@clerk/nextjs/server";
5 | import { revalidateTag } from "next/cache";
6 |
7 | async function deleteTranslation(id: string) {
8 | auth().protect();
9 |
10 | const { userId } = auth();
11 |
12 | const user = await removeTranslation(userId!, id);
13 |
14 | revalidateTag("translationHistory");
15 |
16 | return {
17 | translations: JSON.stringify(user.translations),
18 | };
19 | }
20 |
21 | export default deleteTranslation;
22 |
--------------------------------------------------------------------------------
/actions/translate.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { State } from "@/components/TranslationForm";
4 | import connectDB from "@/mongodb/db";
5 | import { addOrUpdateUser } from "@/mongodb/models/User";
6 | import axios from "axios";
7 | import { v4 } from "uuid";
8 | import { auth } from "@clerk/nextjs/server";
9 | import { revalidateTag } from "next/cache";
10 |
11 | const key = process.env.AZURE_TEXT_TRANSLATION_KEY;
12 | const endpoint = process.env.AZURE_TEXT_TRANSLATION;
13 | const location = process.env.AZURE_TEXT_LOCATION;
14 |
15 | async function translate(prevState: State, formData: FormData) {
16 | auth().protect();
17 |
18 | const { userId } = auth();
19 |
20 | if (!userId) throw new Error("User not found");
21 |
22 | const rawFormData = {
23 | input: formData.get("input") as string,
24 | inputLanguage: formData.get("inputLanguage") as string,
25 | output: formData.get("output") as string,
26 | outputLanguage: formData.get("outputLanguage") as string,
27 | };
28 |
29 | const response = await axios({
30 | baseURL: endpoint,
31 | url: "translate",
32 | method: "POST",
33 | headers: {
34 | "Ocp-Apim-Subscription-Key": key!,
35 | "Ocp-Apim-Subscription-Region": location!,
36 | "Content-type": "application/json",
37 | "X-ClientTraceId": v4().toString(),
38 | },
39 | params: {
40 | "api-version": "3.0",
41 | from:
42 | rawFormData.inputLanguage === "auto" ? null : rawFormData.inputLanguage,
43 | to: rawFormData.outputLanguage,
44 | },
45 | data: [
46 | {
47 | text: rawFormData.input,
48 | },
49 | ],
50 | responseType: "json",
51 | });
52 |
53 | const data = response.data;
54 |
55 | if (data.error) {
56 | console.log(`Error ${data.error.code}: ${data.error.message}`);
57 | }
58 |
59 | // MongoDB
60 | await connectDB();
61 |
62 | if (rawFormData.inputLanguage === "auto") {
63 | rawFormData.inputLanguage = data[0].detectedLanguage.language;
64 | }
65 |
66 | try {
67 | const translation = {
68 | to: rawFormData.outputLanguage,
69 | from: rawFormData.inputLanguage,
70 | fromText: rawFormData.input,
71 | toText: data[0].translations[0].text,
72 | };
73 |
74 | addOrUpdateUser(userId, translation);
75 | } catch (err) {
76 | console.error(err);
77 | }
78 |
79 | revalidateTag("translationHistory");
80 |
81 | return {
82 | ...prevState,
83 | output: data[0].translations[0].text,
84 | };
85 | }
86 |
87 | export default translate;
88 |
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PapaReact/google-translate-2.0-clone-nextjs-14-ms-azure-clerk-openai-mongodb/9caf7001e6f2a2d51ff7e3d214970a2f9401419e/app/favicon.ico
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 222.2 84% 4.9%;
9 |
10 | --card: 0 0% 100%;
11 | --card-foreground: 222.2 84% 4.9%;
12 |
13 | --popover: 0 0% 100%;
14 | --popover-foreground: 222.2 84% 4.9%;
15 |
16 | --primary: 222.2 47.4% 11.2%;
17 | --primary-foreground: 210 40% 98%;
18 |
19 | --secondary: 210 40% 96.1%;
20 | --secondary-foreground: 222.2 47.4% 11.2%;
21 |
22 | --muted: 210 40% 96.1%;
23 | --muted-foreground: 215.4 16.3% 46.9%;
24 |
25 | --accent: 210 40% 96.1%;
26 | --accent-foreground: 222.2 47.4% 11.2%;
27 |
28 | --destructive: 0 84.2% 60.2%;
29 | --destructive-foreground: 210 40% 98%;
30 |
31 | --border: 214.3 31.8% 91.4%;
32 | --input: 214.3 31.8% 91.4%;
33 | --ring: 222.2 84% 4.9%;
34 |
35 | --radius: 0.5rem;
36 | }
37 |
38 | .dark {
39 | --background: 222.2 84% 4.9%;
40 | --foreground: 210 40% 98%;
41 |
42 | --card: 222.2 84% 4.9%;
43 | --card-foreground: 210 40% 98%;
44 |
45 | --popover: 222.2 84% 4.9%;
46 | --popover-foreground: 210 40% 98%;
47 |
48 | --primary: 210 40% 98%;
49 | --primary-foreground: 222.2 47.4% 11.2%;
50 |
51 | --secondary: 217.2 32.6% 17.5%;
52 | --secondary-foreground: 210 40% 98%;
53 |
54 | --muted: 217.2 32.6% 17.5%;
55 | --muted-foreground: 215 20.2% 65.1%;
56 |
57 | --accent: 217.2 32.6% 17.5%;
58 | --accent-foreground: 210 40% 98%;
59 |
60 | --destructive: 0 62.8% 30.6%;
61 | --destructive-foreground: 210 40% 98%;
62 |
63 | --border: 217.2 32.6% 17.5%;
64 | --input: 217.2 32.6% 17.5%;
65 | --ring: 212.7 26.8% 83.9%;
66 | }
67 | }
68 |
69 | @layer base {
70 | * {
71 | @apply border-border;
72 | }
73 | body {
74 | @apply bg-background text-foreground;
75 | }
76 | }
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Inter } from "next/font/google";
3 | import "./globals.css";
4 | import Header from "@/components/Header";
5 | import { auth } from "@clerk/nextjs/server";
6 | import { ClerkProvider } from "@clerk/nextjs";
7 |
8 | const inter = Inter({ subsets: ["latin"] });
9 |
10 | export const metadata: Metadata = {
11 | title: "Translate 2.0 Clone",
12 | description: "Generated by create next app",
13 | };
14 |
15 | export default function RootLayout({
16 | children,
17 | }: Readonly<{
18 | children: React.ReactNode;
19 | }>) {
20 | return (
21 |
22 |
23 |
24 |
25 |
26 | {children}
27 |
28 |
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { SignInButton } from "@clerk/nextjs";
3 | import { auth } from "@clerk/nextjs/server";
4 | import Image from "next/image";
5 | import Link from "next/link";
6 |
7 | export default async function Home() {
8 | const { userId } = auth();
9 |
10 | return (
11 |
12 |
13 | Understand your world and communicate across languages
14 |
{" "}
15 |
21 | {userId ? (
22 |
26 | Translate Now
27 |
28 | ) : (
29 |
34 | )}
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/app/transcribeAudio/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from "next/server";
2 |
3 | import { AzureKeyCredential, OpenAIClient } from "@azure/openai";
4 |
5 | export async function POST(request: NextRequest) {
6 | const formData = await request.formData();
7 | const file = formData.get("audio") as File;
8 | console.log(">>", file);
9 |
10 | if (
11 | process.env.AZURE_API_KEY === undefined ||
12 | process.env.AZURE_ENDPOINT === undefined ||
13 | process.env.AZURE_DEPLOYMENT_NAME === undefined ||
14 | process.env.AZURE_DEPLOYMENT_COMPLETIONS_NAME === undefined
15 | ) {
16 | console.error("Azure credentials not set");
17 | return {
18 | sender: "",
19 | response: "Azure credentials not set",
20 | };
21 | }
22 |
23 | if (file.size === 0) {
24 | return {
25 | sender: "",
26 | response: "No audio file provided",
27 | };
28 | }
29 |
30 | console.log(">>", file);
31 |
32 | const arrayBuffer = await file.arrayBuffer();
33 | const audio = new Uint8Array(arrayBuffer);
34 |
35 | // --- get audio transcription from Azure Whisper AI service ----
36 |
37 | console.log("== Transcribe Audio Sample ==");
38 |
39 | const client = new OpenAIClient(
40 | process.env.AZURE_ENDPOINT,
41 | new AzureKeyCredential(process.env.AZURE_API_KEY)
42 | );
43 |
44 | const result = await client.getAudioTranscription(
45 | process.env.AZURE_DEPLOYMENT_NAME,
46 | audio
47 | );
48 |
49 | console.log(`Transcription: ${result.text}`);
50 |
51 | return NextResponse.json({ text: result.text });
52 | }
53 |
--------------------------------------------------------------------------------
/app/translate/page.tsx:
--------------------------------------------------------------------------------
1 | import TranslationForm from "@/components/TranslationForm";
2 | import TranslationHistory from "@/components/TranslationHistory";
3 | import { auth } from "@clerk/nextjs/server";
4 | import Image from "next/image";
5 |
6 | export type TranslationLanguages = {
7 | translation: {
8 | [key: string]: {
9 | name: string;
10 | nativeName: string;
11 | dir: "ltr" | "rtl";
12 | };
13 | };
14 | };
15 |
16 | async function TranslatePage() {
17 | auth().protect();
18 |
19 | const { userId } = auth();
20 | if (!userId) throw new Error("User not logged in");
21 |
22 | const response = await fetch(
23 | "https://api.cognitive.microsofttranslator.com/languages?api-version=3.0",
24 | {
25 | next: {
26 | revalidate: 60 * 60 * 24,
27 | },
28 | }
29 | );
30 |
31 | const languages = (await response.json()) as TranslationLanguages;
32 |
33 | return (
34 |
35 |
36 |
37 |
38 | );
39 | }
40 |
41 | export default TranslatePage;
42 |
--------------------------------------------------------------------------------
/app/translationHistory/route.ts:
--------------------------------------------------------------------------------
1 | import { getTranslations } from "@/mongodb/models/User";
2 |
3 | import { NextRequest } from "next/server";
4 |
5 | export async function GET(request: NextRequest) {
6 | const searchParams = request.nextUrl.searchParams;
7 | const userId = searchParams.get("userId");
8 |
9 | const translations = await getTranslations(userId!);
10 |
11 | return Response.json({ translations });
12 | }
13 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "app/globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
--------------------------------------------------------------------------------
/components/DeleteTranslationButton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { TrashIcon } from "lucide-react";
4 | import { Button } from "./ui/button";
5 | import deleteTranslation from "@/actions/deleteTranslation";
6 |
7 | function DeleteTranslationButton({ id }: { id: string }) {
8 | const deleteTranslationAction = deleteTranslation.bind(null, id);
9 |
10 | return (
11 |
21 | );
22 | }
23 |
24 | export default DeleteTranslationButton;
25 |
--------------------------------------------------------------------------------
/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import { SignInButton, UserButton } from "@clerk/nextjs";
2 | import { auth } from "@clerk/nextjs/server";
3 | import Image from "next/image";
4 | import Link from "next/link";
5 |
6 | function Header() {
7 | const { userId } = auth();
8 |
9 | return (
10 |
11 |
12 |
13 |
20 |
21 |
22 |
23 | {userId ? (
24 |
25 |
26 |
27 | ) : (
28 |
29 | )}
30 |
31 | );
32 | }
33 |
34 | export default Header;
35 |
--------------------------------------------------------------------------------
/components/LanguageSelect.tsx:
--------------------------------------------------------------------------------
1 | import { TranslationLanguages } from "@/app/translate/page";
2 |
3 | async function LanguageSelect({
4 | name,
5 | defaultValue,
6 | }: {
7 | name: string;
8 | defaultValue: string;
9 | }) {
10 | const languages = await fetch(
11 | "https://api.cognitive.microsofttranslator.com/languages?api-version=3.0",
12 | {
13 | next: {
14 | revalidate: 60 * 60 * 24,
15 | },
16 | }
17 | ).then((response) => response.json() as Promise);
18 |
19 | languages.translation["auto"] = {
20 | name: "Auto-Detect",
21 | nativeName: "Auto-Detect",
22 | dir: "ltr",
23 | };
24 | return (
25 |
32 | );
33 | }
34 |
35 | export default LanguageSelect;
36 |
--------------------------------------------------------------------------------
/components/Recorder.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { MicIcon } from "lucide-react";
4 | import { useEffect, useRef, useState } from "react";
5 | import { useFormStatus } from "react-dom";
6 |
7 | export const mimeType = "audio/webm";
8 |
9 | function Recorder({ uploadAudio }: { uploadAudio: (blob: Blob) => void }) {
10 | const mediaRecorder = useRef(null);
11 | const { pending } = useFormStatus();
12 | const [permission, setPermission] = useState(false);
13 | const [stream, setStream] = useState(null);
14 | const [recordingStatus, setRecordingStatus] = useState("inactive");
15 | const [audioChunks, setAudioChunks] = useState([]);
16 |
17 | useEffect(() => {
18 | getMicrophonePermission();
19 | }, []);
20 |
21 | const getMicrophonePermission = async () => {
22 | if ("MediaRecorder" in window) {
23 | try {
24 | const streamData = await navigator.mediaDevices.getUserMedia({
25 | audio: true,
26 | video: false,
27 | });
28 | setPermission(true);
29 | setStream(streamData);
30 | } catch (err: any) {
31 | alert(err.message);
32 | }
33 | } else {
34 | alert("Your browser does not support the MediaRecorder API");
35 | }
36 | };
37 |
38 | const startRecording = async () => {
39 | if (stream === null || pending) return;
40 |
41 | setRecordingStatus("recording");
42 |
43 | // Create a new media recorder instance using the stream
44 | const media = new MediaRecorder(stream, { mimeType });
45 | mediaRecorder.current = media;
46 | mediaRecorder.current.start();
47 |
48 | let localAudioChunks: Blob[] = [];
49 |
50 | mediaRecorder.current.ondataavailable = (event) => {
51 | if (typeof event.data === "undefined") return;
52 | if (event.data.size === 0) return;
53 |
54 | localAudioChunks.push(event.data);
55 | };
56 |
57 | setAudioChunks(localAudioChunks);
58 | };
59 |
60 | const stopRecording = async () => {
61 | if (mediaRecorder.current === null || pending) return;
62 |
63 | setRecordingStatus("inactive");
64 | mediaRecorder.current.stop();
65 | mediaRecorder.current.onstop = () => {
66 | const audioBlob = new Blob(audioChunks, { type: mimeType });
67 | uploadAudio(audioBlob);
68 | setAudioChunks([]);
69 | };
70 | };
71 |
72 | return (
73 |
80 |
81 |
82 | {!permission && (
83 |
84 | )}
85 |
86 | {pending && (
87 |
88 | {recordingStatus === "recording"
89 | ? "Recording..."
90 | : "Stopping recording..."}
91 |
92 | )}
93 |
94 | {permission && recordingStatus === "inactive" && !pending && (
95 |
101 | )}
102 |
103 | {recordingStatus === "recording" && (
104 |
110 | )}
111 |
112 | );
113 | }
114 |
115 | export default Recorder;
116 |
--------------------------------------------------------------------------------
/components/SubmitButton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useFormStatus } from "react-dom";
4 | import { Button } from "./ui/button";
5 |
6 | function SubmitButton({ disabled }: { disabled: boolean }) {
7 | const { pending } = useFormStatus();
8 |
9 | return (
10 |
17 | );
18 | }
19 |
20 | export default SubmitButton;
21 |
--------------------------------------------------------------------------------
/components/TimeAgoText.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import ReactTimeago from "react-timeago";
4 |
5 | function TimeAgoText({ date }: { date: string }) {
6 | return ;
7 | }
8 |
9 | export default TimeAgoText;
10 |
--------------------------------------------------------------------------------
/components/TranslationForm.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import translate from "@/actions/translate";
4 | import { TranslationLanguages } from "@/app/translate/page";
5 | import { useEffect, useRef, useState } from "react";
6 | import { useFormState } from "react-dom";
7 | import SubmitButton from "./SubmitButton";
8 | import { Textarea } from "./ui/textarea";
9 | import {
10 | Select,
11 | SelectContent,
12 | SelectGroup,
13 | SelectItem,
14 | SelectLabel,
15 | SelectTrigger,
16 | SelectValue,
17 | } from "@/components/ui/select";
18 | import Recorder from "./Recorder";
19 | import Image from "next/image";
20 | import { MicIcon, SpeakerIcon, Volume2Icon } from "lucide-react";
21 | import { Button } from "./ui/button";
22 |
23 | const initialState = {
24 | inputLanguage: "auto",
25 | input: "",
26 | outputLanguage: "es",
27 | output: "",
28 | };
29 |
30 | export type State = typeof initialState;
31 |
32 | function TranslationForm({ languages }: { languages: TranslationLanguages }) {
33 | const [state, formAction] = useFormState(translate, initialState);
34 | const [input, setInput] = useState("");
35 | const [output, setOutput] = useState("");
36 | const submitBtnRef = useRef(null);
37 |
38 | useEffect(() => {
39 | if (state.output) {
40 | setOutput(state.output);
41 | }
42 | }, [state]);
43 |
44 | useEffect(() => {
45 | if (!input?.trim()) return;
46 |
47 | const delayDebounceFn = setTimeout(() => {
48 | submitBtnRef.current?.click();
49 | }, 500);
50 |
51 | return () => clearTimeout(delayDebounceFn);
52 | }, [input]);
53 |
54 | const uploadAudio = async (blob: Blob) => {
55 | const mimeType = "audio/webm";
56 |
57 | const file = new File([blob], "audio.webm", { type: mimeType });
58 |
59 | const formData = new FormData();
60 | formData.append("audio", file);
61 |
62 | const response = await fetch("/transcribeAudio", {
63 | method: "POST",
64 | body: formData,
65 | });
66 |
67 | const data = await response.json();
68 |
69 | if (data.text) {
70 | setInput(data.text);
71 | }
72 | };
73 |
74 | const playAudio = async () => {
75 | const synth = window.speechSynthesis;
76 |
77 | if (!output || !synth) return;
78 |
79 | const wordsToSay = new SpeechSynthesisUtterance(output);
80 |
81 | synth.speak(wordsToSay);
82 | };
83 |
84 | return (
85 | <>
86 |
87 |
88 |
94 |
95 | {/* style like a blue google button */}
96 |
97 | Text
98 |
99 |
100 |
101 |
102 |
103 |
104 |
193 | >
194 | );
195 | }
196 |
197 | export default TranslationForm;
198 |
--------------------------------------------------------------------------------
/components/TranslationHistory.tsx:
--------------------------------------------------------------------------------
1 | import { ITranslation } from "@/mongodb/models/User";
2 | import { auth } from "@clerk/nextjs/server";
3 | import DeleteTranslationButton from "./DeleteTranslationButton";
4 | import TimeAgo from "react-timeago";
5 | import TimeAgoText from "./TimeAgoText";
6 |
7 | const getLanguage = (code: string) => {
8 | const lang = new Intl.DisplayNames(["en"], { type: "language" });
9 | return lang.of(code);
10 | };
11 |
12 | async function TranslationHistory() {
13 | const { userId } = auth();
14 |
15 | const url = `${
16 | process.env.NODE_ENV === "development"
17 | ? "http://localhost:3000"
18 | : process.env.VERCEL_URL
19 | }/translationHistory?userId=${userId}`;
20 |
21 | const response = await fetch(url, {
22 | next: {
23 | tags: ["translationHistory"],
24 | },
25 | });
26 |
27 | const { translations }: { translations: Array } =
28 | await response.json();
29 |
30 | return (
31 |
32 |
History
33 |
34 | {/* Show a message if there are no translations */}
35 | {translations.length === 0 && (
36 |
No translations yet
37 | )}
38 |
39 | {/* Show a list of translations */}
40 |
41 | {translations.map((translation) => (
42 | -
46 |
47 |
48 | {getLanguage(translation.from)}
49 | {" -> "}
50 | {getLanguage(translation.to)}
51 |
52 |
53 |
54 |
{translation.fromText}
55 |
{translation.toText}
56 |
57 |
58 |
59 |
60 |
63 |
64 |
65 |
66 |
67 | ))}
68 |
69 |
70 | );
71 | }
72 |
73 | export default TranslationHistory;
74 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15 | outline:
16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17 | secondary:
18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 | ghost: "hover:bg-accent hover:text-accent-foreground",
20 | link: "text-primary underline-offset-4 hover:underline",
21 | },
22 | size: {
23 | default: "h-10 px-4 py-2",
24 | sm: "h-9 rounded-md px-3",
25 | lg: "h-11 rounded-md px-8",
26 | icon: "h-10 w-10",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | }
34 | )
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : "button"
45 | return (
46 |
51 | )
52 | }
53 | )
54 | Button.displayName = "Button"
55 |
56 | export { Button, buttonVariants }
57 |
--------------------------------------------------------------------------------
/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SelectPrimitive from "@radix-ui/react-select"
5 | import { Check, ChevronDown, ChevronUp } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Select = SelectPrimitive.Root
10 |
11 | const SelectGroup = SelectPrimitive.Group
12 |
13 | const SelectValue = SelectPrimitive.Value
14 |
15 | const SelectTrigger = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, children, ...props }, ref) => (
19 | span]:line-clamp-1",
23 | className
24 | )}
25 | {...props}
26 | >
27 | {children}
28 |
29 |
30 |
31 |
32 | ))
33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
34 |
35 | const SelectScrollUpButton = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 |
48 |
49 | ))
50 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
51 |
52 | const SelectScrollDownButton = React.forwardRef<
53 | React.ElementRef,
54 | React.ComponentPropsWithoutRef
55 | >(({ className, ...props }, ref) => (
56 |
64 |
65 |
66 | ))
67 | SelectScrollDownButton.displayName =
68 | SelectPrimitive.ScrollDownButton.displayName
69 |
70 | const SelectContent = React.forwardRef<
71 | React.ElementRef,
72 | React.ComponentPropsWithoutRef
73 | >(({ className, children, position = "popper", ...props }, ref) => (
74 |
75 |
86 |
87 |
94 | {children}
95 |
96 |
97 |
98 |
99 | ))
100 | SelectContent.displayName = SelectPrimitive.Content.displayName
101 |
102 | const SelectLabel = React.forwardRef<
103 | React.ElementRef,
104 | React.ComponentPropsWithoutRef
105 | >(({ className, ...props }, ref) => (
106 |
111 | ))
112 | SelectLabel.displayName = SelectPrimitive.Label.displayName
113 |
114 | const SelectItem = React.forwardRef<
115 | React.ElementRef,
116 | React.ComponentPropsWithoutRef
117 | >(({ className, children, ...props }, ref) => (
118 |
126 |
127 |
128 |
129 |
130 |
131 |
132 | {children}
133 |
134 | ))
135 | SelectItem.displayName = SelectPrimitive.Item.displayName
136 |
137 | const SelectSeparator = React.forwardRef<
138 | React.ElementRef,
139 | React.ComponentPropsWithoutRef
140 | >(({ className, ...props }, ref) => (
141 |
146 | ))
147 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName
148 |
149 | export {
150 | Select,
151 | SelectGroup,
152 | SelectValue,
153 | SelectTrigger,
154 | SelectContent,
155 | SelectLabel,
156 | SelectItem,
157 | SelectSeparator,
158 | SelectScrollUpButton,
159 | SelectScrollDownButton,
160 | }
161 |
--------------------------------------------------------------------------------
/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | )
20 | }
21 | )
22 | Textarea.displayName = "Textarea"
23 |
24 | export { Textarea }
25 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/middleware.ts:
--------------------------------------------------------------------------------
1 | import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
2 |
3 | // const isDashboardRoute = createRouteMatcher(["/dashboard(.*)"]);
4 | // const isAdminRoute = createRouteMatcher(["/admin(.*)"]);
5 |
6 | export default clerkMiddleware((auth, req) => {
7 | // Restrict admin route to users with specific role
8 | // if (isAdminRoute(req)) auth().protect({ role: "org:admin" });
9 | // Restrict dashboard routes to logged in users
10 | // if (isDashboardRoute(req)) auth().protect();
11 | });
12 |
13 | export const config = {
14 | matcher: ["/((?!.*\\..*|_next).*)", "/", "/translate", "/(api|trpc)(.*)"],
15 | };
16 |
--------------------------------------------------------------------------------
/mongodb/db.ts:
--------------------------------------------------------------------------------
1 | import mongoose from "mongoose";
2 |
3 | const connectionString = `mongodb+srv://${process.env.MONGO_DB_USERNAME}:${process.env.MONGO_DB_PASSWORD}@google-translate-clone.mongocluster.cosmos.azure.com/?tls=true&authMechanism=SCRAM-SHA-256&retrywrites=false&maxIdleTimeMS=120000`;
4 |
5 | if (!connectionString) {
6 | throw new Error("Please define the MONGODB_URI environment variable");
7 | }
8 |
9 | const connectDB = async () => {
10 | if (mongoose.connection?.readyState >= 1) {
11 | console.log("---- Already connected to MongoDB ----");
12 | return;
13 | }
14 |
15 | try {
16 | await mongoose.connect(connectionString);
17 | console.log("---- Connected to MongoDB ----");
18 | } catch (err) {
19 | console.error("Could not connect to MongoDB:", err);
20 | }
21 | };
22 |
23 | export default connectDB;
24 |
--------------------------------------------------------------------------------
/mongodb/models/User.ts:
--------------------------------------------------------------------------------
1 | import mongoose, { Document, Schema } from "mongoose";
2 | import connectDB from "../db";
3 |
4 | export interface ITranslation extends Document {
5 | timestamp: Date;
6 | fromText: string;
7 | from: string;
8 | toText: string;
9 | to: string;
10 | }
11 | interface IUser extends Document {
12 | userId: string;
13 | translations: Array;
14 | }
15 |
16 | const translationSchema = new Schema({
17 | timestamp: { type: Date, default: Date.now },
18 | fromText: String,
19 | from: String,
20 | toText: String,
21 | to: String,
22 | });
23 |
24 | const userSchema = new Schema({
25 | userId: String,
26 | translations: [translationSchema],
27 | });
28 |
29 | // Check if the model already exists to prevent overwriting
30 | const User = mongoose.models.User || mongoose.model("User", userSchema);
31 |
32 | export async function addOrUpdateUser(
33 | userId: string,
34 | translation: {
35 | fromText: string;
36 | from: string;
37 | toText: string;
38 | to: string;
39 | }
40 | ): Promise {
41 | const filter = { userId: userId };
42 | const update = {
43 | $set: { userId: userId },
44 | $push: { translations: translation },
45 | };
46 |
47 | await connectDB();
48 |
49 | // Upsert option ensures that the document is created if it doesn't exist
50 | // The new: true option in the options object ensures that the method returns the updated document after the operation is complete. If you don't set new: true, the method will return the original document before the update.
51 | // In summary, the code you have will either update an existing user's document with new translations or create a new user document with the given userId, and translations, and save it into the database.
52 | const options = { upsert: true, new: true, setDefaultsOnInsert: true };
53 |
54 | try {
55 | const user: IUser | null = await User.findOneAndUpdate(
56 | filter,
57 | update,
58 | options
59 | );
60 | console.log("User added or updated:", user);
61 | if (!user) {
62 | throw new Error("User not found and was not created.");
63 | }
64 |
65 | return user;
66 | } catch (err) {
67 | console.error("Error adding or updating user:", err);
68 | throw err;
69 | }
70 | }
71 |
72 | export async function removeTranslation(
73 | userId: string,
74 | translationId: string
75 | ): Promise {
76 | await connectDB();
77 |
78 | try {
79 | const user: IUser | null = await User.findOneAndUpdate(
80 | { userId: userId }, // Find the user with the given userId
81 | { $pull: { translations: { _id: translationId } } }, // Remove the translation with the given _id
82 | { new: true } // Return the updated document
83 | );
84 | if (!user) {
85 | throw new Error("User not found.");
86 | }
87 | console.log("Translation removed:", user);
88 |
89 | return user;
90 | } catch (err) {
91 | console.error("Error removing translation:", err);
92 | throw err;
93 | }
94 | }
95 |
96 | export async function getTranslations(
97 | userId: string
98 | ): Promise> {
99 | await connectDB();
100 |
101 | try {
102 | const user: IUser | null = await User.findOne({ userId: userId });
103 | if (user) {
104 | // sort translations by timestamp in descending order
105 | user.translations.sort(
106 | (a: ITranslation, b: ITranslation) =>
107 | b.timestamp.getTime() - a.timestamp.getTime()
108 | );
109 |
110 | return user.translations; // Return the translations
111 | } else {
112 | console.log(`User with userId ${userId} not found.`);
113 | return [];
114 | }
115 | } catch (err) {
116 | console.error("Error retrieving translations:", err);
117 | throw err; // Rethrow the error if you want to handle it outside this function
118 | }
119 | }
120 |
121 | export default User;
122 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | images: {
4 | remotePatterns: [
5 | {
6 | protocol: "https",
7 | hostname: "links.papareact.com",
8 | },
9 | ],
10 | },
11 | };
12 |
13 | export default nextConfig;
14 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "google-translate-clone",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@azure/openai": "^1.0.0-beta.11",
13 | "@clerk/nextjs": "^5.0.0-beta.34",
14 | "@radix-ui/react-select": "^2.0.0",
15 | "@radix-ui/react-slot": "^1.0.2",
16 | "axios": "^1.6.8",
17 | "class-variance-authority": "^0.7.0",
18 | "clsx": "^2.1.0",
19 | "lodash": "^4.17.21",
20 | "lucide-react": "^0.359.0",
21 | "mongodb": "^6.5.0",
22 | "mongoose": "^8.2.2",
23 | "next": "14.1.3",
24 | "react": "^18",
25 | "react-dom": "^18",
26 | "react-timeago": "^7.2.0",
27 | "tailwind-merge": "^2.2.2",
28 | "tailwindcss-animate": "^1.0.7",
29 | "uuid": "^9.0.1"
30 | },
31 | "devDependencies": {
32 | "@types/lodash": "^4.17.0",
33 | "@types/node": "^20",
34 | "@types/react": "^18",
35 | "@types/react-dom": "^18",
36 | "@types/react-timeago": "^4.1.7",
37 | "@types/uuid": "^9.0.8",
38 | "autoprefixer": "^10.0.1",
39 | "eslint": "^8",
40 | "eslint-config-next": "14.1.3",
41 | "postcss": "^8",
42 | "tailwindcss": "^3.3.0",
43 | "typescript": "^5"
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss"
2 |
3 | const config = {
4 | darkMode: ["class"],
5 | content: [
6 | './pages/**/*.{ts,tsx}',
7 | './components/**/*.{ts,tsx}',
8 | './app/**/*.{ts,tsx}',
9 | './src/**/*.{ts,tsx}',
10 | ],
11 | prefix: "",
12 | theme: {
13 | container: {
14 | center: true,
15 | padding: "2rem",
16 | screens: {
17 | "2xl": "1400px",
18 | },
19 | },
20 | extend: {
21 | colors: {
22 | border: "hsl(var(--border))",
23 | input: "hsl(var(--input))",
24 | ring: "hsl(var(--ring))",
25 | background: "hsl(var(--background))",
26 | foreground: "hsl(var(--foreground))",
27 | primary: {
28 | DEFAULT: "hsl(var(--primary))",
29 | foreground: "hsl(var(--primary-foreground))",
30 | },
31 | secondary: {
32 | DEFAULT: "hsl(var(--secondary))",
33 | foreground: "hsl(var(--secondary-foreground))",
34 | },
35 | destructive: {
36 | DEFAULT: "hsl(var(--destructive))",
37 | foreground: "hsl(var(--destructive-foreground))",
38 | },
39 | muted: {
40 | DEFAULT: "hsl(var(--muted))",
41 | foreground: "hsl(var(--muted-foreground))",
42 | },
43 | accent: {
44 | DEFAULT: "hsl(var(--accent))",
45 | foreground: "hsl(var(--accent-foreground))",
46 | },
47 | popover: {
48 | DEFAULT: "hsl(var(--popover))",
49 | foreground: "hsl(var(--popover-foreground))",
50 | },
51 | card: {
52 | DEFAULT: "hsl(var(--card))",
53 | foreground: "hsl(var(--card-foreground))",
54 | },
55 | },
56 | borderRadius: {
57 | lg: "var(--radius)",
58 | md: "calc(var(--radius) - 2px)",
59 | sm: "calc(var(--radius) - 4px)",
60 | },
61 | keyframes: {
62 | "accordion-down": {
63 | from: { height: "0" },
64 | to: { height: "var(--radix-accordion-content-height)" },
65 | },
66 | "accordion-up": {
67 | from: { height: "var(--radix-accordion-content-height)" },
68 | to: { height: "0" },
69 | },
70 | },
71 | animation: {
72 | "accordion-down": "accordion-down 0.2s ease-out",
73 | "accordion-up": "accordion-up 0.2s ease-out",
74 | },
75 | },
76 | },
77 | plugins: [require("tailwindcss-animate")],
78 | } satisfies Config
79 |
80 | export default config
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./*"]
22 | }
23 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------