mirror of
https://github.com/zadam/trilium.git
synced 2025-11-01 12:09:02 +01:00
Internationalization improvements for the website (#7515)
This commit is contained in:
commit
252f8ccb1f
@ -5,6 +5,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"test": "vitest",
|
||||||
"preview": "pnpm build && vite preview"
|
"preview": "pnpm build && vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@ -39,6 +39,7 @@
|
|||||||
"web_clipper_content": "Grab web pages (or screenshots) and place them directly into Trilium using the web clipper browser extension."
|
"web_clipper_content": "Grab web pages (or screenshots) and place them directly into Trilium using the web clipper browser extension."
|
||||||
},
|
},
|
||||||
"note_types": {
|
"note_types": {
|
||||||
|
"title": "Multiple ways to represent your information",
|
||||||
"text_title": "Text notes",
|
"text_title": "Text notes",
|
||||||
"text_description": "The notes are edited using a visual (WYSIWYG) editor, with support for tables, images, math expressions, code blocks with syntax highlighting. Quickly format the text using Markdown-like syntax or using slash commands.",
|
"text_description": "The notes are edited using a visual (WYSIWYG) editor, with support for tables, images, math expressions, code blocks with syntax highlighting. Quickly format the text using Markdown-like syntax or using slash commands.",
|
||||||
"code_title": "Code notes",
|
"code_title": "Code notes",
|
||||||
@ -65,6 +66,7 @@
|
|||||||
"api_description": "Interact with Trilium programatically using its builtin REST API."
|
"api_description": "Interact with Trilium programatically using its builtin REST API."
|
||||||
},
|
},
|
||||||
"collections": {
|
"collections": {
|
||||||
|
"title": "Collections",
|
||||||
"calendar_title": "Calendar",
|
"calendar_title": "Calendar",
|
||||||
"calendar_description": "Organize your personal or professional events using a calendar, with support for all-day and multi-day events. See your events at a glance with the week, month and year views. Easy interaction to add or drag events.",
|
"calendar_description": "Organize your personal or professional events using a calendar, with support for all-day and multi-day events. See your events at a glance with the week, month and year views. Easy interaction to add or drag events.",
|
||||||
"table_title": "Table",
|
"table_title": "Table",
|
||||||
@ -106,6 +108,11 @@
|
|||||||
"linux_small": "for Linux",
|
"linux_small": "for Linux",
|
||||||
"more_platforms": "More platforms & server setup"
|
"more_platforms": "More platforms & server setup"
|
||||||
},
|
},
|
||||||
|
"header": {
|
||||||
|
"get-started": "Get started",
|
||||||
|
"documentation": "Documentation",
|
||||||
|
"support-us": "Support us"
|
||||||
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"copyright_and_the": " and the ",
|
"copyright_and_the": " and the ",
|
||||||
"copyright_community": "community"
|
"copyright_community": "community"
|
||||||
|
|||||||
@ -106,6 +106,11 @@
|
|||||||
"linux_small": "pentru Linux",
|
"linux_small": "pentru Linux",
|
||||||
"more_platforms": "Mai multe platforme și instalarea server-ului"
|
"more_platforms": "Mai multe platforme și instalarea server-ului"
|
||||||
},
|
},
|
||||||
|
"header": {
|
||||||
|
"get-started": "Primii pași",
|
||||||
|
"documentation": "Documentație",
|
||||||
|
"support-us": "Sprijină-ne"
|
||||||
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"copyright_and_the": " și ",
|
"copyright_and_the": " și ",
|
||||||
"copyright_community": "comunitatea"
|
"copyright_community": "comunitatea"
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { ComponentChildren, HTMLAttributes } from "preact";
|
import { ComponentChildren, HTMLAttributes } from "preact";
|
||||||
import { Link } from "./Button.js";
|
import { Link } from "./Button.js";
|
||||||
import Icon from "./Icon.js";
|
import Icon from "./Icon.js";
|
||||||
import { t } from "../i18n.js";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface CardProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
interface CardProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
||||||
title: ComponentChildren;
|
title: ComponentChildren;
|
||||||
@ -13,6 +13,8 @@ interface CardProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Card({ title, children, imageUrl, iconSvg, className, moreInfoUrl, ...restProps }: CardProps) {
|
export default function Card({ title, children, imageUrl, iconSvg, className, moreInfoUrl, ...restProps }: CardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`card ${className}`} {...restProps}>
|
<div className={`card ${className}`} {...restProps}>
|
||||||
{imageUrl && <img class="image" src={imageUrl} loading="lazy" />}
|
{imageUrl && <img class="image" src={imageUrl} loading="lazy" />}
|
||||||
|
|||||||
@ -3,18 +3,21 @@ import "./DownloadButton.css";
|
|||||||
import Button from "./Button.js";
|
import Button from "./Button.js";
|
||||||
import downloadIcon from "../assets/boxicons/bx-arrow-in-down-square-half.svg?raw";
|
import downloadIcon from "../assets/boxicons/bx-arrow-in-down-square-half.svg?raw";
|
||||||
import packageJson from "../../../../package.json" with { type: "json" };
|
import packageJson from "../../../../package.json" with { type: "json" };
|
||||||
import { useEffect, useState } from "preact/hooks";
|
import { useContext, useEffect, useState } from "preact/hooks";
|
||||||
import { t } from "../i18n.js";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { LocaleContext } from "../index.js";
|
||||||
|
|
||||||
interface DownloadButtonProps {
|
interface DownloadButtonProps {
|
||||||
big?: boolean;
|
big?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DownloadButton({ big }: DownloadButtonProps) {
|
export default function DownloadButton({ big }: DownloadButtonProps) {
|
||||||
|
const locale = useContext(LocaleContext);
|
||||||
|
const { t } = useTranslation();
|
||||||
const [ recommendedDownload, setRecommendedDownload ] = useState<RecommendedDownload | null>();
|
const [ recommendedDownload, setRecommendedDownload ] = useState<RecommendedDownload | null>();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getRecommendedDownload()?.then(setRecommendedDownload);
|
getRecommendedDownload(t)?.then(setRecommendedDownload);
|
||||||
}, []);
|
}, [ t ]);
|
||||||
|
|
||||||
return (recommendedDownload &&
|
return (recommendedDownload &&
|
||||||
<>
|
<>
|
||||||
@ -35,7 +38,7 @@ export default function DownloadButton({ big }: DownloadButtonProps) {
|
|||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
className={`download-button desktop-only ${big ? "big" : ""}`}
|
className={`download-button desktop-only ${big ? "big" : ""}`}
|
||||||
href="/get-started/"
|
href={`/${locale}/get-started/`}
|
||||||
iconSvg={downloadIcon}
|
iconSvg={downloadIcon}
|
||||||
text={<>
|
text={<>
|
||||||
{t("download_now.text")}
|
{t("download_now.text")}
|
||||||
|
|||||||
@ -5,17 +5,26 @@ footer {
|
|||||||
color: var(--muted-color);
|
color: var(--muted-color);
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
|
|
||||||
.content-wrapper {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-direction: column-reverse;
|
flex-direction: column-reverse;
|
||||||
gap: 2em;
|
gap: 2em;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
|
||||||
@media (min-width: 720px) {
|
@media (min-width: 720px) {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nav.languages {
|
||||||
|
flex-grow: 1;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5em 1em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.social-buttons {
|
.social-buttons {
|
||||||
|
|||||||
@ -5,12 +5,21 @@ import githubDiscussionsIcon from "../assets/boxicons/bx-discussion.svg?raw";
|
|||||||
import matrixIcon from "../assets/boxicons/bx-message-dots.svg?raw";
|
import matrixIcon from "../assets/boxicons/bx-message-dots.svg?raw";
|
||||||
import redditIcon from "../assets/boxicons/bx-reddit.svg?raw";
|
import redditIcon from "../assets/boxicons/bx-reddit.svg?raw";
|
||||||
import { Link } from "./Button.js";
|
import { Link } from "./Button.js";
|
||||||
import { t } from "../i18n";
|
import { LOCALES, swapLocaleInUrl } from "../i18n";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useLocation } from "preact-iso";
|
||||||
|
import { useContext } from "preact/hooks";
|
||||||
|
import { LocaleContext } from "..";
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { url } = useLocation();
|
||||||
|
const currentLocale = useContext(LocaleContext);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer>
|
<footer>
|
||||||
<div class="content-wrapper">
|
<div class="content-wrapper">
|
||||||
|
<div class="row">
|
||||||
<div class="footer-text">
|
<div class="footer-text">
|
||||||
© 2024-2025 <Link href="https://github.com/eliandoran" openExternally>Elian Doran</Link>{t("footer.copyright_and_the")}<Link href="https://github.com/TriliumNext/Trilium/graphs/contributors" openExternally>{t("footer.copyright_community")}</Link>.<br />
|
© 2024-2025 <Link href="https://github.com/eliandoran" openExternally>Elian Doran</Link>{t("footer.copyright_and_the")}<Link href="https://github.com/TriliumNext/Trilium/graphs/contributors" openExternally>{t("footer.copyright_community")}</Link>.<br />
|
||||||
© 2017-2024 <Link href="https://github.com/zadam" openExternally>zadam</Link>.
|
© 2017-2024 <Link href="https://github.com/zadam" openExternally>zadam</Link>.
|
||||||
@ -18,11 +27,24 @@ export default function Footer() {
|
|||||||
|
|
||||||
<SocialButtons />
|
<SocialButtons />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<nav class="languages">
|
||||||
|
{LOCALES.map(locale => (
|
||||||
|
locale.id !== currentLocale
|
||||||
|
? <Link href={swapLocaleInUrl(url, locale.id)}>{locale.name}</Link>
|
||||||
|
: <span className="active">{locale.name}</span>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SocialButtons({ className, withText }: { className?: string, withText?: boolean }) {
|
export function SocialButtons({ className, withText }: { className?: string, withText?: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`social-buttons ${className}`}>
|
<div className={`social-buttons ${className}`}>
|
||||||
<SocialButton
|
<SocialButton
|
||||||
|
|||||||
@ -1,13 +1,16 @@
|
|||||||
import "./Header.css";
|
import "./Header.css";
|
||||||
import { Link } from "./Button.js";
|
import { Link } from "./Button.js";
|
||||||
import { SocialButtons, SocialButton } from "./Footer.js";
|
import { SocialButtons, SocialButton } from "./Footer.js";
|
||||||
import { useEffect, useMemo, useState } from "preact/hooks";
|
import { useContext, useEffect, useMemo, useState } from "preact/hooks";
|
||||||
import { useLocation } from 'preact-iso';
|
import { useLocation } from 'preact-iso';
|
||||||
import DownloadButton from './DownloadButton.js';
|
import DownloadButton from './DownloadButton.js';
|
||||||
import githubIcon from "../assets/boxicons/bx-github.svg?raw";
|
import githubIcon from "../assets/boxicons/bx-github.svg?raw";
|
||||||
import Icon from "./Icon.js";
|
import Icon from "./Icon.js";
|
||||||
import logoPath from "../assets/icon-color.svg";
|
import logoPath from "../assets/icon-color.svg";
|
||||||
import menuIcon from "../assets/boxicons/bx-menu.svg?raw";
|
import menuIcon from "../assets/boxicons/bx-menu.svg?raw";
|
||||||
|
import { LocaleContext } from "..";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { swapLocaleInUrl } from "../i18n";
|
||||||
|
|
||||||
interface HeaderLink {
|
interface HeaderLink {
|
||||||
url: string;
|
url: string;
|
||||||
@ -15,21 +18,26 @@ interface HeaderLink {
|
|||||||
external?: boolean;
|
external?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HEADER_LINKS: HeaderLink[] = [
|
|
||||||
{ url: "/get-started/", text: "Get started" },
|
|
||||||
{ url: "https://docs.triliumnotes.org/", text: "Documentation", external: true },
|
|
||||||
{ url: "/support-us/", text: "Support us" }
|
|
||||||
]
|
|
||||||
|
|
||||||
export function Header(props: {repoStargazersCount: number}) {
|
export function Header(props: {repoStargazersCount: number}) {
|
||||||
const { url } = useLocation();
|
const { url } = useLocation();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const locale = useContext(LocaleContext);
|
||||||
const [ mobileMenuShown, setMobileMenuShown ] = useState(false);
|
const [ mobileMenuShown, setMobileMenuShown ] = useState(false);
|
||||||
|
|
||||||
|
const [ headerLinks, setHeaderLinks ] = useState<HeaderLink[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
setHeaderLinks([
|
||||||
|
{ url: "/get-started", text: t("header.get-started") },
|
||||||
|
{ url: "https://docs.triliumnotes.org/", text: t("header.documentation"), external: true },
|
||||||
|
{ url: "/support-us", text: t("header.support-us") }
|
||||||
|
]);
|
||||||
|
}, [ locale, t ]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header>
|
<header>
|
||||||
<div class="content-wrapper">
|
<div class="content-wrapper">
|
||||||
<div class="first-row">
|
<div class="first-row">
|
||||||
<a class="banner" href="/">
|
<a class="banner" href={`/${locale}/`}>
|
||||||
<img src={logoPath} width="300" height="300" alt="Trilium Notes logo" /> <span>Trilium Notes</span>
|
<img src={logoPath} width="300" height="300" alt="Trilium Notes logo" /> <span>Trilium Notes</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@ -46,16 +54,17 @@ export function Header(props: {repoStargazersCount: number}) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className={`${mobileMenuShown ? "mobile-shown" : ""}`}>
|
<nav className={`${mobileMenuShown ? "mobile-shown" : ""}`}>
|
||||||
{HEADER_LINKS.map(link => (
|
{headerLinks.map(link => {
|
||||||
<Link
|
const linkHref = link.external ? link.url : swapLocaleInUrl(link.url, locale);
|
||||||
href={link.url}
|
return (<Link
|
||||||
className={url === link.url ? "active" : ""}
|
href={linkHref}
|
||||||
|
className={url === linkHref ? "active" : ""}
|
||||||
openExternally={link.external}
|
openExternally={link.external}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMobileMenuShown(false);
|
setMobileMenuShown(false);
|
||||||
}}
|
}}
|
||||||
>{link.text}</Link>
|
>{link.text}</Link>)
|
||||||
))}
|
})}
|
||||||
|
|
||||||
<SocialButtons className="mobile-only" withText />
|
<SocialButtons className="mobile-only" withText />
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
|
import { TFunction } from 'i18next';
|
||||||
import rootPackageJson from '../../../package.json' with { type: "json" };
|
import rootPackageJson from '../../../package.json' with { type: "json" };
|
||||||
import { t } from './i18n';
|
|
||||||
|
|
||||||
export type App = "desktop" | "server";
|
export type App = "desktop" | "server";
|
||||||
|
|
||||||
@ -34,7 +34,8 @@ export interface RecommendedDownload {
|
|||||||
type DownloadMatrix = Record<App, { [ P in Platform ]?: DownloadMatrixEntry }>;
|
type DownloadMatrix = Record<App, { [ P in Platform ]?: DownloadMatrixEntry }>;
|
||||||
|
|
||||||
// Keep compatibility info inline with https://github.com/electron/electron/blob/main/README.md#platform-support.
|
// Keep compatibility info inline with https://github.com/electron/electron/blob/main/README.md#platform-support.
|
||||||
export const downloadMatrix: DownloadMatrix = {
|
export function getDownloadMatrix(t: TFunction<"translation", undefined>): DownloadMatrix {
|
||||||
|
return {
|
||||||
desktop: {
|
desktop: {
|
||||||
windows: {
|
windows: {
|
||||||
title: {
|
title: {
|
||||||
@ -176,9 +177,12 @@ export const downloadMatrix: DownloadMatrix = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildDownloadUrl(app: App, platform: Platform, format: string, architecture: Architecture): string {
|
export function buildDownloadUrl(t: TFunction<"translation", undefined>, app: App, platform: Platform, format: string, architecture: Architecture): string {
|
||||||
|
const downloadMatrix = getDownloadMatrix(t);
|
||||||
|
|
||||||
if (app === "desktop") {
|
if (app === "desktop") {
|
||||||
return downloadMatrix.desktop[platform]?.downloads[format].url ??
|
return downloadMatrix.desktop[platform]?.downloads[format].url ??
|
||||||
`https://github.com/TriliumNext/Trilium/releases/download/v${version}/TriliumNotes-v${version}-${platform}-${architecture}.${format}`;
|
`https://github.com/TriliumNext/Trilium/releases/download/v${version}/TriliumNotes-v${version}-${platform}-${architecture}.${format}`;
|
||||||
@ -218,8 +222,9 @@ export function getPlatform(): Platform | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRecommendedDownload(): Promise<RecommendedDownload | null> {
|
export async function getRecommendedDownload(t: TFunction<"translation", undefined>): Promise<RecommendedDownload | null> {
|
||||||
if (typeof window === "undefined") return null;
|
if (typeof window === "undefined") return null;
|
||||||
|
const downloadMatrix = getDownloadMatrix(t);
|
||||||
|
|
||||||
const architecture = await getArchitecture();
|
const architecture = await getArchitecture();
|
||||||
const platform = getPlatform();
|
const platform = getPlatform();
|
||||||
@ -233,7 +238,7 @@ export async function getRecommendedDownload(): Promise<RecommendedDownload | nu
|
|||||||
if (!recommendedDownload) return null;
|
if (!recommendedDownload) return null;
|
||||||
|
|
||||||
const format = recommendedDownload[0];
|
const format = recommendedDownload[0];
|
||||||
const url = buildDownloadUrl("desktop", platform, format || 'zip', architecture);
|
const url = buildDownloadUrl(t, "desktop", platform, format || 'zip', architecture);
|
||||||
|
|
||||||
const platformTitle = platformInfo.title;
|
const platformTitle = platformInfo.title;
|
||||||
const name = typeof platformTitle === "string" ? platformTitle : platformTitle[architecture] as string;
|
const name = typeof platformTitle === "string" ? platformTitle : platformTitle[architecture] as string;
|
||||||
|
|||||||
31
apps/website/src/i18n.spec.ts
Normal file
31
apps/website/src/i18n.spec.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { extractLocaleFromUrl, mapLocale, swapLocaleInUrl } from "./i18n";
|
||||||
|
|
||||||
|
describe("mapLocale", () => {
|
||||||
|
it("maps Chinese", () => {
|
||||||
|
expect(mapLocale("zh-TW")).toStrictEqual("zh-Hant");
|
||||||
|
expect(mapLocale("zh-CN")).toStrictEqual("zh-Hans");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps languages without countries", () => {
|
||||||
|
expect(mapLocale("ro-RO")).toStrictEqual("ro");
|
||||||
|
expect(mapLocale("ro")).toStrictEqual("ro");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("swapLocale", () => {
|
||||||
|
it("swap locale in URL", () => {
|
||||||
|
expect(swapLocaleInUrl("/get-started", "ro")).toStrictEqual("/ro/get-started");
|
||||||
|
expect(swapLocaleInUrl("/ro/get-started", "ro")).toStrictEqual("/ro/get-started");
|
||||||
|
expect(swapLocaleInUrl("/en/get-started", "ro")).toStrictEqual("/ro/get-started");
|
||||||
|
expect(swapLocaleInUrl("/ro/", "en")).toStrictEqual("/en/");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extractLocaleFromUrl", () => {
|
||||||
|
it("properly extracts locale", () => {
|
||||||
|
expect(extractLocaleFromUrl("/en/get-started")).toStrictEqual("en");
|
||||||
|
expect(extractLocaleFromUrl("/get-started")).toStrictEqual(undefined);
|
||||||
|
expect(extractLocaleFromUrl("/")).toStrictEqual(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,19 +1,50 @@
|
|||||||
import { default as i18next } from "i18next";
|
interface Locale {
|
||||||
import HttpApi from 'i18next-http-backend';
|
id: string;
|
||||||
import { initReactI18next } from "react-i18next";
|
name: string;
|
||||||
|
rtl?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
i18next
|
export const LOCALES: Locale[] = [
|
||||||
.use(HttpApi)
|
{ id: "en", name: "English" },
|
||||||
.use(initReactI18next);
|
{ id: "ro", name: "Română" },
|
||||||
|
{ id: "zh-Hans", name: "简体中文" },
|
||||||
|
{ id: "zh-Hant", name: "繁體中文" },
|
||||||
|
{ id: "fr", name: "Français" },
|
||||||
|
{ id: "it", name: "Italiano" },
|
||||||
|
{ id: "ja", name: "日本語" },
|
||||||
|
{ id: "pl", name: "Polski" },
|
||||||
|
{ id: "es", name: "Español" },
|
||||||
|
{ id: "ar", name: "اَلْعَرَبِيَّةُ", rtl: true },
|
||||||
|
].toSorted((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
await i18next.init({
|
export function mapLocale(locale: string) {
|
||||||
debug: true,
|
if (!locale) return 'en';
|
||||||
lng: "en",
|
const lower = locale.toLowerCase();
|
||||||
fallbackLng: "en",
|
|
||||||
backend: {
|
|
||||||
loadPath: "/translations/{{lng}}/{{ns}}.json",
|
|
||||||
},
|
|
||||||
returnEmptyString: false
|
|
||||||
});
|
|
||||||
|
|
||||||
export const t = i18next.t;
|
if (lower.startsWith('zh')) {
|
||||||
|
if (lower.includes('tw') || lower.includes('hk') || lower.includes('mo') || lower.includes('hant')) {
|
||||||
|
return 'zh-Hant';
|
||||||
|
}
|
||||||
|
return 'zh-Hans';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default for everything else
|
||||||
|
return locale.split('-')[0]; // e.g. "en-US" -> "en"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function swapLocaleInUrl(url: string, newLocale: string) {
|
||||||
|
const components = url.split("/");
|
||||||
|
if (components.length === 2) {
|
||||||
|
return `/${newLocale}${url}`;
|
||||||
|
} else {
|
||||||
|
components[1] = newLocale;
|
||||||
|
return components.join("/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractLocaleFromUrl(url: string) {
|
||||||
|
const localeId = url.split('/')[1];
|
||||||
|
const correspondingLocale = LOCALES.find(l => l.id === localeId);
|
||||||
|
if (!correspondingLocale) return undefined;
|
||||||
|
return localeId;
|
||||||
|
}
|
||||||
|
|||||||
@ -2,29 +2,78 @@ import './style.css';
|
|||||||
import { FALLBACK_STARGAZERS_COUNT, getRepoStargazersCount } from './github-utils.js';
|
import { FALLBACK_STARGAZERS_COUNT, getRepoStargazersCount } from './github-utils.js';
|
||||||
import { Header } from './components/Header.jsx';
|
import { Header } from './components/Header.jsx';
|
||||||
import { Home } from './pages/Home/index.jsx';
|
import { Home } from './pages/Home/index.jsx';
|
||||||
import { LocationProvider, Router, Route, hydrate, prerender as ssr } from 'preact-iso';
|
import { LocationProvider, Router, Route, hydrate, prerender as ssr, useLocation } from 'preact-iso';
|
||||||
import { NotFound } from './pages/_404.jsx';
|
import { NotFound } from './pages/_404.jsx';
|
||||||
import Footer from './components/Footer.js';
|
import Footer from './components/Footer.js';
|
||||||
import GetStarted from './pages/GetStarted/get-started.js';
|
import GetStarted from './pages/GetStarted/get-started.js';
|
||||||
import SupportUs from './pages/SupportUs/SupportUs.js';
|
import SupportUs from './pages/SupportUs/SupportUs.js';
|
||||||
|
import { createContext } from 'preact';
|
||||||
|
import { useLayoutEffect, useState } from 'preact/hooks';
|
||||||
|
import { default as i18next, changeLanguage } from 'i18next';
|
||||||
|
import { extractLocaleFromUrl, LOCALES, mapLocale } from './i18n';
|
||||||
|
import HttpApi from 'i18next-http-backend';
|
||||||
|
import { initReactI18next } from "react-i18next";
|
||||||
|
|
||||||
|
export const LocaleContext = createContext('en');
|
||||||
|
|
||||||
export function App(props: {repoStargazersCount: number}) {
|
export function App(props: {repoStargazersCount: number}) {
|
||||||
return (
|
return (
|
||||||
<LocationProvider>
|
<LocationProvider>
|
||||||
|
<LocaleProvider>
|
||||||
<Header repoStargazersCount={props.repoStargazersCount} />
|
<Header repoStargazersCount={props.repoStargazersCount} />
|
||||||
<main>
|
<main>
|
||||||
<Router>
|
<Router>
|
||||||
<Route path="/" component={Home} />
|
<Route path="/" component={Home} />
|
||||||
<Route default component={NotFound} />
|
|
||||||
<Route path="/get-started" component={GetStarted} />
|
<Route path="/get-started" component={GetStarted} />
|
||||||
<Route path="/support-us" component={SupportUs} />
|
<Route path="/support-us" component={SupportUs} />
|
||||||
|
|
||||||
|
<Route path="/:locale:/" component={Home} />
|
||||||
|
<Route path="/:locale:/get-started" component={GetStarted} />
|
||||||
|
<Route path="/:locale:/support-us" component={SupportUs} />
|
||||||
|
|
||||||
|
<Route default component={NotFound} />
|
||||||
</Router>
|
</Router>
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
</LocaleProvider>
|
||||||
</LocationProvider>
|
</LocationProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function LocaleProvider({ children }) {
|
||||||
|
const { path } = useLocation();
|
||||||
|
const localeId = mapLocale(extractLocaleFromUrl(path) || navigator.language);
|
||||||
|
const [ loaded, setLoaded ] = useState(false);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
i18next
|
||||||
|
.use(HttpApi)
|
||||||
|
.use(initReactI18next);
|
||||||
|
i18next.init({
|
||||||
|
lng: localeId,
|
||||||
|
fallbackLng: "en",
|
||||||
|
backend: {
|
||||||
|
loadPath: "/translations/{{lng}}/{{ns}}.json",
|
||||||
|
},
|
||||||
|
returnEmptyString: false
|
||||||
|
}).then(() => setLoaded(true))
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!loaded) return;
|
||||||
|
changeLanguage(localeId);
|
||||||
|
const correspondingLocale = LOCALES.find(l => l.id === localeId);
|
||||||
|
document.documentElement.lang = localeId;
|
||||||
|
document.documentElement.dir = correspondingLocale?.rtl ? "rtl" : "ltr";
|
||||||
|
}, [ loaded, localeId ]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LocaleContext.Provider value={localeId}>
|
||||||
|
{loaded && children}
|
||||||
|
</LocaleContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
hydrate(<App repoStargazersCount={FALLBACK_STARGAZERS_COUNT} />, document.getElementById('app')!);
|
hydrate(<App repoStargazersCount={FALLBACK_STARGAZERS_COUNT} />, document.getElementById('app')!);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,18 +1,20 @@
|
|||||||
import { useLayoutEffect, useState } from "preact/hooks";
|
import { useLayoutEffect, useState } from "preact/hooks";
|
||||||
import Card from "../../components/Card.js";
|
import Card from "../../components/Card.js";
|
||||||
import Section from "../../components/Section.js";
|
import Section from "../../components/Section.js";
|
||||||
import { App, Architecture, buildDownloadUrl, downloadMatrix, DownloadMatrixEntry, getArchitecture, getPlatform, Platform } from "../../download-helper.js";
|
import { App, Architecture, buildDownloadUrl, DownloadMatrixEntry, getArchitecture, getDownloadMatrix, getPlatform, Platform } from "../../download-helper.js";
|
||||||
import { usePageTitle } from "../../hooks.js";
|
import { usePageTitle } from "../../hooks.js";
|
||||||
import Button, { Link } from "../../components/Button.js";
|
import Button, { Link } from "../../components/Button.js";
|
||||||
import Icon from "../../components/Icon.js";
|
import Icon from "../../components/Icon.js";
|
||||||
import helpIcon from "../../assets/boxicons/bx-help-circle.svg?raw";
|
import helpIcon from "../../assets/boxicons/bx-help-circle.svg?raw";
|
||||||
import "./get-started.css";
|
import "./get-started.css";
|
||||||
import packageJson from "../../../../../package.json" with { type: "json" };
|
import packageJson from "../../../../../package.json" with { type: "json" };
|
||||||
import { t } from "../../i18n.js";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function DownloadPage() {
|
export default function DownloadPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [ currentArch, setCurrentArch ] = useState<Architecture>("x64");
|
const [ currentArch, setCurrentArch ] = useState<Architecture>("x64");
|
||||||
const [ userPlatform, setUserPlatform ] = useState<Platform>();
|
const [ userPlatform, setUserPlatform ] = useState<Platform>();
|
||||||
|
const downloadMatrix = getDownloadMatrix(t);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
getArchitecture().then((arch) => setCurrentArch(arch ?? "x64"));
|
getArchitecture().then((arch) => setCurrentArch(arch ?? "x64"));
|
||||||
@ -71,6 +73,7 @@ export function DownloadCard({ app, arch, entry: [ platform, entry ], isRecommen
|
|||||||
return (typeof text === "string" ? text : text[arch]);
|
return (typeof text === "string" ? text : text[arch]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
const allDownloads = Object.entries(entry.downloads);
|
const allDownloads = Object.entries(entry.downloads);
|
||||||
const recommendedDownloads = allDownloads.filter(download => download[1].recommended);
|
const recommendedDownloads = allDownloads.filter(download => download[1].recommended);
|
||||||
const restDownloads = allDownloads.filter(download => !download[1].recommended);
|
const restDownloads = allDownloads.filter(download => !download[1].recommended);
|
||||||
@ -107,7 +110,7 @@ export function DownloadCard({ app, arch, entry: [ platform, entry ], isRecommen
|
|||||||
{recommendedDownloads.map(recommendedDownload => (
|
{recommendedDownloads.map(recommendedDownload => (
|
||||||
<Button
|
<Button
|
||||||
className="recommended"
|
className="recommended"
|
||||||
href={buildDownloadUrl(app, platform as Platform, recommendedDownload[0], arch)}
|
href={buildDownloadUrl(t, app, platform as Platform, recommendedDownload[0], arch)}
|
||||||
text={recommendedDownload[1].name}
|
text={recommendedDownload[1].name}
|
||||||
openExternally={!!recommendedDownload[1].url}
|
openExternally={!!recommendedDownload[1].url}
|
||||||
/>
|
/>
|
||||||
@ -117,7 +120,7 @@ export function DownloadCard({ app, arch, entry: [ platform, entry ], isRecommen
|
|||||||
<div class="other-options">
|
<div class="other-options">
|
||||||
{restDownloads.map(download => (
|
{restDownloads.map(download => (
|
||||||
<Link
|
<Link
|
||||||
href={buildDownloadUrl(app, platform as Platform, download[0], arch)}
|
href={buildDownloadUrl(t, app, platform as Platform, download[0], arch)}
|
||||||
openExternally={!!download[1].url}
|
openExternally={!!download[1].url}
|
||||||
>
|
>
|
||||||
{download[1].name}
|
{download[1].name}
|
||||||
|
|||||||
@ -57,6 +57,8 @@ section.hero-section {
|
|||||||
color: transparent;
|
color: transparent;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
font-size: 2em;
|
||||||
|
margin-block: 0.65em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -31,8 +31,7 @@ import boardIcon from "../../assets/boxicons/bx-columns-3.svg?raw";
|
|||||||
import geomapIcon from "../../assets/boxicons/bx-map.svg?raw";
|
import geomapIcon from "../../assets/boxicons/bx-map.svg?raw";
|
||||||
import { getPlatform } from '../../download-helper.js';
|
import { getPlatform } from '../../download-helper.js';
|
||||||
import { useEffect, useState } from 'preact/hooks';
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
import { t } from '../../i18n.js';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
import { Trans } from 'react-i18next';
|
|
||||||
|
|
||||||
export function Home() {
|
export function Home() {
|
||||||
usePageTitle("");
|
usePageTitle("");
|
||||||
@ -52,6 +51,7 @@ export function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function HeroSection() {
|
function HeroSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const platform = getPlatform();
|
const platform = getPlatform();
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
const [ screenshotUrl, setScreenshotUrl ] = useState<string>();
|
const [ screenshotUrl, setScreenshotUrl ] = useState<string>();
|
||||||
@ -96,6 +96,7 @@ function HeroSection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function OrganizationBenefitsSection() {
|
function OrganizationBenefitsSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Section className="benefits" title={t("organization_benefits.title")}>
|
<Section className="benefits" title={t("organization_benefits.title")}>
|
||||||
@ -110,6 +111,7 @@ function OrganizationBenefitsSection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ProductivityBenefitsSection() {
|
function ProductivityBenefitsSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Section className="benefits accented" title={t("productivity_benefits.title")}>
|
<Section className="benefits accented" title={t("productivity_benefits.title")}>
|
||||||
@ -127,8 +129,9 @@ function ProductivityBenefitsSection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function NoteTypesSection() {
|
function NoteTypesSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Section className="note-types" title="Multiple ways to represent your information">
|
<Section className="note-types" title={t("note_types.title")}>
|
||||||
<ListWithScreenshot horizontal items={[
|
<ListWithScreenshot horizontal items={[
|
||||||
{
|
{
|
||||||
title: t("note_types.text_title"),
|
title: t("note_types.text_title"),
|
||||||
@ -190,6 +193,7 @@ function NoteTypesSection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ExtensibilityBenefitsSection() {
|
function ExtensibilityBenefitsSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Section className="benefits accented" title={t("extensibility_benefits.title")}>
|
<Section className="benefits accented" title={t("extensibility_benefits.title")}>
|
||||||
@ -205,8 +209,9 @@ function ExtensibilityBenefitsSection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CollectionsSection() {
|
function CollectionsSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Section className="collections" title="Collections">
|
<Section className="collections" title={t("collections.title")}>
|
||||||
<ListWithScreenshot items={[
|
<ListWithScreenshot items={[
|
||||||
{
|
{
|
||||||
title: t("collections.calendar_title"),
|
title: t("collections.calendar_title"),
|
||||||
@ -247,6 +252,7 @@ function ListWithScreenshot({ items, horizontal, cardExtra }: {
|
|||||||
cardExtra?: ComponentChildren;
|
cardExtra?: ComponentChildren;
|
||||||
}) {
|
}) {
|
||||||
const [ selectedItem, setSelectedItem ] = useState(items[0]);
|
const [ selectedItem, setSelectedItem ] = useState(items[0]);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`list-with-screenshot ${horizontal ? "horizontal" : ""}`}>
|
<div className={`list-with-screenshot ${horizontal ? "horizontal" : ""}`}>
|
||||||
@ -278,6 +284,7 @@ function ListWithScreenshot({ items, horizontal, cardExtra }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FaqSection() {
|
function FaqSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Section className="faq" title={t("faq.title")}>
|
<Section className="faq" title={t("faq.title")}>
|
||||||
<div class="grid-2-cols">
|
<div class="grid-2-cols">
|
||||||
@ -301,6 +308,7 @@ function FaqItem({ question, children }: { question: string; children: Component
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FinalCta() {
|
function FinalCta() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Section className="final-cta accented" title={t("final_cta.title")}>
|
<Section className="final-cta accented" title={t("final_cta.title")}>
|
||||||
<p>{t("final_cta.description")}</p>
|
<p>{t("final_cta.description")}</p>
|
||||||
|
|||||||
@ -6,10 +6,10 @@ import buyMeACoffeeIcon from "../../assets/boxicons/bx-buy-me-a-coffee.svg?raw";
|
|||||||
import Button, { Link } from "../../components/Button.js";
|
import Button, { Link } from "../../components/Button.js";
|
||||||
import Card from "../../components/Card.js";
|
import Card from "../../components/Card.js";
|
||||||
import { usePageTitle } from "../../hooks.js";
|
import { usePageTitle } from "../../hooks.js";
|
||||||
import { t } from "../../i18n.js";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { Trans } from "react-i18next";
|
|
||||||
|
|
||||||
export default function Donate() {
|
export default function Donate() {
|
||||||
|
const { t } = useTranslation();
|
||||||
usePageTitle(t("support_us.title"));
|
usePageTitle(t("support_us.title"));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import Section from "../components/Section.js";
|
import Section from "../components/Section.js";
|
||||||
import { usePageTitle } from "../hooks.js";
|
import { usePageTitle } from "../hooks.js";
|
||||||
import { t } from "../i18n.js";
|
|
||||||
import "./_404.css";
|
import "./_404.css";
|
||||||
|
|
||||||
export function NotFound() {
|
export function NotFound() {
|
||||||
|
const { t } = useTranslation();
|
||||||
usePageTitle(t("404.title"));
|
usePageTitle(t("404.title"));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -31,7 +31,13 @@ html,
|
|||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
|
font-family: Inter,
|
||||||
|
system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
|
||||||
|
"Noto Sans", "Noto Sans CJK SC",
|
||||||
|
"Hiragino Sans", "Hiragino Kaku Gothic ProN",
|
||||||
|
"Microsoft YaHei", "Meiryo", "Malgun Gothic",
|
||||||
|
"PingFang SC", "Source Han Sans SC",
|
||||||
|
"Source Han Sans JP", "Source Han Sans KR";
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -14,4 +14,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
test: {
|
||||||
|
environment: "happy-dom"
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
"desktop:start": "pnpm run --filter desktop dev",
|
"desktop:start": "pnpm run --filter desktop dev",
|
||||||
"desktop:build": "pnpm run --filter desktop build",
|
"desktop:build": "pnpm run --filter desktop build",
|
||||||
"desktop:start-prod": "pnpm run --filter desktop start-prod",
|
"desktop:start-prod": "pnpm run --filter desktop start-prod",
|
||||||
|
"website:start": "pnpm run --filter website dev",
|
||||||
"website:build": "pnpm run --filter website build",
|
"website:build": "pnpm run --filter website build",
|
||||||
"electron:build": "pnpm desktop:build",
|
"electron:build": "pnpm desktop:build",
|
||||||
"electron:start": "pnpm desktop:start",
|
"electron:start": "pnpm desktop:start",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user