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,4 +1,7 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { formatDistanceToNow } from "date-fns";
import { de, enGB, es, fr, it, nl } from "date-fns/locale";
import {
HiOutlineArrowLeft,
HiOutlineArrowRight,
@@ -8,24 +11,24 @@ import {
HiOutlineUserMinus,
HiOutlineUserPlus,
} from "react-icons/hi2";
import type { GetCardByIdOutput } from "@kan/api/types";
import { authClient } from "@kan/auth/client";
import Avatar from "~/components/Avatar";
import { useLocalisation } from "~/hooks/useLocalisation";
import Comment from "./Comment";
import { authClient } from "@kan/auth/client";
type ActivityType =
NonNullable<GetCardByIdOutput>["activities"][number]["type"];
const ACTIVITY_TYPE_MAP = {
"card.created": "created the card",
"card.updated.title": "updated the title",
"card.updated.description": "updated the description",
"card.updated.list": "moved the card to another list",
"card.updated.label.added": "added a label to the card",
"card.updated.label.removed": "removed a label from the card",
"card.updated.member.added": "added a member to the card",
"card.updated.member.removed": "removed a member from the card",
const dateLocaleMap = {
en: enGB,
fr: fr,
de: de,
es: es,
it: it,
nl: nl,
} as const;
const getActivityText = ({
@@ -45,6 +48,17 @@ const getActivityText = ({
isSelf: boolean;
label: string | null;
}) => {
const ACTIVITY_TYPE_MAP = {
"card.created": t`created the card`,
"card.updated.title": t`updated the title`,
"card.updated.description": t`updated the description`,
"card.updated.list": t`moved the card to another list`,
"card.updated.label.added": t`added a label to the card`,
"card.updated.label.removed": t`removed a label from the card`,
"card.updated.member.added": t`added a member to the card`,
"card.updated.member.removed": t`removed a member from the card`,
} as const;
if (!(type in ACTIVITY_TYPE_MAP)) return null;
const baseText = ACTIVITY_TYPE_MAP[type as keyof typeof ACTIVITY_TYPE_MAP];
@@ -56,54 +70,54 @@ const getActivityText = ({
if (type === "card.updated.title" && toTitle) {
return (
<>
<Trans>
updated the title to <TextHighlight>{toTitle}</TextHighlight>
</>
</Trans>
);
}
if (type === "card.updated.list" && fromList && toList) {
return (
<>
<Trans>
moved the card from <TextHighlight>{fromList}</TextHighlight> to
<TextHighlight>{toList}</TextHighlight>
</>
</Trans>
);
}
if (type === "card.updated.member.added" && memberName) {
if (isSelf) return <>self-assigned the card</>;
if (isSelf) return <Trans>self-assigned the card</Trans>;
return (
<>
<Trans>
assigned <TextHighlight>{memberName}</TextHighlight> to the card
</>
</Trans>
);
}
if (type === "card.updated.member.removed" && memberName) {
if (isSelf) return <>unassigned themselves from the card</>;
if (isSelf) return <Trans>unassigned themselves from the card</Trans>;
return (
<>
<Trans>
unassigned <TextHighlight>{memberName}</TextHighlight> from the card
</>
</Trans>
);
}
if (type === "card.updated.label.added" && label) {
return (
<>
<Trans>
added label <TextHighlight>{label}</TextHighlight>
</>
</Trans>
);
}
if (type === "card.updated.label.removed" && label) {
return (
<>
<Trans>
removed label <TextHighlight>{label}</TextHighlight>
</>
</Trans>
);
}
@@ -148,6 +162,9 @@ const ActivityList = ({
isAdmin?: boolean;
}) => {
const { data } = authClient.useSession();
const { locale } = useLocalisation();
const currentDateLocale = dateLocaleMap[locale] || enGB;
return (
<div className="flex flex-col space-y-4 pt-4">
@@ -158,7 +175,7 @@ const ActivityList = ({
fromList: activity.fromList?.name ?? null,
toList: activity.toList?.name ?? null,
memberName: activity.member?.user?.name ?? null,
isSelf: activity.member?.user?.id === data?.user?.id,
isSelf: activity.member?.user?.id === data?.user.id,
label: activity.label?.name ?? null,
});
@@ -171,10 +188,10 @@ const ActivityList = ({
name={activity.user?.name ?? ""}
email={activity.user?.email ?? ""}
isLoading={isLoading}
createdAt={activity.createdAt}
createdAt={activity.createdAt.toISOString()}
comment={activity.comment?.comment}
isEdited={!!activity.comment?.updatedAt}
isAuthor={activity.comment?.createdBy === data?.user?.id}
isAuthor={activity.comment?.createdBy === data?.user.id}
isAdmin={isAdmin ?? false}
/>
);
@@ -213,6 +230,7 @@ const ActivityList = ({
<span className="space-x-1 text-light-900 dark:text-dark-800">
{formatDistanceToNow(new Date(activity.createdAt), {
addSuffix: true,
locale: currentDateLocale,
})}
</span>
</p>

View File

@@ -1,3 +1,4 @@
import { t } from "@lingui/core/macro";
import { formatDistanceToNow } from "date-fns";
import { useState } from "react";
import ContentEditable from "react-contenteditable";
@@ -57,8 +58,8 @@ const Comment = ({
},
onError: () => {
showPopup({
header: "Unable to update comment",
message: "Please try again later, or contact customer support.",
header: t`Unable to update comment`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
@@ -76,7 +77,7 @@ const Comment = ({
...(isAuthor
? [
{
label: "Edit comment",
label: t`Edit comment`,
action: () => setIsEditing(true),
icon: <HiPencil className="h-[16px] w-[16px] text-dark-900" />,
},
@@ -85,7 +86,7 @@ const Comment = ({
...(isAuthor || isAdmin
? [
{
label: "Delete comment",
label: t`Delete comment`,
action: () => openModal("DELETE_COMMENT", publicId),
icon: <HiTrash className="h-[16px] w-[16px] text-dark-900" />,
},
@@ -117,7 +118,7 @@ const Comment = ({
</span>
{isEdited && (
<span className="text-light-900 dark:text-dark-800">
{" (edited)"}
{t` (edited)`}
</span>
)}
</p>
@@ -140,7 +141,7 @@ const Comment = ({
) : (
<form onSubmit={handleSubmit(onSubmit)}>
<ContentEditable
placeholder="Add a comment..."
placeholder={t`Add a comment...`}
html={watch("comment")}
disabled={false}
onChange={(e) => setValue("comment", e.target.value)}
@@ -152,14 +153,14 @@ const Comment = ({
variant="ghost"
onClick={() => setIsEditing(false)}
>
Cancel
{t`Cancel`}
</Button>
<Button
isLoading={updateCommentMutation.isPending}
type="submit"
size="sm"
>
Save
{t`Save`}
</Button>
</div>
</form>

View File

@@ -1,4 +1,5 @@
import { useRouter } from "next/navigation";
import { t } from "@lingui/core/macro";
import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
@@ -47,8 +48,8 @@ export function DeleteCardConfirmation({
onError: (_error, _newList, context) => {
utils.board.byId.setData(queryParams, context?.previousState);
showPopup({
header: "Unable to delete card",
message: "Please try again later, or contact customer support.",
header: t`Unable to delete card`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
@@ -71,10 +72,10 @@ export function DeleteCardConfirmation({
<div className="p-5">
<div className="flex w-full flex-col justify-between pb-4">
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
Are you sure you want to delete this card?
{t`Are you sure you want to delete this card?`}
</h2>
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
{"This action can't be undone."}
{t`This action can't be undone.`}
</p>
</div>
<div className="mt-5 flex justify-end sm:mt-6">
@@ -82,13 +83,13 @@ export function DeleteCardConfirmation({
className="mr-4 inline-flex justify-center rounded-md border-[1px] border-light-600 bg-light-50 px-3 py-2 text-sm font-semibold text-neutral-900 shadow-sm focus-visible:outline-none dark:border-dark-600 dark:bg-dark-300 dark:text-dark-1000"
onClick={() => closeModal()}
>
Cancel
{t`Cancel`}
</button>
<Button
onClick={handleDeleteCard}
isLoading={deleteCardMutation.isPending}
>
Delete
{t`Delete`}
</Button>
</div>
</div>

View File

@@ -1,3 +1,5 @@
import { t } from "@lingui/core/macro";
import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
@@ -39,8 +41,8 @@ export function DeleteCommentConfirmation({
onError: (_error, _newList, context) => {
utils.card.byId.setData(queryParams, context?.previousState);
showPopup({
header: "Unable to delete comment",
message: "Please try again later, or contact customer support.",
header: t`Unable to delete comment`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
@@ -60,10 +62,10 @@ export function DeleteCommentConfirmation({
<div className="p-5">
<div className="flex w-full flex-col justify-between pb-4">
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
Are you sure you want to delete this comment?
{t`Are you sure you want to delete this comment?`}
</h2>
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
{"This action can't be undone."}
{t`This action can't be undone.`}
</p>
</div>
<div className="mt-5 flex justify-end sm:mt-6">
@@ -71,13 +73,13 @@ export function DeleteCommentConfirmation({
className="mr-4 inline-flex justify-center rounded-md border-[1px] border-light-600 bg-light-50 px-3 py-2 text-sm font-semibold text-neutral-900 shadow-sm focus-visible:outline-none dark:border-dark-600 dark:bg-dark-300 dark:text-dark-1000"
onClick={() => closeModal()}
>
Cancel
{t`Cancel`}
</button>
<Button
onClick={handleDeleteComment}
isLoading={deleteCommentMutation.isPending}
>
Delete
{t`Delete`}
</Button>
</div>
</div>

View File

@@ -1,3 +1,4 @@
import { t } from "@lingui/core/macro";
import { HiEllipsisHorizontal, HiOutlineTrash } from "react-icons/hi2";
import Dropdown from "~/components/Dropdown";
@@ -10,7 +11,7 @@ export default function BoardDropdown() {
<Dropdown
items={[
{
label: "Delete card",
label: t`Delete card`,
action: () => openModal("DELETE_CARD"),
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
},

View File

@@ -1,4 +1,5 @@
import { Menu } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { HiMiniPlus } from "react-icons/hi2";
import Badge from "~/components/Badge";
@@ -68,8 +69,8 @@ export default function LabelSelector({
onError: (_error, _newList, context) => {
utils.card.byId.setData({ cardPublicId }, context?.previousCard);
showPopup({
header: "Unable to update labels",
message: "Please try again later, or contact customer support.",
header: t`Unable to update labels`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
@@ -94,7 +95,7 @@ export default function LabelSelector({
}}
handleEdit={(labelPublicId) => openModal("EDIT_LABEL", labelPublicId)}
handleCreate={() => openModal("NEW_LABEL")}
createNewItemLabel="Create new label"
createNewItemLabel={t`Create new label`}
asChild
>
{selectedLabels.length ? (
@@ -105,13 +106,16 @@ export default function LabelSelector({
</Menu.Button>
))}
<Menu.Button>
<Badge value="Add label" iconLeft={<HiMiniPlus size={14} />} />
<Badge
value={t`Add label`}
iconLeft={<HiMiniPlus size={14} />}
/>
</Menu.Button>
</div>
) : (
<Menu.Button className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-200 pl-2 text-left text-sm text-neutral-900 hover:bg-light-300 dark:border-dark-100 dark:text-dark-1000 dark:hover:border-dark-300 dark:hover:bg-dark-200">
<HiMiniPlus size={22} className="pr-2" />
Add label
{t`Add label`}
</Menu.Button>
)}
</CheckboxDropdown>

View File

@@ -1,4 +1,5 @@
import { Menu } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import CheckboxDropdown from "~/components/CheckboxDropdown";
import { usePopup } from "~/providers/popup";
@@ -48,8 +49,8 @@ export default function ListSelector({
onError: (_error, _newList, context) => {
utils.card.byId.setData({ cardPublicId }, context?.previousCard);
showPopup({
header: "Unable to update list",
message: "Please try again later, or contact customer support.",
header: t`Unable to update list`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},

View File

@@ -1,5 +1,6 @@
import { useRouter } from "next/router";
import { Menu } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { HiMiniPlus } from "react-icons/hi2";
import Avatar from "~/components/Avatar";
@@ -55,6 +56,8 @@ export default function MemberSelector({
...oldCard.members,
{
publicId: update.workspaceMemberPublicId,
email: memberToAdd?.email ?? "",
deletedAt: null,
user: {
id: memberToAdd?.user?.id ?? "",
name: memberToAdd?.user?.name ?? "",
@@ -73,8 +76,8 @@ export default function MemberSelector({
onError: (_error, _newList, context) => {
utils.card.byId.setData({ cardPublicId }, context?.previousCard);
showPopup({
header: "Unable to update members",
message: "Please try again later, or contact customer support.",
header: t`Unable to update members`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
@@ -106,7 +109,7 @@ export default function MemberSelector({
});
}}
handleCreate={handleInviteMember}
createNewItemLabel="Invite member"
createNewItemLabel={t`Invite member`}
asChild
>
<Menu.Button className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-200 py-1 pl-2 text-left text-sm text-neutral-900 hover:bg-light-300 dark:border-dark-100 dark:text-dark-1000 dark:hover:border-dark-300 dark:hover:bg-dark-200">
@@ -124,7 +127,7 @@ export default function MemberSelector({
) : (
<>
<HiMiniPlus size={22} className="pr-2" />
{"Add member"}
{t`Add member`}
</>
)}
</Menu.Button>

View File

@@ -1,3 +1,4 @@
import { t } from "@lingui/core/macro";
import ContentEditable from "react-contenteditable";
import { useForm } from "react-hook-form";
import { HiOutlineArrowUp } from "react-icons/hi2";
@@ -26,8 +27,8 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
const addCommentMutation = api.card.addComment.useMutation({
onError: (_error, _newList) => {
showPopup({
header: "Unable to add comment",
message: "Please try again later, or contact customer support.",
header: t`Unable to add comment`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
@@ -53,7 +54,7 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
className="flex w-full max-w-[1000px] flex-col rounded-xl border border-light-600 bg-light-200 p-4 text-light-900 focus-visible:outline-none dark:border-dark-400 dark:bg-dark-100 dark:text-dark-1000 sm:text-sm sm:leading-6"
>
<ContentEditable
placeholder="Add a comment..."
placeholder={t`Add a comment...`}
html={watch("comment")}
disabled={false}
onChange={(e) => setValue("comment", e.target.value)}