Implement translations
See also: https://github.com/kytta/share2fedi/pull/43 Co-authored-by: Sunny Ripert <sunny@sunfox.org>
This commit is contained in:
parent
31d95aa834
commit
89bb338065
10 changed files with 231 additions and 29 deletions
|
@ -13,14 +13,16 @@ const { prefilledText, prefilledInstance } = Astro.props;
|
|||
action="/api/share"
|
||||
method="POST"
|
||||
>
|
||||
<label>
|
||||
<label data-translate="postText">
|
||||
Post text
|
||||
<textarea
|
||||
name="text"
|
||||
id="text"
|
||||
rows="7"
|
||||
placeholder="What's on your mind?"
|
||||
placeholder="What’s on your mind?"
|
||||
required
|
||||
data-translate="postTextPlaceholder"
|
||||
data-translate-attribute="placeholder"
|
||||
>{prefilledText}</textarea
|
||||
>
|
||||
</label>
|
||||
|
@ -30,5 +32,7 @@ const { prefilledText, prefilledInstance } = Astro.props;
|
|||
<input
|
||||
type="submit"
|
||||
value="Publish"
|
||||
data-translate="publish"
|
||||
data-translate-attribute="value"
|
||||
/>
|
||||
</form>
|
||||
|
|
|
@ -8,7 +8,7 @@ const { prefilledInstance } = Astro.props;
|
|||
---
|
||||
|
||||
<datalist id="popular-instances"></datalist>
|
||||
<label>
|
||||
<label data-translate="instance">
|
||||
Fediverse instance
|
||||
<div class="instance-input">
|
||||
<span id="https-label">https://</span>
|
||||
|
@ -16,7 +16,6 @@ const { prefilledInstance } = Astro.props;
|
|||
type="text"
|
||||
name="instance"
|
||||
id="instance"
|
||||
placeholder="mastodon.social"
|
||||
list="popular-instances"
|
||||
required
|
||||
aria-describedby="https-label"
|
||||
|
@ -25,15 +24,24 @@ const { prefilledInstance } = Astro.props;
|
|||
</div>
|
||||
</label>
|
||||
|
||||
<div id="saved-instances"></div>
|
||||
<div
|
||||
id="saved-instances"
|
||||
data-translate="previouslyUsed"
|
||||
>
|
||||
Previously used:
|
||||
<div></div>
|
||||
</div>
|
||||
|
||||
<label for="remember">
|
||||
<label
|
||||
for="remember"
|
||||
data-translate="rememberInstance"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remember"
|
||||
name="remember"
|
||||
/>
|
||||
Remember my instance on this device
|
||||
Remember instance on this device
|
||||
</label>
|
||||
|
||||
<style lang="scss">
|
||||
|
@ -61,6 +69,10 @@ const { prefilledInstance } = Astro.props;
|
|||
#saved-instances {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
> div {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
:global(span) {
|
||||
color: var(--s2f-accent-color-contrast);
|
||||
cursor: pointer;
|
||||
|
@ -87,8 +99,7 @@ const { prefilledInstance } = Astro.props;
|
|||
instanceElement.value = getUrlDomain(savedInstances[0] as string);
|
||||
}
|
||||
|
||||
document.querySelector("#saved-instances")!.replaceChildren(
|
||||
"Previously used: ",
|
||||
document.querySelector("#saved-instances>div")!.replaceChildren(
|
||||
...savedInstances
|
||||
.flatMap((instance: string) => {
|
||||
if (!instance) {
|
||||
|
|
55
src/components/language-select.astro
Normal file
55
src/components/language-select.astro
Normal file
|
@ -0,0 +1,55 @@
|
|||
---
|
||||
/*!
|
||||
* © 2023 Nikita Karamov
|
||||
* Licensed under AGPL v3 or later
|
||||
*/
|
||||
import { languages } from "@i18n/translations";
|
||||
|
||||
const initialLanguage = "en";
|
||||
---
|
||||
|
||||
<div>
|
||||
<span data-translate="language">Language:</span>
|
||||
<select
|
||||
name="language"
|
||||
id="language"
|
||||
>
|
||||
{
|
||||
Object.entries(languages).map(([k, v]) => {
|
||||
return (
|
||||
<option
|
||||
selected={k === initialLanguage}
|
||||
value={k}
|
||||
>
|
||||
{v}
|
||||
</option>
|
||||
);
|
||||
})
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import { findBestLanguage } from "@i18n/engine";
|
||||
import { applyTranslations } from "@i18n/engine";
|
||||
import { $locale } from "@stores/i18n";
|
||||
|
||||
const select: HTMLSelectElement = document.querySelector("#language")!;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
$locale.subscribe((newLocale) => {
|
||||
if (newLocale === undefined) {
|
||||
newLocale = findBestLanguage();
|
||||
}
|
||||
|
||||
applyTranslations(newLocale);
|
||||
if (select.value !== newLocale) {
|
||||
select.value = newLocale;
|
||||
}
|
||||
});
|
||||
|
||||
select.addEventListener("change", (event) => {
|
||||
$locale.set((event.target as typeof select).value);
|
||||
});
|
||||
});
|
||||
</script>
|
54
src/i18n/engine.ts
Normal file
54
src/i18n/engine.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { strings, defaultLanguage, languages } from "./translations";
|
||||
|
||||
export function useTranslations(language: string) {
|
||||
if (!(language in strings)) {
|
||||
language = defaultLanguage;
|
||||
}
|
||||
return function t(
|
||||
key: keyof (typeof strings)[typeof defaultLanguage],
|
||||
): string {
|
||||
return strings[language]![key] || strings[defaultLanguage]![key] || "";
|
||||
};
|
||||
}
|
||||
|
||||
export function findBestLanguage(): string {
|
||||
let browserLanguages = navigator.languages;
|
||||
if (!navigator.languages) browserLanguages = [navigator.language];
|
||||
for (const language of browserLanguages) {
|
||||
const locale = new Intl.Locale(language);
|
||||
const minimized = locale.minimize();
|
||||
|
||||
for (const candidate of [locale.baseName, minimized.baseName]) {
|
||||
if (candidate in languages) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return defaultLanguage;
|
||||
}
|
||||
|
||||
export function applyTranslations(language: string) {
|
||||
const t = useTranslations(language);
|
||||
|
||||
for (const node of document.querySelectorAll("[data-translate]")) {
|
||||
const dataset = (node as HTMLElement).dataset;
|
||||
|
||||
if (dataset.translateAttribute) {
|
||||
node.setAttribute(dataset.translateAttribute, t(dataset.translate!));
|
||||
continue;
|
||||
}
|
||||
|
||||
const splitTranslated = t(dataset.translate!).split("{}");
|
||||
if (splitTranslated.length === 1) {
|
||||
node.innerHTML = t(dataset.translate!);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const child of node.childNodes) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
child.textContent = splitTranslated.shift() || "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
55
src/i18n/translations.ts
Normal file
55
src/i18n/translations.ts
Normal file
|
@ -0,0 +1,55 @@
|
|||
export const languages = {
|
||||
en: "English",
|
||||
de: "Deutsch",
|
||||
};
|
||||
|
||||
export const strings: Record<keyof typeof languages, Record<string, string>> = {
|
||||
en: {
|
||||
title: "Share₂Fedi — an instance-agnostic share page for the Fediverse",
|
||||
metaDescription:
|
||||
"Share₂Fedi is a share page for Mastodon, Pleroma, Misskey, and others. Type in your post text and the instance URL and click ‘Publish!’",
|
||||
language: "Language:",
|
||||
description:
|
||||
"Share₂Fedi is an instance-agnostic share page for {}. With it, you can post to various federated platforms from a single page.",
|
||||
fediverse: "the Fediverse",
|
||||
supportedProjects: "Supported projects:",
|
||||
incl: "incl.",
|
||||
credits:
|
||||
"Share₂Fedi is developed and maintained by {}. Source code is {}. Hosted with {}. {}.",
|
||||
onGitHub: "on GitHub",
|
||||
statusPage: "Status page",
|
||||
licence: "Licence",
|
||||
privacyNotice: "Privacy Notice",
|
||||
postText: "Post text{}",
|
||||
postTextPlaceholder: "What’s on your mind?",
|
||||
instance: "Fediverse instance{}",
|
||||
previouslyUsed: "Previously used: {}",
|
||||
rememberInstance: "{} Remember instance on this device",
|
||||
publish: "Publish",
|
||||
},
|
||||
de: {
|
||||
title: "Share₂Fedi — eine instanzunabhängige Share-Seite für das Fediverse",
|
||||
metaDescription:
|
||||
"Share₂Fedi ist eine Share-Seite für Mastodon, Pleroma, Misskey und andere. Geben Sie Ihren Beitragstext und die Instanz-URL ein und klicken Sie auf „Veröffentlichen“!",
|
||||
language: "Sprache:",
|
||||
description:
|
||||
"Share₂Fedi ist eine instanzunabhängige Share-Seite für {}. Mit ihr können Sie von einer einzigen Seite aus auf verschiedenen föderierten Plattformen posten.",
|
||||
fediverse: "das Fediverse",
|
||||
supportedProjects: "Unterstützte Projekte:",
|
||||
incl: "inkl.",
|
||||
credits:
|
||||
"Share₂Fedi wird von {} entwickelt und gepflegt. Der Quellcode ist {}. Gehostet mit {}. {}.",
|
||||
onGitHub: "auf GitHub",
|
||||
statusPage: "Statusseite",
|
||||
licence: "Lizenz",
|
||||
privacyNotice: "Datenschutzhinweis",
|
||||
postText: "Beitragstext{}",
|
||||
postTextPlaceholder: "Was gibt’s Neues?",
|
||||
instance: "Fediverse-Instanz{}",
|
||||
previouslyUsed: "Bisher verwendet: {}",
|
||||
rememberInstance: "{} Instanz auf diesem Gerät merken",
|
||||
publish: "Veröffentlichen",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const defaultLanguage: keyof typeof strings = "en";
|
|
@ -16,10 +16,12 @@ const { title } = Astro.props;
|
|||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
/>
|
||||
<title>{title}</title>
|
||||
<title data-translate="title">{title}</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Share₂Fedi is a share page for Mastodon, Pleroma, Misskey, and others. Type in your post text and the instance URL and click ‘Publish!’"
|
||||
data-translate="metaDescription"
|
||||
data-translate-attribute="content"
|
||||
/>
|
||||
<link
|
||||
rel="canonical"
|
||||
|
|
|
@ -8,6 +8,8 @@ import Form from "@components/form.astro";
|
|||
import { Content as PrivacyNotice } from "@pages/_privacy.md";
|
||||
import { Content as Licence } from "@pages/_licence.md";
|
||||
|
||||
import LanguageSelect from "@components/language-select.astro";
|
||||
|
||||
const searchParameters = new URL(Astro.request.url).searchParams;
|
||||
const prefilledText = searchParameters.get("text");
|
||||
const prefilledInstance = searchParameters.get("instance");
|
||||
|
@ -21,6 +23,8 @@ const prefilledInstance = searchParameters.get("instance");
|
|||
width="195"
|
||||
height="60"
|
||||
/>
|
||||
|
||||
<LanguageSelect />
|
||||
</header>
|
||||
<main>
|
||||
<Form
|
||||
|
@ -29,43 +33,53 @@ const prefilledInstance = searchParameters.get("instance");
|
|||
/>
|
||||
</main>
|
||||
<aside>
|
||||
<p>
|
||||
<p data-translate="description">
|
||||
Share₂Fedi is an instance-agnostic share page for
|
||||
<a href="https://en.wikipedia.org/wiki/Fediverse">the Fediverse</a>. With
|
||||
it, you can post to various federated platforms from a single page.
|
||||
<a
|
||||
href="https://en.wikipedia.org/wiki/Fediverse"
|
||||
data-translate="fediverse"
|
||||
>the Fediverse</a
|
||||
>. With it, you can post to various federated platforms from a single
|
||||
page.
|
||||
</p>
|
||||
<p><b>Supported projects:</b></p>
|
||||
<p><b data-translate="supportedProjects">Supported projects:</b></p>
|
||||
<ul>
|
||||
<li>Mastodon (incl. Hometown, Fedibird, GlitchCafé)</li>
|
||||
<li>
|
||||
Pleroma (incl. Akkoma)
|
||||
Mastodon (<span data-translate="incl">incl.</span> Hometown, Fedibird,
|
||||
GlitchCafé)
|
||||
</li>
|
||||
<li>
|
||||
Misskey (incl. Firefish/Calckey, FoundKey, Meisskey)
|
||||
Pleroma (<span data-translate="incl">incl.</span> Akkoma)
|
||||
</li>
|
||||
<li>
|
||||
Friendica
|
||||
</li>
|
||||
<li>
|
||||
Hubzilla
|
||||
</li>
|
||||
<li>
|
||||
GNU Social
|
||||
Misskey (<span data-translate="incl">incl.</span> Firefish/Calckey,
|
||||
FoundKey, Meisskey)
|
||||
</li>
|
||||
<li>Friendica</li>
|
||||
<li>Hubzilla</li>
|
||||
<li>GNU Social</li>
|
||||
</ul>
|
||||
<p>
|
||||
<p data-translate="credits">
|
||||
Share₂Fedi is developed and maintained by
|
||||
<a href="https://www.kytta.dev/">Nikita Karamov</a>. Source code is
|
||||
<a href="https://github.com/kytta/share2fedi">on GitHub</a>. Hosted with
|
||||
<a
|
||||
href="https://github.com/kytta/share2fedi"
|
||||
data-translate="onGitHub"
|
||||
>on GitHub</a
|
||||
>. Hosted with
|
||||
<a href="https://vercel.com">Vercel</a>.
|
||||
<a href="https://stats.uptimerobot.com/QOXj3uXPDX">Status page</a>.
|
||||
<a
|
||||
href="https://stats.uptimerobot.com/QOXj3uXPDX"
|
||||
data-translate="statusPage"
|
||||
>Status page</a
|
||||
>.
|
||||
</p>
|
||||
<details>
|
||||
<summary>Licence</summary>
|
||||
<summary data-translate="licence">Licence</summary>
|
||||
<Licence />
|
||||
</details>
|
||||
<details>
|
||||
<summary>Privacy Notice</summary>
|
||||
<summary data-translate="privacyNotice">Privacy Notice</summary>
|
||||
<PrivacyNotice />
|
||||
</details>
|
||||
</aside>
|
||||
|
|
3
src/stores/i18n.ts
Normal file
3
src/stores/i18n.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import { persistentAtom } from "@nanostores/persistent";
|
||||
|
||||
export const $locale = persistentAtom<string | undefined>("locale");
|
|
@ -47,6 +47,9 @@ body {
|
|||
|
||||
header {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
main,
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@components/*": ["src/components/*"],
|
||||
"@i18n/*": ["src/i18n/*"],
|
||||
"@layouts/*": ["src/layouts/*"],
|
||||
"@pages/*": ["src/pages/*"],
|
||||
"@scripts/*": ["src/scripts/*"],
|
||||
|
|
Reference in a new issue