feat: localisation and multi-lang support (#95)

* feat: setup localisation with lingui

* Fix hydration and locale issues

* Add missing translations and tweak selector styling

* Extend language support

* Update lang support

* Extend lang support

* Fix build and add dyanmic imports
This commit is contained in:
Henry
2025-06-25 21:30:24 +01:00
committed by GitHub
parent 014fdbb7d8
commit dd061c6d00
79 changed files with 11822 additions and 883 deletions

View File

@@ -1,5 +1,7 @@
import type { SocialProvider } from "better-auth/social-providers";
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useQuery } from "@tanstack/react-query";
import { env } from "next-runtime-env";
import { useState } from "react";
@@ -183,8 +185,8 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
{
onSuccess: () =>
showPopup({
header: "Success",
message: "You have been signed up successfully.",
header: t`Success`,
message: t`You have been signed up successfully.`,
icon: "success",
}),
onError: ({ error }) => setLoginError(error.message),
@@ -200,8 +202,8 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
{
onSuccess: () =>
showPopup({
header: "Success",
message: "You have been logged in successfully.",
header: t`Success`,
message: t`You have been logged in successfully.`,
icon: "success",
}),
onError: ({ error }) => setLoginError(error.message),
@@ -236,7 +238,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
if (error) {
setLoginError(
`Failed to login with ${provider.at(0)?.toUpperCase() + provider.slice(1)}. Please try again.`,
t`Failed to login with ${provider.at(0)?.toUpperCase() + provider.slice(1)}. Please try again.`,
);
}
};
@@ -263,7 +265,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
fullWidth
size="lg"
>
Continue with {provider.name}
<Trans>Continue with {provider.name}</Trans>
</Button>
);
})}
@@ -274,7 +276,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
<div className="mb-[1.5rem] flex w-full items-center gap-4">
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
<span className="text-sm text-light-900 dark:text-dark-900">
or
{t`or`}
</span>
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
</div>
@@ -284,11 +286,11 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
<div>
<Input
{...register("name", { required: true })}
placeholder="Enter your name"
placeholder={t`Enter your name`}
/>
{errors.name && (
<p className="mt-2 text-xs text-red-400">
Please enter a valid name
{t`Please enter a valid name`}
</p>
)}
</div>
@@ -296,11 +298,11 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
<div>
<Input
{...register("email", { required: true })}
placeholder="Enter your email address"
placeholder={t`Enter your email address`}
/>
{errors.email && (
<p className="mt-2 text-xs text-red-400">
Please enter a valid email address
{t`Please enter a valid email address`}
</p>
)}
</div>
@@ -309,11 +311,11 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
<Input
type="password"
{...register("password", { required: true })}
placeholder="Enter your password"
placeholder={t`Enter your password`}
/>
{errors.password && (
<p className="mt-2 text-xs text-red-400">
Please enter a valid password
{t`Please enter a valid password`}
</p>
)}
</div>
@@ -329,10 +331,10 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
size="lg"
variant="secondary"
>
{isSignUp ? "Sign up with " : "Continue with "}
{isSignUp ? t`Sign up with ` : t`Continue with `}
{!isCredentialsEnabled || (password && password.length !== 0)
? "email"
: "magic link"}
? t`email`
: t`magic link`}
</Button>
</div>
</form>

View File

