feat: localization (#196)

* Started adding locale translations :)

* Added some more translations

* Working on translating even more pages

* More translations

* Added test default locale retrieval

* replace `intl.formatMessage` with custom `t` hook

* add more translations

* improve title syntax

* add more translations

* translate admin config page

* translated error messages

* add language selecter

* minor fixes

* improve language handling

* add upcoming languages

* add `crowdin.yml`

* run formatter

---------

Co-authored-by: Steve Tautonico <stautonico@gmail.com>
This commit is contained in:
Elias Schneider
2023-07-20 15:32:07 +02:00
committed by GitHub
parent 7c5ec8d0ea
commit b9f6e3bd08
68 changed files with 4712 additions and 461 deletions

View File

@@ -2,10 +2,13 @@ import { ActionIcon, TextInput } from "@mantine/core";
import { useClipboard } from "@mantine/hooks";
import { useRef, useState } from "react";
import { TbCheck, TbCopy } from "react-icons/tb";
import useTranslate from "../../hooks/useTranslate.hook";
import toast from "../../utils/toast.util";
function CopyTextField(props: { link: string }) {
const clipboard = useClipboard({ timeout: 500 });
const t = useTranslate();
const [checkState, setCheckState] = useState(false);
const [textClicked, setTextClicked] = useState(false);
const timerRef = useRef<number | ReturnType<typeof setTimeout> | undefined>(
@@ -14,7 +17,7 @@ function CopyTextField(props: { link: string }) {
const copyLink = () => {
clipboard.copy(props.link);
toast.success("The link was copied to your clipboard.");
toast.success(t("common.notify.copied"));
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
setCheckState(false);
@@ -25,7 +28,7 @@ function CopyTextField(props: { link: string }) {
return (
<TextInput
readOnly
label="Link"
label={t("common.text.link")}
variant="filled"
value={props.link}
onClick={() => {

View File

@@ -2,7 +2,8 @@ import { Button, Center, createStyles, Group, Text } from "@mantine/core";
import { Dropzone as MantineDropzone } from "@mantine/dropzone";
import { Dispatch, ForwardedRef, SetStateAction, useRef } from "react";
import { TbCloudUpload, TbUpload } from "react-icons/tb";
import useConfig from "../../hooks/config.hook";
import { FormattedMessage } from "react-intl";
import useTranslate from "../../hooks/useTranslate.hook";
import { FileUpload } from "../../types/File.type";
import { byteToHumanSizeString } from "../../utils/fileSize.util";
import toast from "../../utils/toast.util";
@@ -42,7 +43,7 @@ const Dropzone = ({
files: FileUpload[];
setFiles: Dispatch<SetStateAction<FileUpload[]>>;
}) => {
const config = useConfig();
const t = useTranslate();
const { classes } = useStyles();
const openRef = useRef<() => void>();
@@ -62,9 +63,9 @@ const Dropzone = ({
if (fileSizeSum > maxShareSize) {
toast.error(
`Your files exceed the maximum share size of ${byteToHumanSizeString(
maxShareSize
)}.`
t("upload.dropzone.notify.file-too-big", {
maxSize: byteToHumanSizeString(maxShareSize),
})
);
} else {
newFiles = newFiles.map((newFile) => {
@@ -82,12 +83,13 @@ const Dropzone = ({
<TbCloudUpload size={50} />
</Group>
<Text align="center" weight={700} size="lg" mt="xl">
Upload files
<FormattedMessage id="upload.dropzone.title" />
</Text>
<Text align="center" size="sm" mt="xs" color="dimmed">
Drag&apos;n&apos;drop files here to start your share. We can accept
only files that are less than {byteToHumanSizeString(maxShareSize)}{" "}
in total.
<FormattedMessage
id="upload.dropzone.description"
values={{ maxSize: byteToHumanSizeString(maxShareSize) }}
/>
</Text>
</div>
</MantineDropzone>

View File

@@ -4,6 +4,7 @@ import { TbTrash } from "react-icons/tb";
import { FileUpload } from "../../types/File.type";
import { byteToHumanSizeString } from "../../utils/fileSize.util";
import UploadProgressIndicator from "./UploadProgressIndicator";
import { FormattedMessage } from "react-intl";
const FileList = ({
files,
@@ -41,8 +42,12 @@ const FileList = ({
<Table>
<thead>
<tr>
<th>Name</th>
<th>Size</th>
<th>
<FormattedMessage id="upload.filelist.name" />
</th>
<th>
<FormattedMessage id="upload.filelist.size" />
</th>
<th></th>
</tr>
</thead>

View File

@@ -3,6 +3,10 @@ import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context";
import moment from "moment";
import { useRouter } from "next/router";
import { FormattedMessage } from "react-intl";
import useTranslate, {
translateOutsideContext,
} from "../../../hooks/useTranslate.hook";
import { Share } from "../../../types/share.type";
import CopyTextField from "../CopyTextField";
@@ -11,11 +15,12 @@ const showCompletedUploadModal = (
share: Share,
appUrl: string
) => {
const t = translateOutsideContext();
return modals.openModal({
closeOnClickOutside: false,
withCloseButton: false,
closeOnEscape: false,
title: "Share ready",
title: t("upload.modal.completed.share-ready"),
children: <Body share={share} appUrl={appUrl} />,
});
};
@@ -23,6 +28,7 @@ const showCompletedUploadModal = (
const Body = ({ share, appUrl }: { share: Share; appUrl: string }) => {
const modals = useModals();
const router = useRouter();
const t = useTranslate();
const link = `${appUrl}/share/${share.id}`;
@@ -37,10 +43,10 @@ const Body = ({ share, appUrl }: { share: Share; appUrl: string }) => {
>
{/* If our share.expiration is timestamp 0, show a different message */}
{moment(share.expiration).unix() === 0
? "This share will never expire."
: `This share will expire on ${moment(share.expiration).format(
"LLL"
)}`}
? t("upload.modal.completed.never-expires")
: t("upload.modal.completed.expires-on", {
expiration: moment(share.expiration).format("LLL"),
})}
</Text>
<Button
@@ -49,7 +55,7 @@ const Body = ({ share, appUrl }: { share: Share; appUrl: string }) => {
router.push("/upload");
}}
>
Done
<FormattedMessage id="common.button.done" />
</Button>
</Stack>
);

View File

@@ -13,14 +13,17 @@ import {
Text,
Textarea,
TextInput,
Title,
} from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form";
import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context";
import { useState } from "react";
import { TbAlertCircle } from "react-icons/tb";
import { FormattedMessage } from "react-intl";
import * as yup from "yup";
import useTranslate, {
translateOutsideContext,
} from "../../../hooks/useTranslate.hook";
import shareService from "../../../services/share.service";
import { CreateShare } from "../../../types/share.type";
import { getExpirationPreview } from "../../../utils/date.util";
@@ -36,8 +39,10 @@ const showCreateUploadModal = (
},
uploadCallback: (createShare: CreateShare) => void
) => {
const t = translateOutsideContext();
return modals.openModal({
title: "Share",
title: t("upload.modal.title"),
children: (
<CreateUploadModalBody
options={options}
@@ -61,6 +66,7 @@ const CreateUploadModalBody = ({
};
}) => {
const modals = useModals();
const t = useTranslate();
const generatedLink = Buffer.from(Math.random().toString(), "utf8")
.toString("base64")
@@ -71,11 +77,11 @@ const CreateUploadModalBody = ({
const validationSchema = yup.object().shape({
link: yup
.string()
.required()
.min(3)
.max(50)
.required(t("common.error.field-required"))
.min(3, t("common.error.too-short", { length: 3 }))
.max(50, t("common.error.too-long", { length: 50 }))
.matches(new RegExp("^[a-zA-Z0-9_-]*$"), {
message: "Can only contain letters, numbers, underscores and hyphens",
message: t("upload.modal.link.error.invalid"),
}),
password: yup.string().min(3).max(30),
maxViews: yup.number().min(1),
@@ -100,20 +106,19 @@ const CreateUploadModalBody = ({
withCloseButton
onClose={() => setShowNotSignedInAlert(false)}
icon={<TbAlertCircle size={16} />}
title="You're not signed in"
title={t("upload.modal.not-signed-in")}
color="yellow"
>
You will be unable to delete your share manually and view the visitor
count.
<FormattedMessage id="upload.modal.not-signed-in-description" />
</Alert>
)}
<form
onSubmit={form.onSubmit(async (values) => {
if (!(await shareService.isShareIdAvailable(values.link))) {
form.setFieldError("link", "This link is already in use");
form.setFieldError("link", t("upload.modal.link.error.taken"));
} else {
const expiration = form.values.never_expires
? "never"
? t("upload.modal.expires.never")
: form.values.expiration_num + form.values.expiration_unit;
uploadCallback({
id: values.link,
@@ -151,7 +156,7 @@ const CreateUploadModalBody = ({
)
}
>
Generate
<FormattedMessage id="common.button.generate" />
</Button>
</Col>
</Grid>
@@ -169,18 +174,6 @@ const CreateUploadModalBody = ({
{!options.isReverseShare && (
<>
<Grid align={form.errors.link ? "center" : "flex-end"}>
<Col xs={6}>
<NumberInput
min={1}
max={99999}
precision={0}
variant="filled"
label="Expiration"
placeholder="n"
disabled={form.values.never_expires}
{...form.getInputProps("expiration_num")}
/>
</Col>
<Col xs={6}>
<Select
disabled={form.values.never_expires}
@@ -190,41 +183,51 @@ const CreateUploadModalBody = ({
{
value: "-minutes",
label:
"Minute" +
(form.values.expiration_num == 1 ? "" : "s"),
form.values.expiration_num == 1
? t("upload.modal.expires.minute-singular")
: t("upload.modal.expires.minute-plural"),
},
{
value: "-hours",
label:
"Hour" + (form.values.expiration_num == 1 ? "" : "s"),
form.values.expiration_num == 1
? t("upload.modal.expires.hour-singular")
: t("upload.modal.expires.hour-plural"),
},
{
value: "-days",
label:
"Day" + (form.values.expiration_num == 1 ? "" : "s"),
form.values.expiration_num == 1
? t("upload.modal.expires.day-singular")
: t("upload.modal.expires.day-plural"),
},
{
value: "-weeks",
label:
"Week" + (form.values.expiration_num == 1 ? "" : "s"),
form.values.expiration_num == 1
? t("upload.modal.expires.week-singular")
: t("upload.modal.expires.week-plural"),
},
{
value: "-months",
label:
"Month" +
(form.values.expiration_num == 1 ? "" : "s"),
form.values.expiration_num == 1
? t("upload.modal.expires.month-singular")
: t("upload.modal.expires.month-plural"),
},
{
value: "-years",
label:
"Year" + (form.values.expiration_num == 1 ? "" : "s"),
form.values.expiration_num == 1
? t("upload.modal.expires.year-singular")
: t("upload.modal.expires.year-plural"),
},
]}
/>
</Col>
</Grid>
<Checkbox
label="Never Expires"
label={t("upload.modal.expires.never-long")}
{...form.getInputProps("never_expires")}
/>
<Text
@@ -234,18 +237,28 @@ const CreateUploadModalBody = ({
color: theme.colors.gray[6],
})}
>
{getExpirationPreview("share", form)}
{getExpirationPreview(
{
neverExpires: t("upload.modal.completed.never-expires"),
expiresOn: t("upload.modal.completed.expires-on"),
},
form
)}
</Text>
</>
)}
<Accordion>
<Accordion.Item value="description" sx={{ borderBottom: "none" }}>
<Accordion.Control>Description</Accordion.Control>
<Accordion.Control>
<FormattedMessage id="upload.modal.accordion.description.title" />
</Accordion.Control>
<Accordion.Panel>
<Stack align="stretch">
<Textarea
variant="filled"
placeholder="Note for the recepients"
placeholder={t(
"upload.modal.accordion.description.placeholder"
)}
{...form.getInputProps("description")}
/>
</Stack>
@@ -253,11 +266,13 @@ const CreateUploadModalBody = ({
</Accordion.Item>
{options.enableEmailRecepients && (
<Accordion.Item value="recipients" sx={{ borderBottom: "none" }}>
<Accordion.Control>Email recipients</Accordion.Control>
<Accordion.Control>
<FormattedMessage id="upload.modal.accordion.email.tile" />
</Accordion.Control>
<Accordion.Panel>
<MultiSelect
data={form.values.recipients}
placeholder="Enter email recipients"
placeholder={t("upload.modal.accordion.email.placeholder")}
searchable
{...form.getInputProps("recipients")}
creatable
@@ -266,7 +281,7 @@ const CreateUploadModalBody = ({
if (!query.match(/^\S+@\S+\.\S+$/)) {
form.setFieldError(
"recipients",
"Invalid email address"
t("upload.modal.accordion.email.invalid-email")
);
} else {
form.setFieldError("recipients", null);
@@ -283,28 +298,36 @@ const CreateUploadModalBody = ({
)}
<Accordion.Item value="security" sx={{ borderBottom: "none" }}>
<Accordion.Control>Security options</Accordion.Control>
<Accordion.Control>
<FormattedMessage id="upload.modal.accordion.security.title" />
</Accordion.Control>
<Accordion.Panel>
<Stack align="stretch">
<PasswordInput
variant="filled"
placeholder="No password"
label="Password protection"
placeholder={t(
"upload.modal.accordion.security.password.placeholder"
)}
label={t("upload.modal.accordion.security.password.label")}
{...form.getInputProps("password")}
/>
<NumberInput
min={1}
type="number"
variant="filled"
placeholder="No limit"
label="Maximal views"
placeholder={t(
"upload.modal.accordion.security.max-views.placeholder"
)}
label={t("upload.modal.accordion.security.max-views.label")}
{...form.getInputProps("maxViews")}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<Button type="submit">Share</Button>
<Button type="submit">
<FormattedMessage id="common.button.share" />
</Button>
</Stack>
</form>
</>