diff --git a/docs/zh-CN/README.md b/docs/zh-CN/README.md index 04888579..83373ed9 100644 --- a/docs/zh-CN/README.md +++ b/docs/zh-CN/README.md @@ -40,7 +40,7 @@ GCopy重视您的数据隐私, 不持久化存储您的数据, 它们都在内 目前, 你可以在Windows电脑与MacOS电脑之间共享剪切板, 支持文字, 截图和文件. 它对网络没有太高的要求, 不同的设备可以在同一个局域网, 也可以不在. -最开始我使用Git作为后端存储, 使用powershell、osascript这样的脚本在不同的设备之间同步剪切板. 但是因为它依赖Git, 注定不能让更多的非技术朋友使用. 所以, 我使用Golang替换了Git, 来作为不同设备之间的数据中转服务, 但是它仍然需要您在设备上下载运行GCopy客户端, 给使用带来了门槛. 所以, 我做了现在的`GCopy v1.0`, 它一个Web服务, 您可以直接打开网站[https://gcopy.rutron.net](https://gcopy.rutron.net)使用, 同时不用担心您的数据泄露. +最开始我使用Git作为后端存储, 使用powershell、osascript这样的脚本在不同的设备之间同步剪切板. 但是因为它依赖Git, 注定不能让更多的非技术朋友使用. 所以, 我使用Golang替换了Git, 来作为不同设备之间的数据中转服务, 但是它仍然需要您在设备上下载运行GCopy客户端, 给使用带来了门槛. 所以, 我做了现在的`GCopy v1.0`, 它是一个Web服务, 您可以直接打开网站[https://gcopy.rutron.net](https://gcopy.rutron.net)使用, 同时不用担心您的数据泄露. ## 不足之处 diff --git a/frontend/app/[locale]/layout.tsx b/frontend/app/[locale]/layout.tsx new file mode 100644 index 00000000..73da7c53 --- /dev/null +++ b/frontend/app/[locale]/layout.tsx @@ -0,0 +1,32 @@ +import { Inter } from "next/font/google"; +import "@/app/globals.css"; +import { ReactNode } from "react"; +import { getTranslations } from "next-intl/server"; + +export async function generateMetadata({ + params: { locale }, +}: { + params: { locale: string }; +}) { + const t = await getTranslations({ locale, namespace: "Metadata" }); + return { + title: t("title"), + description: t("description"), + }; +} + +const inter = Inter({ subsets: ["latin"] }); + +export default function RootLayout({ + children, + params: { locale }, +}: { + children: ReactNode; + params: { locale: string }; +}) { + return ( + + {children} + + ); +} diff --git a/frontend/app/[locale]/page.tsx b/frontend/app/[locale]/page.tsx new file mode 100644 index 00000000..cf902612 --- /dev/null +++ b/frontend/app/[locale]/page.tsx @@ -0,0 +1,29 @@ +import Navbar from "@/components/navbar"; +import SyncClipboard from "@/components/sync-clipboard"; +import Notice from "@/components/notice"; +import Footer from "@/components/footer"; +import { NextIntlClientProvider, useMessages } from "next-intl"; + +export default function Home({ + params: { locale }, +}: { + params: { locale: string }; +}) { + const messages = useMessages(); + return ( +
+
+ + + +
+
+ + + + +
+
+ ); +} diff --git a/frontend/app/user/email-code/page.tsx b/frontend/app/[locale]/user/email-code/page.tsx similarity index 72% rename from frontend/app/user/email-code/page.tsx rename to frontend/app/[locale]/user/email-code/page.tsx index 56420182..4e30bd1d 100644 --- a/frontend/app/user/email-code/page.tsx +++ b/frontend/app/[locale]/user/email-code/page.tsx @@ -3,12 +3,15 @@ import Image from "next/image"; import { FormEvent, useState } from "react"; import { useRouter } from "next/navigation"; +import { useLocale, useTranslations } from "next-intl"; export default function EmailCode({ searchParams, }: { searchParams: { email: string }; }) { + const locale = useLocale(); + const t = useTranslations("EmailCode"); const [errorMessage, setErrorMessage] = useState(""); const email = searchParams.email; const router = useRouter(); @@ -31,7 +34,7 @@ export default function EmailCode({ setErrorMessage(body.message); } if (res.status == 200) { - router.push("/user/login?email=" + email); + router.push(`/${locale}/user/login?email=${email}`); } }); }; @@ -39,35 +42,30 @@ export default function EmailCode({ return (
- Picture of the author + gcopy's logo
-

Sign in

- to continue to GCopy +

{t("title")}

+ {t("smallTitle")}
-

Support for text, screenshots & file synchronization.

+

{t("subTitle")}

- Your privacy is important to GCopy! + {t("tip")}
{errorMessage &&

{errorMessage}

}
diff --git a/frontend/app/[locale]/user/layout.tsx b/frontend/app/[locale]/user/layout.tsx new file mode 100644 index 00000000..60e313da --- /dev/null +++ b/frontend/app/[locale]/user/layout.tsx @@ -0,0 +1,18 @@ +import { NextIntlClientProvider, useMessages } from "next-intl"; + +export default function UserLayout({ + params: { locale }, + children, +}: { + children: React.ReactNode; + params: { locale: string }; +}) { + const messages = useMessages(); + return ( +
+ + {children} + +
+ ); +} diff --git a/frontend/app/user/login/page.tsx b/frontend/app/[locale]/user/login/page.tsx similarity index 79% rename from frontend/app/user/login/page.tsx rename to frontend/app/[locale]/user/login/page.tsx index c6d20cfa..d1f76361 100644 --- a/frontend/app/user/login/page.tsx +++ b/frontend/app/[locale]/user/login/page.tsx @@ -3,12 +3,15 @@ import Image from "next/image"; import { FormEvent, useState } from "react"; import { useRouter } from "next/navigation"; +import { useLocale, useTranslations } from "next-intl"; export default function Login({ searchParams, }: { searchParams: { email: string }; }) { + const locale = useLocale(); + const t = useTranslations("Login"); const email = searchParams.email; const [errorMessage, setErrorMessage] = useState(""); const router = useRouter(); @@ -33,7 +36,7 @@ export default function Login({ setErrorMessage(body.message); } if (res.status == 200) { - router.push("/"); + router.push(`/${locale}/`); } }); }; @@ -41,19 +44,14 @@ export default function Login({ return (
- Picture of the author + gcopy's logo
{email}
-

Enter code

-

We emailed a code to {email}. Please enter the code to sign in.

- +

{t("title")}

+

{t("subTitle", { email: email })}

- Your privacy is important to GCopy! + {t("tip")}
{errorMessage &&

{errorMessage}

}
diff --git a/frontend/app/api/session/route.ts b/frontend/app/api/session/route.ts index b0f15b0d..cf6a927d 100644 --- a/frontend/app/api/session/route.ts +++ b/frontend/app/api/session/route.ts @@ -1,34 +1,11 @@ -// import { NextRequest } from "next/server"; import { cookies } from "next/headers"; import { getIronSession } from "iron-session"; import { SessionData, sessionOptions } from "@/lib/session"; import { UserInfo, defaultUserInfo } from "@/lib/types"; -// login -// export async function POST(request: NextRequest) { -// const session = await getIronSession(cookies(), sessionOptions); - -// const { username = "No username" } = (await request.json()) as { -// username: string; -// }; - -// session.isLoggedIn = true; -// session.username = username; -// await session.save(); - -// // simulate looking up the user in db -// await sleep(250); - -// return Response.json(session); -// } - -// read session export async function GET() { const session = await getIronSession(cookies(), sessionOptions); - // simulate looking up the user in db - // await sleep(250); - if (session.isLoggedIn !== true) { return Response.json({ defaultUserInfo }); } @@ -39,11 +16,8 @@ export async function GET() { } as UserInfo); } -// logout export async function DELETE() { const session = await getIronSession(cookies(), sessionOptions); - session.destroy(); - return Response.json(defaultUserInfo); } diff --git a/frontend/app/api/user/email-code/route.ts b/frontend/app/api/user/email-code/route.ts index ca67f5b3..29f9eb19 100644 --- a/frontend/app/api/user/email-code/route.ts +++ b/frontend/app/api/user/email-code/route.ts @@ -5,13 +5,17 @@ import { z } from "zod"; import { NextRequest } from "next/server"; import nodemailer from "nodemailer"; import SMTPTransport from "nodemailer/lib/smtp-transport"; +import { getAcceptLanguageLocale } from "@/lib/i18n"; +import { getTranslations } from "next-intl/server"; export async function POST(request: NextRequest) { + const locale = getAcceptLanguageLocale(request.headers); + const t = await getTranslations({ locale, namespace: "EmailCode" }); const body = (await request.json()) as { email: string }; const validatedFields = z .object({ - email: z.string().email({ message: "Invalid email" }), + email: z.string().email({ message: t("invalidEmail") }), }) .safeParse({ email: body.email, @@ -45,21 +49,11 @@ export async function POST(request: NextRequest) { address: process.env.SMTP_SENDER || "", }, to: validatedFields.data.email, - subject: code + " is your verification code", - text: - "Enter the verification code when prompted: " + - code + - ". Code will expire in 5 minutes. To protect your account, do not share this code.", - html: - "

Enter the following verification code when prompted:
" + - code + - "
Code will expire in 5 minutes.
To protect your account, do not share this code.

", + subject: t("sendEmail.subject", { code: code }), + text: t("sendEmail.text", { code: code }), }); } catch { - return Response.json( - { message: "Failed to send email. Please check and retry." }, - { status: 500 }, - ); + return Response.json({ message: t("sendEmail.failed") }, { status: 500 }); } const session = await getIronSession(cookies(), sessionOptions); @@ -68,5 +62,5 @@ export async function POST(request: NextRequest) { session.createdTime = Date.now(); await session.save(); - return Response.json({ message: "success" }); + return Response.json({ message: t("sendEmail.success") }); } diff --git a/frontend/app/api/user/login/route.ts b/frontend/app/api/user/login/route.ts index 501706e6..e1042464 100644 --- a/frontend/app/api/user/login/route.ts +++ b/frontend/app/api/user/login/route.ts @@ -2,18 +2,22 @@ import { cookies } from "next/headers"; import { getIronSession } from "iron-session"; import { SessionData, sessionOptions } from "@/lib/session"; import { z } from "zod"; +import { getAcceptLanguageLocale } from "@/lib/i18n"; +import { getTranslations } from "next-intl/server"; export async function POST(request: Request) { + const locale = getAcceptLanguageLocale(request.headers); + const t = await getTranslations({ locale, namespace: "Login" }); const body = (await request.json()) as { email: string; code: string; }; const validatedFields = z .object({ - email: z.string().email({ message: "Invalid email" }), + email: z.string().email({ message: t("invalidEmail") }), code: z .string() - .regex(new RegExp("[0-9]{6}"), { message: "Incorrect code" }), + .regex(new RegExp("[0-9]{6}"), { message: t("incorrectCode") }), }) .safeParse({ email: body.email, @@ -37,23 +41,17 @@ export async function POST(request: Request) { if (validatedFields.data.code == session.emailCode) { if (Date.now() - session.createdTime > 5 * 60 * 1000) { - return Response.json( - { email: "The code was expired. Please go back and retry." }, - { status: 422 }, - ); + return Response.json({ message: t("expiredCode") }, { status: 422 }); } session.isLoggedIn = true; await session.save(); return Response.json({ - message: "success", + message: t("success"), data: { email: validatedFields.data.email, }, }); } - return Response.json( - { message: "Incorrect code. Please go back and retry." }, - { status: 401 }, - ); + return Response.json({ message: t("incorrectCode") }, { status: 401 }); } diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx deleted file mode 100644 index 5d176fa8..00000000 --- a/frontend/app/layout.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { Metadata } from "next"; -import { Inter } from "next/font/google"; -import "./globals.css"; - -const inter = Inter({ subsets: ["latin"] }); - -export const metadata: Metadata = { - title: "GCopy - Sync text screenshot & file", - description: - "A clipboard synchronization web service for different devices that can synchronize text, screenshots, and files.", -}; - -export default async function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( - - {children} - - ); -} diff --git a/frontend/app/user/layout.tsx b/frontend/app/user/layout.tsx deleted file mode 100644 index 9dad29c6..00000000 --- a/frontend/app/user/layout.tsx +++ /dev/null @@ -1,11 +0,0 @@ -export default function UserLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( -
- {children} -
- ); -} diff --git a/frontend/components/avator.tsx b/frontend/components/avator.tsx index c0d6adc1..9191e1ba 100644 --- a/frontend/components/avator.tsx +++ b/frontend/components/avator.tsx @@ -2,8 +2,11 @@ import { useState } from "react"; import useSession from "@/lib/use-session"; import { defaultUserInfo } from "@/lib/types"; import { useRouter } from "next/navigation"; +import { useLocale, useTranslations } from "next-intl"; export default function Avator() { + const locale = useLocale(); + const t = useTranslations("Avator"); const [isClicked, setIsClicked] = useState(false); const router = useRouter(); const { session, logout, isLoading } = useSession(); @@ -41,10 +44,10 @@ export default function Avator() { logout(null, { optimisticData: defaultUserInfo, }); - router.push("/user/email-code"); + router.push(`/${locale}/user/email-code`); }} > - Logout + {t("logout")} diff --git a/frontend/components/footer.tsx b/frontend/components/footer.tsx index e4652531..a8b7cd08 100644 --- a/frontend/components/footer.tsx +++ b/frontend/components/footer.tsx @@ -1,4 +1,4 @@ -import * as pack from "@/package.json"; +import pack from "@/package.json"; export default function Footer() { return ( diff --git a/frontend/components/log-box.tsx b/frontend/components/log-box.tsx index 328223ed..9eb686e7 100644 --- a/frontend/components/log-box.tsx +++ b/frontend/components/log-box.tsx @@ -7,5 +7,9 @@ export default function LogBox({ logs }: { logs: Log[] }) { )); - return
{listItems}
; + return ( +
+ {listItems} +
+ ); } diff --git a/frontend/components/navbar.tsx b/frontend/components/navbar.tsx index 8f451e91..a1653ea2 100644 --- a/frontend/components/navbar.tsx +++ b/frontend/components/navbar.tsx @@ -1,8 +1,13 @@ +"use client"; + import Avator from "@/components/avator"; import Link from "next/link"; import useSession from "@/lib/use-session"; +import { useLocale, useTranslations } from "next-intl"; export default function Navbar() { + const locale = useLocale(); + const t = useTranslations("Navbar"); const { session, isLoading } = useSession(); if (isLoading) { return null; @@ -33,8 +38,15 @@ export default function Navbar() { className="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52" >
  • - - Document + + {t("title")}
  • @@ -63,8 +75,8 @@ export default function Navbar() { {session.isLoggedIn ? ( ) : ( - - Sign in + + {t("signIn")} )} diff --git a/frontend/components/notice.tsx b/frontend/components/notice.tsx index ec53a7e0..93e325e4 100644 --- a/frontend/components/notice.tsx +++ b/frontend/components/notice.tsx @@ -1,17 +1,21 @@ import Title from "./title"; +import { useTranslations } from "next-intl"; export default function Notice() { + const t = useTranslations("Notice"); + return ( <> - -
    + +
    1. - Click Allow when the browser asks you{" "} - Share clipboard? + {t.rich("shareClipboard", { + strong: (chunks) => {chunks}, + })}
    2. - We recommend using Google Chrome{" "} + {t("useChrome")}{" "} {" "} 86+.
    3. -
    4. - GCopy protects your privacy data by temporarily storing your latest - clipboard data in memory only. GCopy does not persist your clipboard - data. -
    5. +
    6. {t("protectPrivacy")}
    diff --git a/frontend/app/page.tsx b/frontend/components/sync-clipboard.tsx similarity index 66% rename from frontend/app/page.tsx rename to frontend/components/sync-clipboard.tsx index 5c557504..6a423771 100644 --- a/frontend/app/page.tsx +++ b/frontend/components/sync-clipboard.tsx @@ -1,9 +1,5 @@ "use client"; -import Navbar from "@/components/navbar"; -import Notice from "@/components/notice"; -import Title from "@/components/title"; -import Footer from "@/components/footer"; import LogBox from "@/components/log-box"; import FileLink from "@/components/file-link"; import useSession from "@/lib/use-session"; @@ -12,12 +8,16 @@ import { CursorArrowRippleIcon } from "@heroicons/react/24/solid"; import { DragEvent, useRef, useState } from "react"; import clsx from "clsx"; import { useRouter } from "next/navigation"; +import Title from "@/components/title"; +import { useLocale, useTranslations } from "next-intl"; -export default function Home() { +export default function SyncClipboard() { + const locale = useLocale(); + const t = useTranslations("SyncClipboard"); let syncLogs: Log[] = [ { level: "text-warning", - message: "click the right button to sync clipboard 👉", + message: t("log.clickButton") + " 👉", }, ]; const [logs, setLogs] = useState(syncLogs); @@ -41,7 +41,7 @@ export default function Home() { } const ensureLoggedIn = () => { if (!session.isLoggedIn) { - router.push("/user/email-code"); + router.push(`/${locale}/user/email-code`); return false; } return true; @@ -70,7 +70,7 @@ export default function Home() { }; const fetchClipboard = async () => { - addInfoLog("fetching..."); + addInfoLog(t("log.fetching") + "..."); fetch("/api/v1/clipboard", { headers: { "X-Index": clipboard.index, @@ -91,16 +91,14 @@ export default function Home() { xtype == "" || xtype == null ) { - addInfoLog("already up to date."); + addInfoLog(t("log.up2Date")); readClipboard(); return; } - addInfoLog("received " + xtype + "(" + xindex + ")"); + addInfoLog(`${t("log.received")} ${t(xtype)}(${xindex})`); if (xtype == "file") { - addSuccessLog( - "file download should start shortly. if not, please click the file link below.", - ); + addSuccessLog(t("log.downloadTip")); } let blob = await response.blob(); @@ -124,7 +122,7 @@ export default function Home() { blobId: nextBlobId, index: xindex, }); - addSuccessLog("wrote data to the clipboard successfully"); + addSuccessLog(t("log.writeClipboardSuccessfully")); }, (err) => { addErrorLog(err); @@ -156,7 +154,7 @@ export default function Home() { name: permissionClipboardRead, }); if (permission.state === "denied") { - addErrorLog("not allowed to read clipboard!"); + addErrorLog(t("log.denyReadClipboard")); return; } const clipboardItems = await navigator.clipboard.read(); @@ -178,7 +176,8 @@ export default function Home() { if (nextBlobId == clipboard.blobId) { return; } - addInfoLog("read data from the clipboard successfully."); + addInfoLog(t("log.readClipboardSuccessfully")); + addInfoLog(`${t("log.uploading")} ${t(xtype)}`); fetch("/api/v1/clipboard", { method: "POST", @@ -204,7 +203,7 @@ export default function Home() { blobId: nextBlobId, index: xindex, }); - addSuccessLog(xtype + "(" + xindex + ") uploaded."); + addSuccessLog(`${t("log.uploaded")} ${t(xtype)}(${xindex}).`); }); } } @@ -213,14 +212,14 @@ export default function Home() { const uploadFileHandler = async (file: File) => { resetLog(); if (file.size > 10 * 1024 * 1024) { - addErrorLog("sorry, the file cannot exceed 10mb!"); + addErrorLog(t("log.fileTooLarge")); return; } const nextBlobId: string = file.type + file.size + encodeURI(file.name); if (nextBlobId == clipboard.blobId) { return; } - addInfoLog("uploading file " + file.name); + addInfoLog(`${t("log.uploading")} ${file.name}`); await fetch("/api/v1/clipboard", { method: "POST", @@ -250,7 +249,7 @@ export default function Home() { fileName: file.name, fileURL: "", }); - addSuccessLog("file(" + xindex + ") uploaded."); + addSuccessLog(`${t("log.uploaded")} ${t("file")}(${xindex}).`); }); }; @@ -288,85 +287,72 @@ export default function Home() { const onDragOver = (ev: DragEvent) => { ev.preventDefault(); }; - return ( -
    -
    - -
    -
    -
    - -
    - - -
    + <> +
    + +
    + +
    +
    -
    - +
    + -
    - {!dragging && ( - <> - -
    - drag and drop a file here -
    - - - )} - { - if (inputRef.current?.files) { - const selectedFile = inputRef.current.files[0]; - await uploadFileHandler(selectedFile); - setDragging(false); - } - }} - /> -
    +
    + {!dragging && ( + <> + +
    + {t("syncFile.dragDropTip")} +
    + + + )} + { + if (inputRef.current?.files) { + const selectedFile = inputRef.current.files[0]; + await uploadFileHandler(selectedFile); + setDragging(false); + } + }} + />
    - - -
    -
    -
    +
    + ); } diff --git a/frontend/i18n.ts b/frontend/i18n.ts new file mode 100644 index 00000000..3426ee2e --- /dev/null +++ b/frontend/i18n.ts @@ -0,0 +1,14 @@ +import { notFound } from "next/navigation"; +import { getRequestConfig } from "next-intl/server"; + +// Can be imported from a shared config +const locales = ["en", "zh"]; + +export default getRequestConfig(async ({ locale }) => { + // Validate that the incoming `locale` parameter is valid + if (!locales.includes(locale as any)) notFound(); + + return { + messages: (await import(`@/messages/${locale}.json`)).default, + }; +}); diff --git a/frontend/lib/i18n.ts b/frontend/lib/i18n.ts new file mode 100644 index 00000000..81a00feb --- /dev/null +++ b/frontend/lib/i18n.ts @@ -0,0 +1,23 @@ +import { match } from "@formatjs/intl-localematcher"; +import Negotiator from "negotiator"; + +export const locales = ["en", "zh"]; +export const defaultLocale = "en"; +export function getAcceptLanguageLocale(requestHeaders: Headers) { + let locale; + const languages = new Negotiator({ + headers: { + "accept-language": requestHeaders.get("accept-language") || undefined, + }, + }).languages(); + try { + locale = match( + languages, + locales as unknown as Array, + defaultLocale, + ); + } catch (e) { + // Invalid language + } + return locale || defaultLocale; +} diff --git a/frontend/messages/en.json b/frontend/messages/en.json new file mode 100644 index 00000000..139718f7 --- /dev/null +++ b/frontend/messages/en.json @@ -0,0 +1,72 @@ +{ + "Metadata": { + "title": "GCopy - Sync text screenshot & file", + "description": "A clipboard synchronization web service for different devices that can synchronize text, screenshots, and files." + }, + "Navbar": { + "title": "Document", + "signIn": "Sign in" + }, + "Notice": { + "title": "Notice", + "shareClipboard": "Click Allow when the browser asks you if it can See text and images copied to the clipboard.", + "useChrome": "We recommend using Google Chrome", + "protectPrivacy": "GCopy protects your privacy data by temporarily storing your latest clipboard data in memory only. GCopy does not persist your clipboard data." + }, + "Avator": { + "logout": "Logout" + }, + "EmailCode": { + "title": "Sign in", + "smallTitle": "to continue to GCopy", + "subTitle": "Support for text, screenshots & file synchronization.", + "placeholder": "Enter email", + "tip": "Your privacy is important to GCopy!", + "buttonText": "Next", + "invalidEmail": "Invalid email.", + "sendEmail": { + "subject": "{code} is your verification code", + "text": "Enter the verification code when prompted: {code}. Code will expire in 5 minutes. To protect your account, do not share this code.", + "failed": "Failed to send email. Please check and retry.", + "success": "Success" + } + }, + "Login": { + "title": "Enter code", + "subTitle": "We emailed a code to {email}. Please enter the code to sign in.", + "placeholder": "Enter code", + "tip": "Your privacy is important to GCopy!", + "buttonText": "Sign in", + "invalidEmail": "Invalid email.", + "incorrectCode": "Incorrect code. Please go back and retry.", + "expiredCode": "The code was expired. Please go back and retry.", + "success": "Success" + }, + "SyncClipboard": { + "title": "Sync the clipboard", + "subTitle": "Click-click and the clipboard synced between devices.", + "text": "TEXT", + "screenshot": "SCREENSHOT", + "file": "FILE", + "syncButtonText": "Click to sync clipboard", + "log": { + "clickButton": "Click the button to sync clipboard", + "up2Date": "Already up to date.", + "received": "Received", + "uploaded": "Uploaded", + "uploading": "Uploading", + "fetching": "Fetching", + "readClipboardSuccessfully": "Read data from the clipboard successfully.", + "writeClipboardSuccessfully": "Written data to the clipboard successfully.", + "downloadTip": "File download should start shortly. if not, please click the file link below.", + "denyReadClipboard": "Not allowed to read clipboard!", + "fileTooLarge": "Sorry, the file cannot exceed 10mb!" + }, + "syncFile": { + "title": "Sync the files", + "subTitle": "Drag and drop a file here to sync it to different devices.", + "dragDropTip": "Drag and drop a file here", + "fileInputText": "Choose a file" + } + } +} diff --git a/frontend/messages/zh.json b/frontend/messages/zh.json new file mode 100644 index 00000000..ec9f037d --- /dev/null +++ b/frontend/messages/zh.json @@ -0,0 +1,72 @@ +{ + "Metadata": { + "title": "GCopy - 不同设备间的剪切板同步服务", + "description": "GCopy是一个不同设备间的剪切板同步服务, 它支持文字, 截图和文件. 只需要点击两下就实现数据同步." + }, + "Navbar": { + "title": "文档", + "signIn": "登录" + }, + "Notice": { + "title": "注意", + "shareClipboard": "当浏览器询问是否允许查看复制到剪切板的文字和图片时, 点击 允许.", + "useChrome": "我们推荐使用谷歌Chrome浏览器", + "protectPrivacy": "GCopy保护您的隐私, 它仅在内存中临时存储最新剪切板数据. GCopy并不持久化存储您的数据." + }, + "Avator": { + "logout": "登出" + }, + "EmailCode": { + "title": "登录", + "smallTitle": "以使用GCopy", + "subTitle": "支持文字, 截图和文件的同步.", + "placeholder": "输入邮箱地址", + "tip": "你的隐私安全对GCopy很重要!", + "buttonText": "下一步", + "invalidEmail": "无效的邮箱地址.", + "sendEmail": { + "subject": "{code}是您的验证码", + "text": "请输入您的验证码: {code}. 该验证码有效期5分钟. 为保护您的账户, 请不要分享这个验证码.", + "failed": "发送邮件失败, 请检查并重试.", + "success": "成功" + } + }, + "Login": { + "title": "输入验证码", + "subTitle": "我们向{email}发送了一个验证码. 请输入验证码登录.", + "placeholder": "输入验证码", + "tip": "你的隐私安全对GCopy很重要!", + "buttonText": "登录", + "invalidEmail": "无效的邮箱地址.", + "incorrectCode": "验证码不正确, 请退回重试.", + "expiredCode": "验证码过期, 请退回重试.", + "success": "成功" + }, + "SyncClipboard": { + "title": "同步剪切板", + "subTitle": "Click-click剪切板就能在不同设备间同步.", + "text": "文字", + "screenshot": "截图", + "file": "文件", + "log": { + "clickButton": "点击按钮同步剪切板", + "up2Date": "已经最新.", + "received": "已接收", + "uploaded": "已上传", + "uploading": "正在上传", + "fetching": "正在获取", + "readClipboardSuccessfully": "成功从剪切板读到数据.", + "writeClipboardSuccessfully": "数据成功写入剪切板.", + "downloadTip": "很快文件会自动下载. 如果没有, 请点击下面的文件链接.", + "denyReadClipboard": "不允许读取剪切板!", + "fileTooLarge": "对不起, 文件不能超过10mb!" + }, + "syncButtonText": "点击同步剪切板", + "syncFile": { + "title": "同步文件", + "subTitle": "把文件拖拽到这里就能在不同设备间同步它.", + "dragDropTip": "把文件拖拽到这里", + "fileInputText": "选择一个文件" + } + } +} diff --git a/frontend/middleware.ts b/frontend/middleware.ts new file mode 100644 index 00000000..923aceed --- /dev/null +++ b/frontend/middleware.ts @@ -0,0 +1,11 @@ +import createMiddleware from "next-intl/middleware"; +import { locales, defaultLocale } from "@/lib/i18n"; + +export default createMiddleware({ + locales: locales, + defaultLocale: defaultLocale, +}); + +export const config = { + matcher: ["/", "/(en|zh)/:path*"], +}; diff --git a/frontend/next.config.js b/frontend/next.config.js index 150a6f7d..9d36d3f7 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -1,3 +1,5 @@ +const withNextIntl = require("next-intl/plugin")("./i18n.ts"); + /** @type {import('next').NextConfig} */ const nextConfig = { output: "standalone", @@ -11,4 +13,4 @@ const nextConfig = { }, }; -module.exports = nextConfig; +module.exports = withNextIntl(nextConfig); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f32f158d..47dc8cb8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,18 +1,21 @@ { "name": "gcopy", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gcopy", - "version": "1.0.0", + "version": "1.1.0", "dependencies": { + "@formatjs/intl-localematcher": "^0.5.4", "@heroicons/react": "^2.1.1", "@types/nodemailer": "^6.4.14", "clsx": "^2.1.0", "iron-session": "^8.0.1", + "negotiator": "^0.6.3", "next": "14.0.4", + "next-intl": "^3.4.5", "nodemailer": "^6.9.8", "react": "^18", "react-dom": "^18", @@ -21,6 +24,7 @@ }, "devDependencies": { "@tailwindcss/typography": "^0.5.10", + "@types/negotiator": "^0.6.3", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", @@ -117,6 +121,84 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@formatjs/ecma402-abstract": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.18.2.tgz", + "integrity": "sha512-+QoPW4csYALsQIl8GbN14igZzDbuwzcpWrku9nyMXlaqAlwRBgl5V+p0vWMGFqHOw37czNXaP/lEk4wbLgcmtA==", + "dependencies": { + "@formatjs/intl-localematcher": "0.5.4", + "tslib": "^2.4.0" + } + }, + "node_modules/@formatjs/fast-memoize": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-1.2.1.tgz", + "integrity": "sha512-Rg0e76nomkz3vF9IPlKeV+Qynok0r7YZjL6syLz4/urSg0IbjPZCB/iYUMNsYA643gh4mgrX3T7KEIFIxJBQeg==", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.1.0.tgz", + "integrity": "sha512-Qxv/lmCN6hKpBSss2uQ8IROVnta2r9jd3ymUEIjm2UyIkUCHVcbUVRGL/KS/wv7876edvsPe+hjHVJ4z8YuVaw==", + "dependencies": { + "@formatjs/ecma402-abstract": "1.11.4", + "@formatjs/icu-skeleton-parser": "1.3.6", + "tslib": "^2.1.0" + } + }, + "node_modules/@formatjs/icu-messageformat-parser/node_modules/@formatjs/ecma402-abstract": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.11.4.tgz", + "integrity": "sha512-EBikYFp2JCdIfGEb5G9dyCkTGDmC57KSHhRQOC3aYxoPWVZvfWCDjZwkGYHN7Lis/fmuWl906bnNTJifDQ3sXw==", + "dependencies": { + "@formatjs/intl-localematcher": "0.2.25", + "tslib": "^2.1.0" + } + }, + "node_modules/@formatjs/icu-messageformat-parser/node_modules/@formatjs/intl-localematcher": { + "version": "0.2.25", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.2.25.tgz", + "integrity": "sha512-YmLcX70BxoSopLFdLr1Ds99NdlTI2oWoLbaUW2M406lxOIPzE1KQhRz2fPUkq34xVZQaihCoU29h0KK7An3bhA==", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.3.6.tgz", + "integrity": "sha512-I96mOxvml/YLrwU2Txnd4klA7V8fRhb6JG/4hm3VMNmeJo1F03IpV2L3wWt7EweqNLES59SZ4d6hVOPCSf80Bg==", + "dependencies": { + "@formatjs/ecma402-abstract": "1.11.4", + "tslib": "^2.1.0" + } + }, + "node_modules/@formatjs/icu-skeleton-parser/node_modules/@formatjs/ecma402-abstract": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.11.4.tgz", + "integrity": "sha512-EBikYFp2JCdIfGEb5G9dyCkTGDmC57KSHhRQOC3aYxoPWVZvfWCDjZwkGYHN7Lis/fmuWl906bnNTJifDQ3sXw==", + "dependencies": { + "@formatjs/intl-localematcher": "0.2.25", + "tslib": "^2.1.0" + } + }, + "node_modules/@formatjs/icu-skeleton-parser/node_modules/@formatjs/intl-localematcher": { + "version": "0.2.25", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.2.25.tgz", + "integrity": "sha512-YmLcX70BxoSopLFdLr1Ds99NdlTI2oWoLbaUW2M406lxOIPzE1KQhRz2fPUkq34xVZQaihCoU29h0KK7An3bhA==", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.4.tgz", + "integrity": "sha512-zTwEpWOzZ2CiKcB93BLngUX59hQkuZjT2+SAQEscSm52peDW/getsawMcWF1rGRpMCX6D7nSJA3CzJ8gn13N/g==", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@heroicons/react": { "version": "2.1.1", "resolved": "https://mirrors.huaweicloud.com/repository/npm/@heroicons/react/-/react-2.1.1.tgz", @@ -435,6 +517,12 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@types/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-JkXTOdKs5MF086b/pt8C3+yVp3iDUwG635L7oCH6HvJvvr6lSUU5oe/gLXnPEfYRROHjJIPgCV6cuAg8gGkntQ==", + "dev": true + }, "node_modules/@types/node": { "version": "20.10.5", "resolved": "https://registry.npmmirror.com/@types/node/-/node-20.10.5.tgz", @@ -2196,6 +2284,34 @@ "node": ">= 0.4" } }, + "node_modules/intl-messageformat": { + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-9.13.0.tgz", + "integrity": "sha512-7sGC7QnSQGa5LZP7bXLDhVDtQOeKGeBFGHF2Y8LVBwYZoQZCgWeKoPGTa5GMG8g/TzDgeXuYJQis7Ggiw2xTOw==", + "dependencies": { + "@formatjs/ecma402-abstract": "1.11.4", + "@formatjs/fast-memoize": "1.2.1", + "@formatjs/icu-messageformat-parser": "2.1.0", + "tslib": "^2.1.0" + } + }, + "node_modules/intl-messageformat/node_modules/@formatjs/ecma402-abstract": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.11.4.tgz", + "integrity": "sha512-EBikYFp2JCdIfGEb5G9dyCkTGDmC57KSHhRQOC3aYxoPWVZvfWCDjZwkGYHN7Lis/fmuWl906bnNTJifDQ3sXw==", + "dependencies": { + "@formatjs/intl-localematcher": "0.2.25", + "tslib": "^2.1.0" + } + }, + "node_modules/intl-messageformat/node_modules/@formatjs/intl-localematcher": { + "version": "0.2.25", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.2.25.tgz", + "integrity": "sha512-YmLcX70BxoSopLFdLr1Ds99NdlTI2oWoLbaUW2M406lxOIPzE1KQhRz2fPUkq34xVZQaihCoU29h0KK7An3bhA==", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/iron-session": { "version": "8.0.1", "resolved": "https://mirrors.huaweicloud.com/repository/npm/iron-session/-/iron-session-8.0.1.tgz", @@ -2761,6 +2877,14 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/next": { "version": "14.0.4", "resolved": "https://registry.npmmirror.com/next/-/next-14.0.4.tgz", @@ -2807,6 +2931,34 @@ } } }, + "node_modules/next-intl": { + "version": "3.4.5", + "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-3.4.5.tgz", + "integrity": "sha512-k3oCyFavZNar/JowdKfJVBIDISmcvkO32DEwRUxa46Yv6LQ3GTnOlINoaPvGr4M81gp/7WVBuYlQBZyu8aJ6Ug==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "dependencies": { + "@formatjs/intl-localematcher": "^0.2.32", + "negotiator": "^0.6.3", + "use-intl": "^3.4.5" + }, + "peerDependencies": { + "next": "^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0 || ^14.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/next-intl/node_modules/@formatjs/intl-localematcher": { + "version": "0.2.32", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.2.32.tgz", + "integrity": "sha512-k/MEBstff4sttohyEpXxCmC3MqbUn9VvHGlZ8fauLzkbwXmVrEeyzS+4uhrvAk9DWU9/7otYWxyDox4nT/KVLQ==", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.31.tgz", @@ -3980,6 +4132,18 @@ "punycode": "^2.1.0" } }, + "node_modules/use-intl": { + "version": "3.4.5", + "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-3.4.5.tgz", + "integrity": "sha512-QXDYgJMAYX+aAyNWUg1paZbdAu5DbBpwpBcflM85PxnAJ7dvRX9KCPiEXKE6/I9e88ZzMmr7gtb+t4iM/7BsCA==", + "dependencies": { + "@formatjs/ecma402-abstract": "^1.11.4", + "intl-messageformat": "^9.3.18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/use-sync-external-store": { "version": "1.2.0", "resolved": "https://mirrors.huaweicloud.com/repository/npm/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4b28e27b..0286f8e8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "gcopy", - "version": "1.0.0", + "version": "1.1.0", "private": true, "scripts": { "dev": "next dev", @@ -11,11 +11,14 @@ "prettier:check": "prettier --check --ignore-unknown ." }, "dependencies": { + "@formatjs/intl-localematcher": "^0.5.4", "@heroicons/react": "^2.1.1", "@types/nodemailer": "^6.4.14", "clsx": "^2.1.0", "iron-session": "^8.0.1", + "negotiator": "^0.6.3", "next": "14.0.4", + "next-intl": "^3.4.5", "nodemailer": "^6.9.8", "react": "^18", "react-dom": "^18", @@ -24,6 +27,7 @@ }, "devDependencies": { "@tailwindcss/typography": "^0.5.10", + "@types/negotiator": "^0.6.3", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", diff --git a/internal/server/auth.go b/internal/server/auth.go index 10eb1356..3a671a3b 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -33,7 +33,7 @@ func (s *Server) verifyAuth(c *gin.Context) { } if response.StatusCode != http.StatusOK { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"message": "unauthorized"}) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"message": "Unauthorized"}) return } @@ -51,7 +51,7 @@ func (s *Server) verifyAuth(c *gin.Context) { } if !userInfo.IsLoggedIn || userInfo.Email == "" { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"message": "unauthorized"}) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"message": "Unauthorized"}) return } diff --git a/internal/server/server.go b/internal/server/server.go index e3bd942e..0822e5b9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -71,12 +71,12 @@ func (s *Server) Run() { func (s *Server) getClipboardHandler(c *gin.Context) { subject, ok := c.Get("subject") if !ok { - c.JSON(http.StatusNotFound, gin.H{"message": "subject not found"}) + c.JSON(http.StatusNotFound, gin.H{"message": "Subject not found"}) return } sub, ok := subject.(string) if !ok { - c.JSON(http.StatusInternalServerError, gin.H{"message": "subject type assert failed"}) + c.JSON(http.StatusInternalServerError, gin.H{"message": "Subject type assert failed"}) return } cb := s.cbs.Get(sub) @@ -97,7 +97,7 @@ func (s *Server) getClipboardHandler(c *gin.Context) { c.Header("X-Type", cb.Type) c.Header("X-FileName", cb.FileName) if _, err := c.Writer.Write(cb.Data); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"message": "write data failed"}) + c.JSON(http.StatusInternalServerError, gin.H{"message": "Write data failed"}) s.log.Error(err) } } @@ -105,12 +105,12 @@ func (s *Server) getClipboardHandler(c *gin.Context) { func (s *Server) updateClipboardHandler(c *gin.Context) { subject, ok := c.Get("subject") if !ok { - c.JSON(http.StatusNotFound, gin.H{"message": "subject not found"}) + c.JSON(http.StatusNotFound, gin.H{"message": "Subject not found"}) return } sub, ok := subject.(string) if !ok { - c.JSON(http.StatusInternalServerError, gin.H{"message": "subject type assert failed"}) + c.JSON(http.StatusInternalServerError, gin.H{"message": "Subject type assert failed"}) return } @@ -120,18 +120,18 @@ func (s *Server) updateClipboardHandler(c *gin.Context) { } defer c.Request.Body.Close() if data == nil { - c.JSON(http.StatusBadRequest, gin.H{"message": "request body is nil"}) + c.JSON(http.StatusBadRequest, gin.H{"message": "Request body is nil"}) return } xType := c.Request.Header.Get("X-Type") xFileName := c.Request.Header.Get("X-FileName") if xType == "" || (xType == gcopy.TypeFile && xFileName == "") { - c.JSON(http.StatusBadRequest, gin.H{"message": "request header invalid"}) + c.JSON(http.StatusBadRequest, gin.H{"message": "Request header invalid"}) return } if xType == gcopy.TypeFile && len(data) > 10*1024*1024 { - c.JSON(http.StatusRequestEntityTooLarge, gin.H{"message": "the file cannot exceed 10mb"}) + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"message": "The file cannot exceed 10mb"}) return } @@ -150,5 +150,5 @@ func (s *Server) updateClipboardHandler(c *gin.Context) { s.cbs.Set(sub, cb) c.Header("X-Index", strconv.Itoa(cb.Index)) - c.JSON(http.StatusOK, gin.H{"message": "success"}) + c.JSON(http.StatusOK, gin.H{"message": "Success"}) }