@@ -5,6 +5,7 @@ import type {
} from "@tiptap/suggestion";
import type { Instance as TippyInstance } from "tippy.js";
import { Button } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import Link from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
import {
@@ -298,7 +299,7 @@ export default function Editor({
Placeholder.configure({
placeholder: readOnly
? ""
: "Add description... (type '/' to open commands)",
: t`Add description... (type '/' to open commands)`,
}),
Link.configure({
openOnClick: true,

View File

@@ -1,5 +1,6 @@
import Link from "next/link";
import { Menu, Transition } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { Fragment, useRef, useState } from "react";
import { useForm } from "react-hook-form";
@@ -34,15 +35,15 @@ const FeedbackButton: React.FC = () => {
onSuccess: async () => {
reset();
showPopup({
header: "Feedback sent",
message: "Thank you for your feedback!",
header: t`Feedback sent`,
message: t`Thank you for your feedback!`,
icon: "success",
});
},
onError: async () => {
showPopup({
header: "Unable to send feedback",
message: "Please try again later, or contact customer support.",
header: t`Unable to send feedback`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
@@ -74,7 +75,7 @@ const FeedbackButton: React.FC = () => {
json={activeTheme === "dark" ? chatIconDark : chatIconLight}
isPlaying={isHovered}
/>
<span className="ml-1">Feedback</span>
<span className="ml-1">{t`Feedback`}</span>
</Menu.Button>
</div>
@@ -94,7 +95,7 @@ const FeedbackButton: React.FC = () => {
>
<form onSubmit={handleSubmit(onSubmit)} className="p-1">
<Input
placeholder="Ideas to improve this page..."
placeholder={t`Ideas to improve this page...`}
onChange={(e) => {
setValue("feedback", e.target.value);
}}
@@ -112,28 +113,28 @@ const FeedbackButton: React.FC = () => {
<div className="flex flex-row items-center justify-between pt-2">
<div>
<p className="ml-2 text-xs text-neutral-900 dark:text-dark-1000">
Need help?{" "}
{t`Need help?`}{" "}
<Link
href="mailto:support@kan.bn"
className="text-blue-600 underline dark:text-blue-300"
>
Contact us
{t`Contact us`}
</Link>
, or see our{" "}
{t`, or see our`}{" "}
<Link
href="https://docs.kan.bn"
target="_blank"
rel="noreferrer"
className="text-blue-600 underline dark:text-blue-300"
>
docs
{t`docs`}
</Link>
.
</p>
</div>
<div className="justify-end">
<Button size="sm" isLoading={createFeedback.isPending}>
Send
{t`Send`}
</Button>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { Listbox, Transition } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { Fragment, useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { HiChevronUpDown, HiXMark } from "react-icons/hi2";
@@ -114,7 +115,7 @@ export function LabelForm({
<div className="px-5 pt-5">
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
<h2 className="text-sm font-medium">
{isEdit ? "Edit label" : "New label"}
{isEdit ? t`Edit label` : t`New label`}
</h2>
<button
type="button"
@@ -130,7 +131,7 @@ export function LabelForm({
<Input
id="label-name"
placeholder="Name"
placeholder={t`Name`}
{...register("name")}
onKeyDown={async (e) => {
if (e.key === "Enter") {
@@ -208,7 +209,7 @@ export function LabelForm({
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
{!isEdit && (
<Toggle
label="Create another"
label={t`Create another`}
isChecked={!!isCreateAnotherEnabled}
onChange={() =>
setValue("isCreateAnotherEnabled", !isCreateAnotherEnabled)
@@ -222,14 +223,14 @@ export function LabelForm({
variant="secondary"
onClick={() => openModal("DELETE_LABEL", entityId)}
>
Delete
{t`Delete`}
</Button>
)}
<Button
type="submit"
isLoading={updateLabel.isPending || createLabel.isPending}
>
{isEdit ? "Update label" : "Create label"}
{isEdit ? t`Update label` : t`Create label`}
</Button>
</div>
</div>

View File

@@ -0,0 +1,26 @@
import { HiLanguage } from "react-icons/hi2";
import { useLocalisation } from "~/hooks/useLocalisation";
import { localeNames } from "~/locales";
export function LanguageSelector() {
const { locale, setLocale, availableLocales } = useLocalisation();
return (
<div className="relative">
<HiLanguage className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<select
id="language-select"
value={locale}
onChange={(e) => setLocale(e.target.value as any)}
className="mt-8 block w-full max-w-[180px] rounded-lg border-0 bg-light-50 pl-10 shadow-sm ring-1 ring-inset ring-light-300 focus:ring-2 focus:ring-inset focus:ring-light-400 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500 sm:text-sm"
>
{availableLocales.map((loc) => (
<option key={loc} value={loc}>
{localeNames[loc]}
</option>
))}
</select>
</div>
);
}

View File

@@ -1,3 +1,4 @@
import { t } from "@lingui/core/macro";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2";
@@ -34,8 +35,8 @@ export function NewWorkspaceForm() {
},
onError: () => {
showPopup({
header: "Unable to create workspace",
message: "Please try again later, or contact customer support.",
header: t`Unable to create workspace`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
@@ -58,7 +59,7 @@ export function NewWorkspaceForm() {
<div className="px-5 pt-5">
<div className="flex w-full items-center justify-between pb-4">
<h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000">
New workspace
{t`New workspace`}
</h2>
<button
type="button"
@@ -74,7 +75,7 @@ export function NewWorkspaceForm() {
<Input
id="workspace-name"
placeholder="Workspace name"
placeholder={t`Workspace name`}
{...register("name")}
onKeyDown={async (e) => {
if (e.key === "Enter") {
@@ -87,7 +88,7 @@ export function NewWorkspaceForm() {
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div>
<Button type="submit" isLoading={createWorkspace.isPending}>
Create workspace
{t`Create workspace`}
</Button>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import boardsIconDark from "~/assets/boards-dark.json";
import boardsIconLight from "~/assets/boards-light.json";
@@ -35,17 +36,17 @@ export default function SideNavigation({
const navigation = [
{
name: "Boards",
name: t`Boards`,
href: "/boards",
icon: isDarkMode ? boardsIconDark : boardsIconLight,
},
{
name: "Members",
name: t`Members`,
href: "/members",
icon: isDarkMode ? membersIconDark : membersIconLight,
},
{
name: "Settings",
name: t`Settings`,
href: "/settings",
icon: isDarkMode ? settingsIconDark : settingsIconLight,
},

View File

@@ -1,6 +1,7 @@
import Image from "next/image";
import { useRouter } from "next/navigation";
import { Menu, Transition } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { Fragment } from "react";
import { authClient } from "@kan/auth/client";
@@ -81,7 +82,7 @@ export default function UserMenu({
<div className="flex flex-col text-neutral-900 dark:text-dark-1000">
<div className="p-1">
<div className="flex w-full items-center px-3 py-2 text-left text-xs">
<span>Theme</span>
<span>{t`Theme`}</span>
</div>
<Menu.Item>
<button
@@ -94,7 +95,7 @@ export default function UserMenu({
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
)}
/>
System
{t`System`}
</button>
</Menu.Item>
<Menu.Item>
@@ -108,7 +109,7 @@ export default function UserMenu({
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
)}
/>
Dark
{t`Dark`}
</button>
</Menu.Item>
<Menu.Item>
@@ -122,7 +123,7 @@ export default function UserMenu({
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
)}
/>
Light
{t`Light`}
</button>
</Menu.Item>
</div>
@@ -132,7 +133,7 @@ export default function UserMenu({
onClick={handleLogout}
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
>
Logout
{t`Logout`}
</button>
</Menu.Item>
</div>

View File

@@ -1,4 +1,5 @@
import { Menu, Transition } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { Fragment } from "react";
import { HiCheck } from "react-icons/hi2";
@@ -81,7 +82,7 @@ export default function WorkspaceMenu() {
onClick={() => openModal("NEW_WORKSPACE")}
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
>
Create workspace
{t`Create workspace`}
</button>
</Menu.Item>
</div>