Compare commits

...

36 commits

Author SHA1 Message Date
lewisakura a1ef2c4010
Merge branch 'dev' into feat/translation 2024-07-05 21:07:34 +01:00
lewisakura 946738e069
chore: cloud settings i18n 2024-07-01 13:16:36 +01:00
lewisakura 77d059dd8f
chore: fix i18n ally 2024-07-01 13:13:16 +01:00
lewisakura d216bdb6bb
chore: add reauthorise key 2024-07-01 13:09:21 +01:00
lewisakura 3f3d5379cf
Merge branch 'dev' into feat/translation 2024-07-01 13:08:29 +01:00
Lewis Crichton 70cc39b5c6
chore: remove unnecessary assertions 2024-06-24 19:01:57 +01:00
Lewis Crichton 2cd94221cd
chore: even better if condition 2024-06-24 19:00:54 +01:00
Lewis Crichton faea1c8224
chore: remove unnecessary if 2024-06-24 19:00:26 +01:00
Lewis Crichton e55c6f99e2
fix: objects counted as plural incorrectly 2024-06-23 21:18:43 +01:00
Lewis Crichton cc7675eccb
chore: boop 2024-06-23 21:14:58 +01:00
Lewis Crichton fcc86370f9
Merge branch 'feat/translation' of ssh://github.com/Vendicated/Vencord into feat/translation 2024-06-23 21:11:46 +01:00
Lewis Crichton cc22c15bcb
merge: dev 2024-06-23 21:11:31 +01:00
lewisakura bbd0729ed6
Merge branch 'dev' into feat/translation 2024-06-19 08:43:47 +01:00
Lewis Crichton 51770e96f2
feat: translate clienttheme plugin as test 2024-06-11 23:12:13 +01:00
Lewis Crichton b92a21ac7d
chore: BIKESHEDDING IS OVER, T PREVAILS 2024-06-11 22:31:06 +01:00
Lewis Crichton 8c4aed699d
chore: better path getter func 2024-06-11 22:25:09 +01:00
Lewis Crichton 26c21c2de8
ci: parse $t calls as valid descriptions 2024-06-11 22:21:19 +01:00
Lewis Crichton 15394e106a
chore: fix showConnections?? 2024-06-11 22:03:21 +01:00
lewisakura 9cc9c57f11
Merge branch 'dev' into feat/translation 2024-06-11 22:01:41 +01:00
Lewis Crichton 38624a8661
chore: use Object.create for better semantics 2024-06-09 21:57:54 +01:00
Lewis Crichton ec5f9f78d3
feat: testing top levels 2024-06-09 21:50:18 +01:00
Lewis Crichton ef028edc0d
feat: top level hax 2024-06-09 21:47:03 +01:00
Lewis Crichton a4d4d981e0
chore: more localisation 2024-06-09 10:59:03 +01:00
Lewis Crichton 22e5396684
chore: restructure locale keys 2024-06-08 19:51:56 +01:00
Lewis Crichton d1242633e5
feat: cached translations, use translation coremod, add terrible german 2024-06-08 19:23:56 +01:00
Lewis Crichton becf4a4c4f
Merge branch 'feat/translation' of ssh://github.com/Vendicated/Vencord into feat/translation 2024-06-08 19:03:53 +01:00
lewisakura c5c0732ffe
Merge branch 'dev' into feat/translation 2024-06-08 19:03:48 +01:00
Lewis Crichton f1e1f9cd44
Merge branch 'dev' of ssh://github.com/Vendicated/Vencord into feat/translation 2024-06-08 19:02:50 +01:00
Lewis Crichton d349689c6a
feat: 0 always uses 'zero' plural rule 2024-06-08 18:21:02 +01:00
Lewis Crichton 16549695d1
feat: initial implementation of translation component 2024-06-08 18:20:12 +01:00
Masterjoona caf1779be3
fix showconnections in new profiles (#2567)
Co-authored-by: Vendicated <vendicated@riseup.net>
2024-06-08 16:33:58 +00:00
Lewis Crichton 7ed73b49e5
feat: start translating 2024-06-03 18:31:56 +01:00
Lewis Crichton 9c9a02f9bf
feat: plurality 2024-06-03 17:39:17 +01:00
Lewis Crichton c0111169b8
feat: translation v2 2024-06-01 11:50:18 +01:00
Lewis Crichton 2dc0c20462
Merge branch 'dev' of ssh://github.com/Vendicated/Vencord into dev 2024-06-01 10:37:03 +01:00
Lewis Crichton 42307ccc0e
fix: Vencord_cloudSecret check (#2077)
finally got around to fixing it - `null` is never a valid return value,
it's `undefined` 🤦
2023-12-28 02:02:49 +00:00
30 changed files with 679 additions and 188 deletions

View file

@ -4,6 +4,7 @@
"EditorConfig.EditorConfig", "EditorConfig.EditorConfig",
"GregorBiswanger.json2ts", "GregorBiswanger.json2ts",
"stylelint.vscode-stylelint", "stylelint.vscode-stylelint",
"Vendicated.vencord-companion" "Vendicated.vencord-companion",
"lokalise.i18n-ally"
] ]
} }

16
.vscode/i18n-ally-custom-framework.yml vendored Normal file
View file

@ -0,0 +1,16 @@
languageIds:
- javascript
- typescript
- javascriptreact
- typescriptreact
usageMatchRegex:
- "[^\\w\\d]t\\(['\"`]({key})['\"`]"
- "<Translate ?.* i18nKey=\\{?['\"`]({key})['\"`]"
refactorTemplates:
- "t(\"$1\")"
- "{t(\"$1\")}"
- "<Translate i18nKey=\"$1\" />"
monopoly: true

View file

@ -21,5 +21,11 @@
"domain": "codeberg.org", "domain": "codeberg.org",
"type": "Gitea" "type": "Gitea"
} }
] ],
"i18n-ally.namespace": true,
"i18n-ally.localesPaths": ["./translations"],
"i18n-ally.sourceLanguage": "en",
"i18n-ally.extract.keygenStyle": "camelCase",
"i18n-ally.sortKeys": true,
"i18n-ally.keystyle": "nested"
} }

View file

@ -34,6 +34,7 @@
"testTsc": "tsc --noEmit" "testTsc": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@fluent/langneg": "^0.7.0",
"@sapphi-red/web-noise-suppressor": "0.3.3", "@sapphi-red/web-noise-suppressor": "0.3.3",
"@vap/core": "0.0.12", "@vap/core": "0.0.12",
"@vap/shiki": "0.10.5", "@vap/shiki": "0.10.5",

View file

@ -16,6 +16,9 @@ importers:
.: .:
dependencies: dependencies:
'@fluent/langneg':
specifier: ^0.7.0
version: 0.7.0
'@sapphi-red/web-noise-suppressor': '@sapphi-red/web-noise-suppressor':
specifier: 0.3.3 specifier: 0.3.3
version: 0.3.3 version: 0.3.3
@ -385,6 +388,10 @@ packages:
resolution: {integrity: sha512-a8TLtmPi8xzPkCbp/OGFUo5yhRkHM2Ko9kOWP4znJr0WAhWyThaw3PnwX4vOTWOAMsV2uRt32PPDcEz63esSaA==} resolution: {integrity: sha512-a8TLtmPi8xzPkCbp/OGFUo5yhRkHM2Ko9kOWP4znJr0WAhWyThaw3PnwX4vOTWOAMsV2uRt32PPDcEz63esSaA==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
'@fluent/langneg@0.7.0':
resolution: {integrity: sha512-StAM0vgsD1QK+nFikaKs9Rxe3JGNipiXrpmemNGwM4gWERBXPe9gjzsBoKjgBgq1Vyiy+xy/C652QIWY+MPyYw==}
engines: {node: '>=14.0.0', npm: '>=7.0.0'}
'@humanwhocodes/config-array@0.11.10': '@humanwhocodes/config-array@0.11.10':
resolution: {integrity: sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==} resolution: {integrity: sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==}
engines: {node: '>=10.10.0'} engines: {node: '>=10.10.0'}
@ -2665,6 +2672,8 @@ snapshots:
'@eslint/js@8.46.0': {} '@eslint/js@8.46.0': {}
'@fluent/langneg@0.7.0': {}
'@humanwhocodes/config-array@0.11.10': '@humanwhocodes/config-array@0.11.10':
dependencies: dependencies:
'@humanwhocodes/object-schema': 1.2.1 '@humanwhocodes/object-schema': 1.2.1

View file

@ -292,6 +292,39 @@ export const stylePlugin = {
} }
}; };
/**
* @type {import("esbuild").Plugin}
*/
export const translationPlugin = {
name: "translation-plugin",
setup: ({ onResolve, onLoad }) => {
const filter = /^~translations$/;
onResolve({ filter }, ({ path }) => ({
namespace: "translations", path
}));
onLoad({ filter, namespace: "translations" }, async () => {
const translations = {};
const locales = await readdir("./translations");
for (const locale of locales) {
const translationBundles = await readdir(`./translations/${locale}`);
for (const bundle of translationBundles) {
const name = bundle.replace(/\.json$/, "");
translations[locale] ??= {};
translations[locale][name] = JSON.parse(await readFile(`./translations/${locale}/${bundle}`, "utf-8"));
}
}
return {
contents: `export default ${JSON.stringify(translations)}`,
};
});
}
};
/** /**
* @type {import("esbuild").BuildOptions} * @type {import("esbuild").BuildOptions}
*/ */
@ -303,8 +336,8 @@ export const commonOpts = {
sourcemap: watch ? "inline" : "", sourcemap: watch ? "inline" : "",
legalComments: "linked", legalComments: "linked",
banner, banner,
plugins: [fileUrlPlugin, gitHashPlugin, gitRemotePlugin, stylePlugin], plugins: [fileUrlPlugin, gitHashPlugin, gitRemotePlugin, stylePlugin, translationPlugin],
external: ["~plugins", "~git-hash", "~git-remote", "/assets/*"], external: ["~plugins", "~git-hash", "~git-remote", "~translations", "/assets/*"],
inject: ["./scripts/build/inject/react.mjs"], inject: ["./scripts/build/inject/react.mjs"],
jsxFactory: "VencordCreateElement", jsxFactory: "VencordCreateElement",
jsxFragment: "VencordFragment", jsxFragment: "VencordFragment",

View file

@ -20,7 +20,7 @@ import { Dirent, readdirSync, readFileSync, writeFileSync } from "fs";
import { access, readFile } from "fs/promises"; import { access, readFile } from "fs/promises";
import { join, sep } from "path"; import { join, sep } from "path";
import { normalize as posixNormalize, sep as posixSep } from "path/posix"; import { normalize as posixNormalize, sep as posixSep } from "path/posix";
import { BigIntLiteral, createSourceFile, Identifier, isArrayLiteralExpression, isCallExpression, isExportAssignment, isIdentifier, isObjectLiteralExpression, isPropertyAccessExpression, isPropertyAssignment, isSatisfiesExpression, isStringLiteral, isVariableStatement, NamedDeclaration, NodeArray, ObjectLiteralExpression, ScriptTarget, StringLiteral, SyntaxKind } from "typescript"; import { BigIntLiteral, CallExpression, createSourceFile, Identifier, isArrayLiteralExpression, isCallExpression, isExportAssignment, isIdentifier, isObjectLiteralExpression, isPropertyAccessExpression, isPropertyAssignment, isSatisfiesExpression, isStringLiteral, isVariableStatement, LiteralExpression, NamedDeclaration, Node, NodeArray, ObjectLiteralExpression, ScriptTarget, StringLiteral, SyntaxKind } from "typescript";
import { getPluginTarget } from "./utils.mjs"; import { getPluginTarget } from "./utils.mjs";
@ -90,6 +90,38 @@ function parseDevs() {
throw new Error("Could not find Devs constant"); throw new Error("Could not find Devs constant");
} }
function isTranslationExpression(node: Node): node is CallExpression {
if (!isCallExpression(node)) return false;
const literal = node.expression as LiteralExpression;
if (literal.text !== "t") return false;
return true;
}
async function getTranslation(node: Node): Promise<string | null> {
if (!isTranslationExpression(node)) return null;
const translationString = node.arguments[0];
if (!isStringLiteral(translationString)) return null;
const splitPath = translationString.text.split(".");
const namespace = splitPath.shift();
const path = splitPath.join(".");
// load the namespace
const bundle = JSON.parse(
await readFile(`./translations/en/${namespace}.json`, "utf-8")
);
const dotProp = (key: string, object: any) =>
key.split(".").reduce((obj, key) => obj?.[key], object);
return dotProp(path, bundle);
}
async function parseFile(fileName: string) { async function parseFile(fileName: string) {
const file = createSourceFile(fileName, await readFile(fileName, "utf8"), ScriptTarget.Latest); const file = createSourceFile(fileName, await readFile(fileName, "utf8"), ScriptTarget.Latest);
@ -120,10 +152,16 @@ async function parseFile(fileName: string) {
switch (key) { switch (key) {
case "name": case "name":
case "description":
if (!isStringLiteral(value)) throw fail(`${key} is not a string literal`); if (!isStringLiteral(value)) throw fail(`${key} is not a string literal`);
data[key] = value.text; data[key] = value.text;
break; break;
case "description":
if (isStringLiteral(value))
data[key] = value.text;
else if (isTranslationExpression(value))
data[key] = (await getTranslation(value))!;
else throw fail(`${key} is not a string literal or a translation function call`);
break;
case "patches": case "patches":
data.hasPatches = true; data.hasPatches = true;
break; break;

View file

@ -38,6 +38,7 @@ import { patches, PMLogger, startAllPlugins } from "./plugins";
import { localStorage } from "./utils/localStorage"; import { localStorage } from "./utils/localStorage";
import { relaunch } from "./utils/native"; import { relaunch } from "./utils/native";
import { getCloudSettings, putCloudSettings } from "./utils/settingsSync"; import { getCloudSettings, putCloudSettings } from "./utils/settingsSync";
import { t } from "./utils/translation";
import { checkForUpdates, update, UpdateLogger } from "./utils/updater"; import { checkForUpdates, update, UpdateLogger } from "./utils/updater";
import { onceReady } from "./webpack"; import { onceReady } from "./webpack";
import { SettingsRouter } from "./webpack/common"; import { SettingsRouter } from "./webpack/common";
@ -54,9 +55,8 @@ async function syncSettings() {
) { ) {
// show a notification letting them know and tell them how to fix it // show a notification letting them know and tell them how to fix it
showNotification({ showNotification({
title: "Cloud Integrations", title: t("vencord.cloudIntegrations"),
body: "We've noticed you have cloud integrations enabled in another client! Due to limitations, you will " + body: t("vencord.cloud.integrations.reauthenticate"),
"need to re-authenticate to continue using them. Click here to go to the settings page to do so!",
color: "var(--yellow-360)", color: "var(--yellow-360)",
onClick: () => SettingsRouter.open("VencordCloud") onClick: () => SettingsRouter.open("VencordCloud")
}); });
@ -76,8 +76,8 @@ async function syncSettings() {
// there was an error to notify the user, but besides that we only want to show one notification instead of all // there was an error to notify the user, but besides that we only want to show one notification instead of all
// of the possible ones it has (such as when your settings are newer). // of the possible ones it has (such as when your settings are newer).
showNotification({ showNotification({
title: "Cloud Settings", title: t("vencord.cloudSettings"),
body: "Your settings have been updated! Click here to restart to fully apply changes!", body: t("vencord.cloud.settings.updated"),
color: "var(--green-360)", color: "var(--green-360)",
onClick: relaunch onClick: relaunch
}); });
@ -100,8 +100,8 @@ async function init() {
await update(); await update();
if (Settings.autoUpdateNotification) if (Settings.autoUpdateNotification)
setTimeout(() => showNotification({ setTimeout(() => showNotification({
title: "Vencord has been updated!", title: t("vencord.update.updated"),
body: "Click here to restart", body: t("vencord.update.clickToRestart"),
permanent: true, permanent: true,
noPersist: true, noPersist: true,
onClick: relaunch onClick: relaunch
@ -110,8 +110,8 @@ async function init() {
} }
setTimeout(() => showNotification({ setTimeout(() => showNotification({
title: "A Vencord update is available!", title: t("vencord.update.available"),
body: "Click here to view the update", body: t("vencord.update.clickToView"),
permanent: true, permanent: true,
noPersist: true, noPersist: true,
onClick: openUpdaterModal! onClick: openUpdaterModal!

View file

@ -19,6 +19,7 @@
import { Logger } from "@utils/Logger"; import { Logger } from "@utils/Logger";
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
import { LazyComponent } from "@utils/react"; import { LazyComponent } from "@utils/react";
import { t } from "@utils/translation";
import { React } from "@webpack/common"; import { React } from "@webpack/common";
import { ErrorCard } from "./ErrorCard"; import { ErrorCard } from "./ErrorCard";
@ -85,11 +86,11 @@ const ErrorBoundary = LazyComponent(() => {
{...this.state} {...this.state}
/>; />;
const msg = this.props.message || "An error occurred while rendering this Component. More info can be found below and in your console."; const msg = this.props.message || t("vencord.errorBoundaryDescription");
return ( return (
<ErrorCard style={{ overflow: "hidden" }}> <ErrorCard style={{ overflow: "hidden" }}>
<h1>Oh no!</h1> <h1>{t("vencord.ohNo")}</h1>
<p>{msg}</p> <p>{msg}</p>
<code> <code>
{this.state.message} {this.state.message}

View file

@ -12,8 +12,9 @@ import ErrorBoundary from "@components/ErrorBoundary";
import { Link } from "@components/Link"; import { Link } from "@components/Link";
import { DevsById } from "@utils/constants"; import { DevsById } from "@utils/constants";
import { fetchUserProfile } from "@utils/discord"; import { fetchUserProfile } from "@utils/discord";
import { classes, pluralise } from "@utils/misc"; import { classes } from "@utils/misc";
import { ModalContent, ModalRoot, openModal } from "@utils/modal"; import { ModalContent, ModalRoot, openModal } from "@utils/modal";
import { t, Translate } from "@utils/translation";
import { Forms, showToast, useEffect, useMemo, UserProfileStore, useStateFromStores } from "@webpack/common"; import { Forms, showToast, useEffect, useMemo, UserProfileStore, useStateFromStores } from "@webpack/common";
import { User } from "discord-types/general"; import { User } from "discord-types/general";
@ -60,8 +61,6 @@ function ContributorModal({ user }: { user: User; }) {
.sort((a, b) => Number(a.required ?? false) - Number(b.required ?? false)); .sort((a, b) => Number(a.required ?? false) - Number(b.required ?? false));
}, [user.id, user.username]); }, [user.id, user.username]);
const ContributedHyperLink = <Link href="https://vencord.dev/source">contributed</Link>;
return ( return (
<> <>
<div className={cl("header")}> <div className={cl("header")}>
@ -88,15 +87,11 @@ function ContributorModal({ user }: { user: User; }) {
</div> </div>
</div> </div>
{plugins.length ? (
<Forms.FormText> <Forms.FormText>
This person has {ContributedHyperLink} to {pluralise(plugins.length, "plugin")}! <Translate i18nKey="vencord.pluginContributed" variables={{ count: plugins.length }}>
<Link href="https://vencord.dev/source" />
</Translate>
</Forms.FormText> </Forms.FormText>
) : (
<Forms.FormText>
This person has not made any plugins. They likely {ContributedHyperLink} to Vencord in other ways!
</Forms.FormText>
)}
{!!plugins.length && ( {!!plugins.length && (
<div className={cl("plugins")}> <div className={cl("plugins")}>
@ -105,7 +100,7 @@ function ContributorModal({ user }: { user: User; }) {
key={p.name} key={p.name}
plugin={p} plugin={p}
disabled={p.required ?? false} disabled={p.required ?? false}
onRestartNeeded={() => showToast("Restart to apply changes!")} onRestartNeeded={() => showToast(t("vencord.pluginRestart"))}
/> />
)} )}
</div> </div>

View file

@ -28,6 +28,7 @@ import { proxyLazy } from "@utils/lazy";
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
import { classes, isObjectEmpty } from "@utils/misc"; import { classes, isObjectEmpty } from "@utils/misc";
import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal"; import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { t } from "@utils/translation";
import { OptionType, Plugin } from "@utils/types"; import { OptionType, Plugin } from "@utils/types";
import { findByPropsLazy, findComponentByCodeLazy } from "@webpack"; import { findByPropsLazy, findComponentByCodeLazy } from "@webpack";
import { Button, Clickable, FluxDispatcher, Forms, React, Text, Tooltip, UserStore, UserUtils } from "@webpack/common"; import { Button, Clickable, FluxDispatcher, Forms, React, Text, Tooltip, UserStore, UserUtils } from "@webpack/common";
@ -138,7 +139,7 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
function renderSettings() { function renderSettings() {
if (!hasSettings || !plugin.options) { if (!hasSettings || !plugin.options) {
return <Forms.FormText>There are no settings for this plugin.</Forms.FormText>; return <Forms.FormText>{t("vencord.noSettings")}</Forms.FormText>;
} else { } else {
const options = Object.entries(plugin.options).map(([key, setting]) => { const options = Object.entries(plugin.options).map(([key, setting]) => {
if (setting.hidden) return null; if (setting.hidden) return null;
@ -274,7 +275,7 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
</div> </div>
)} )}
<Forms.FormSection className={Margins.bottom16}> <Forms.FormSection className={Margins.bottom16}>
<Forms.FormTitle tag="h3">Settings</Forms.FormTitle> <Forms.FormTitle tag="h3">{t("vencord.settings")}</Forms.FormTitle>
{renderSettings()} {renderSettings()}
</Forms.FormSection> </Forms.FormSection>
</ModalContent> </ModalContent>
@ -289,7 +290,7 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
> >
Cancel Cancel
</Button> </Button>
<Tooltip text="You must fix all errors before saving" shouldShow={!canSubmit()}> <Tooltip text={t("vencord.settingsErrors")} shouldShow={!canSubmit()}>
{({ onMouseEnter, onMouseLeave }) => ( {({ onMouseEnter, onMouseLeave }) => (
<Button <Button
size={Button.Sizes.SMALL} size={Button.Sizes.SMALL}
@ -299,12 +300,12 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
onMouseLeave={onMouseLeave} onMouseLeave={onMouseLeave}
disabled={!canSubmit()} disabled={!canSubmit()}
> >
Save & Close {t("vencord.saveAndClose")}
</Button> </Button>
)} )}
</Tooltip> </Tooltip>
</Flex> </Flex>
{saveError && <Text variant="text-md/semibold" style={{ color: "var(--text-danger)" }}>Error while saving: {saveError}</Text>} {saveError && <Text variant="text-md/semibold" style={{ color: "var(--text-danger)" }}>{t("vencord.settingsSaveError", { saveError })}</Text>}
</Flex> </Flex>
</ModalFooter>} </ModalFooter>}
</ModalRoot> </ModalRoot>

View file

@ -32,6 +32,7 @@ import { Logger } from "@utils/Logger";
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
import { classes, isObjectEmpty } from "@utils/misc"; import { classes, isObjectEmpty } from "@utils/misc";
import { useAwaiter } from "@utils/react"; import { useAwaiter } from "@utils/react";
import { t } from "@utils/translation";
import { Plugin } from "@utils/types"; import { Plugin } from "@utils/types";
import { findByPropsLazy } from "@webpack"; import { findByPropsLazy } from "@webpack";
import { Alerts, Button, Card, Forms, lodash, Parser, React, Select, Text, TextInput, Toasts, Tooltip, useMemo } from "@webpack/common"; import { Alerts, Button, Card, Forms, lodash, Parser, React, Select, Text, TextInput, Toasts, Tooltip, useMemo } from "@webpack/common";
@ -64,19 +65,19 @@ function ReloadRequiredCard({ required }: { required: boolean; }) {
<Card className={cl("info-card", { "restart-card": required })}> <Card className={cl("info-card", { "restart-card": required })}>
{required ? ( {required ? (
<> <>
<Forms.FormTitle tag="h5">Restart required!</Forms.FormTitle> <Forms.FormTitle tag="h5">{t("vencord.pluginHeader.reloadHeader")}</Forms.FormTitle>
<Forms.FormText className={cl("dep-text")}> <Forms.FormText className={cl("dep-text")}>
Restart now to apply new plugins and their settings {t("vencord.pluginHeader.reloadDescription")}
</Forms.FormText> </Forms.FormText>
<Button onClick={() => location.reload()}> <Button onClick={() => location.reload()}>
Restart {t("vencord.pluginHeader.restart")}
</Button> </Button>
</> </>
) : ( ) : (
<> <>
<Forms.FormTitle tag="h5">Plugin Management</Forms.FormTitle> <Forms.FormTitle tag="h5">{t("vencord.pluginHeader.managementHeader")}</Forms.FormTitle>
<Forms.FormText>Press the cog wheel or info icon to get more info on a plugin</Forms.FormText> <Forms.FormText>{t("vencord.pluginHeader.iconInformation")}</Forms.FormText>
<Forms.FormText>Plugins with a cog wheel have settings you can modify!</Forms.FormText> <Forms.FormText>{t("vencord.pluginHeader.cogWheel")}</Forms.FormText>
</> </>
)} )}
</Card> </Card>
@ -209,10 +210,10 @@ export default function PluginSettings() {
React.useEffect(() => { React.useEffect(() => {
return () => void (changes.hasChanges && Alerts.show({ return () => void (changes.hasChanges && Alerts.show({
title: "Restart required", title: t("vencord.restartRequired"),
body: ( body: (
<> <>
<p>The following plugins require a restart:</p> <p>$t("vencord.pluginsNeedRestart")</p>
<div>{changes.map((s, i) => ( <div>{changes.map((s, i) => (
<> <>
{i > 0 && ", "} {i > 0 && ", "}
@ -221,8 +222,8 @@ export default function PluginSettings() {
))}</div> ))}</div>
</> </>
), ),
confirmText: "Restart now", confirmText: t("vencord.restartNow"),
cancelText: "Later!", cancelText: t("vencord.restartLater"),
onConfirm: () => location.reload() onConfirm: () => location.reload()
})); }));
}, []); }, []);
@ -296,7 +297,7 @@ export default function PluginSettings() {
if (isRequired) { if (isRequired) {
const tooltipText = p.required const tooltipText = p.required
? "This plugin is required for Vencord to function." ? t("vencord.requiredPlugin")
: makeDependencyList(depMap[p.name]?.filter(d => settings.plugins[d].enabled)); : makeDependencyList(depMap[p.name]?.filter(d => settings.plugins[d].enabled));
requiredPlugins.push( requiredPlugins.push(
@ -331,18 +332,18 @@ export default function PluginSettings() {
<ReloadRequiredCard required={changes.hasChanges} /> <ReloadRequiredCard required={changes.hasChanges} />
<Forms.FormTitle tag="h5" className={classes(Margins.top20, Margins.bottom8)}> <Forms.FormTitle tag="h5" className={classes(Margins.top20, Margins.bottom8)}>
Filters {t("vencord.pluginFilters")}
</Forms.FormTitle> </Forms.FormTitle>
<div className={classes(Margins.bottom20, cl("filter-controls"))}> <div className={classes(Margins.bottom20, cl("filter-controls"))}>
<TextInput autoFocus value={searchValue.value} placeholder="Search for a plugin..." onChange={onSearch} /> <TextInput autoFocus value={searchValue.value} placeholder={t("vencord.search.placeholder")} onChange={onSearch} />
<div className={InputStyles.inputWrapper}> <div className={InputStyles.inputWrapper}>
<Select <Select
options={[ options={[
{ label: "Show All", value: SearchStatus.ALL, default: true }, { label: t("vencord.search.all"), value: SearchStatus.ALL, default: true },
{ label: "Show Enabled", value: SearchStatus.ENABLED }, { label: t("vencord.search.enabled"), value: SearchStatus.ENABLED },
{ label: "Show Disabled", value: SearchStatus.DISABLED }, { label: t("vencord.search.disabled"), value: SearchStatus.DISABLED },
{ label: "Show New", value: SearchStatus.NEW } { label: t("vencord.search.new"), value: SearchStatus.NEW }
]} ]}
serialize={String} serialize={String}
select={onStatusChange} select={onStatusChange}
@ -353,7 +354,7 @@ export default function PluginSettings() {
</div> </div>
</div> </div>
<Forms.FormTitle className={Margins.top20}>Plugins</Forms.FormTitle> <Forms.FormTitle className={Margins.top20}>{t("vencord.plugins")}</Forms.FormTitle>
{plugins.length || requiredPlugins.length {plugins.length || requiredPlugins.length
? ( ? (
@ -371,7 +372,7 @@ export default function PluginSettings() {
<Forms.FormDivider className={Margins.top20} /> <Forms.FormDivider className={Margins.top20} />
<Forms.FormTitle tag="h5" className={classes(Margins.top20, Margins.bottom8)}> <Forms.FormTitle tag="h5" className={classes(Margins.top20, Margins.bottom8)}>
Required Plugins {t("vencord.requiredPlugins")}
</Forms.FormTitle> </Forms.FormTitle>
<div className={cl("grid")}> <div className={cl("grid")}>
{requiredPlugins.length {requiredPlugins.length
@ -386,7 +387,7 @@ export default function PluginSettings() {
function makeDependencyList(deps: string[]) { function makeDependencyList(deps: string[]) {
return ( return (
<React.Fragment> <React.Fragment>
<Forms.FormText>This plugin is required by:</Forms.FormText> <Forms.FormText>{t("vencord.pluginRequiredBy")}</Forms.FormText>
{deps.map((dep: string) => <Forms.FormText className={cl("dep-text")}>{dep}</Forms.FormText>)} {deps.map((dep: string) => <Forms.FormText className={cl("dep-text")}>{dep}</Forms.FormText>)}
</React.Fragment> </React.Fragment>
); );

View file

@ -21,6 +21,7 @@ import "./addonCard.css";
import { classNameFactory } from "@api/Styles"; import { classNameFactory } from "@api/Styles";
import { Badge } from "@components/Badge"; import { Badge } from "@components/Badge";
import { Switch } from "@components/Switch"; import { Switch } from "@components/Switch";
import { t } from "@utils/translation";
import { Text, useRef } from "@webpack/common"; import { Text, useRef } from "@webpack/common";
import type { MouseEventHandler, ReactNode } from "react"; import type { MouseEventHandler, ReactNode } from "react";
@ -67,7 +68,7 @@ export function AddonCard({ disabled, isNew, name, infoButton, footer, author, e
> >
{name} {name}
</div> </div>
</div>{isNew && <Badge text="NEW" color="#ED4245" />} </div>{isNew && <Badge text={t("vencord.new")} color="#ED4245" />}
</Text> </Text>
{!!author && ( {!!author && (
<Text variant="text-md/normal" className={cl("author")}> <Text variant="text-md/normal" className={cl("author")}>

View file

@ -20,6 +20,7 @@ import { Flex } from "@components/Flex";
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
import { classes } from "@utils/misc"; import { classes } from "@utils/misc";
import { downloadSettingsBackup, uploadSettingsBackup } from "@utils/settingsSync"; import { downloadSettingsBackup, uploadSettingsBackup } from "@utils/settingsSync";
import { t } from "@utils/translation";
import { Button, Card, Text } from "@webpack/common"; import { Button, Card, Text } from "@webpack/common";
import { SettingsTab, wrapTab } from "./shared"; import { SettingsTab, wrapTab } from "./shared";
@ -29,21 +30,19 @@ function BackupRestoreTab() {
<SettingsTab title="Backup & Restore"> <SettingsTab title="Backup & Restore">
<Card className={classes("vc-settings-card", "vc-backup-restore-card")}> <Card className={classes("vc-settings-card", "vc-backup-restore-card")}>
<Flex flexDirection="column"> <Flex flexDirection="column">
<strong>Warning</strong> <strong>{t("vencord.warning")}</strong>
<span>Importing a settings file will overwrite your current settings.</span> <span>{t("vencord.backupAndRestore.importWarning")}</span>
</Flex> </Flex>
</Card> </Card>
<Text variant="text-md/normal" className={Margins.bottom8}> <Text variant="text-md/normal" className={Margins.bottom8}>
You can import and export your Vencord settings as a JSON file. {t("vencord.backupAndRestore.description")}
This allows you to easily transfer your settings to another device,
or recover your settings after reinstalling Vencord or Discord.
</Text> </Text>
<Text variant="text-md/normal" className={Margins.bottom8}> <Text variant="text-md/normal" className={Margins.bottom8}>
Settings Export contains: {t("vencord.backupAndRestore.exportContains")}
<ul> <ul>
<li>&mdash; Custom QuickCSS</li> <li>&mdash; {t("vencord.backupAndRestore.customQuickcss")}</li>
<li>&mdash; Theme Links</li> <li>&mdash; {t("vencord.backupAndRestore.themeLinks")}</li>
<li>&mdash; Plugin Settings</li> <li>&mdash; {t("vencord.backupAndRestore.pluginSettings")}</li>
</ul> </ul>
</Text> </Text>
<Flex> <Flex>
@ -51,13 +50,13 @@ function BackupRestoreTab() {
onClick={() => uploadSettingsBackup()} onClick={() => uploadSettingsBackup()}
size={Button.Sizes.SMALL} size={Button.Sizes.SMALL}
> >
Import Settings {t("vencord.backupAndRestore.importSettings")}
</Button> </Button>
<Button <Button
onClick={downloadSettingsBackup} onClick={downloadSettingsBackup}
size={Button.Sizes.SMALL} size={Button.Sizes.SMALL}
> >
Export Settings {t("vencord.backupAndRestore.exportSettings")}
</Button> </Button>
</Flex> </Flex>
</SettingsTab> </SettingsTab>

View file

@ -24,6 +24,7 @@ import { Link } from "@components/Link";
import { authorizeCloud, cloudLogger, deauthorizeCloud, getCloudAuth, getCloudUrl } from "@utils/cloud"; import { authorizeCloud, cloudLogger, deauthorizeCloud, getCloudAuth, getCloudUrl } from "@utils/cloud";
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
import { deleteCloudSettings, getCloudSettings, putCloudSettings } from "@utils/settingsSync"; import { deleteCloudSettings, getCloudSettings, putCloudSettings } from "@utils/settingsSync";
import { t, Translate } from "@utils/translation";
import { Alerts, Button, Forms, Switch, Tooltip } from "@webpack/common"; import { Alerts, Button, Forms, Switch, Tooltip } from "@webpack/common";
import { SettingsTab, wrapTab } from "./shared"; import { SettingsTab, wrapTab } from "./shared";
@ -46,8 +47,8 @@ async function eraseAllData() {
if (!res.ok) { if (!res.ok) {
cloudLogger.error(`Failed to erase data, API returned ${res.status}`); cloudLogger.error(`Failed to erase data, API returned ${res.status}`);
showNotification({ showNotification({
title: "Cloud Integrations", title: t("vencord.cloudIntegrations"),
body: `Could not erase all data (API returned ${res.status}), please contact support.`, body: t("vencord.cloud.integrations.eraseError", { status: res.status }),
color: "var(--red-360)" color: "var(--red-360)"
}); });
return; return;
@ -57,8 +58,8 @@ async function eraseAllData() {
await deauthorizeCloud(); await deauthorizeCloud();
showNotification({ showNotification({
title: "Cloud Integrations", title: t("vencord.cloudIntegrations"),
body: "Successfully erased all data.", body: t("vencord.cloud.integrations.eraseSuccess"),
color: "var(--green-360)" color: "var(--green-360)"
}); });
} }
@ -70,8 +71,7 @@ function SettingsSyncSection() {
return ( return (
<Forms.FormSection title="Settings Sync" className={Margins.top16}> <Forms.FormSection title="Settings Sync" className={Margins.top16}>
<Forms.FormText variant="text-md/normal" className={Margins.bottom20}> <Forms.FormText variant="text-md/normal" className={Margins.bottom20}>
Synchronize your settings to the cloud. This allows easy synchronization across multiple devices with {t("vencord.cloud.settings.description")}
minimal effort.
</Forms.FormText> </Forms.FormText>
<Switch <Switch
key="cloud-sync" key="cloud-sync"
@ -79,7 +79,7 @@ function SettingsSyncSection() {
value={cloud.settingsSync} value={cloud.settingsSync}
onChange={v => { cloud.settingsSync = v; }} onChange={v => { cloud.settingsSync = v; }}
> >
Settings Sync {t("vencord.settingsSync")}
</Switch> </Switch>
<div className="vc-cloud-settings-sync-grid"> <div className="vc-cloud-settings-sync-grid">
<Button <Button
@ -87,9 +87,9 @@ function SettingsSyncSection() {
disabled={!sectionEnabled} disabled={!sectionEnabled}
onClick={() => putCloudSettings(true)} onClick={() => putCloudSettings(true)}
> >
Sync to Cloud {t("vencord.cloud.settings.syncToCloud")}
</Button> </Button>
<Tooltip text="This will overwrite your local settings with the ones on the cloud. Use wisely!"> <Tooltip text={t("vencord.cloud.settings.overwriteWarning")}>
{({ onMouseLeave, onMouseEnter }) => ( {({ onMouseLeave, onMouseEnter }) => (
<Button <Button
onMouseLeave={onMouseLeave} onMouseLeave={onMouseLeave}
@ -99,7 +99,7 @@ function SettingsSyncSection() {
disabled={!sectionEnabled} disabled={!sectionEnabled}
onClick={() => getCloudSettings(true, true)} onClick={() => getCloudSettings(true, true)}
> >
Sync from Cloud {t("vencord.cloud.settings.syncFromCloud")}
</Button> </Button>
)} )}
</Tooltip> </Tooltip>
@ -109,7 +109,7 @@ function SettingsSyncSection() {
disabled={!sectionEnabled} disabled={!sectionEnabled}
onClick={() => deleteCloudSettings()} onClick={() => deleteCloudSettings()}
> >
Delete Cloud Settings {t("vencord.cloud.settings.deleteCloudSettings")}
</Button> </Button>
</div> </div>
</Forms.FormSection> </Forms.FormSection>
@ -121,12 +121,12 @@ function CloudTab() {
return ( return (
<SettingsTab title="Vencord Cloud"> <SettingsTab title="Vencord Cloud">
<Forms.FormSection title="Cloud Settings" className={Margins.top16}> <Forms.FormSection title={t("vencord.cloudSettings")} className={Margins.top16}>
<Forms.FormText variant="text-md/normal" className={Margins.bottom20}> <Forms.FormText variant="text-md/normal" className={Margins.bottom20}>
Vencord comes with a cloud integration that adds goodies like settings sync across devices. <Translate i18nKey="vencord.cloud.integrations.description">
It <Link href="https://vencord.dev/cloud/privacy">respects your privacy</Link>, and <Link href="https://vencord.dev/cloud/privacy" />
the <Link href="https://github.com/Vencord/Backend">source code</Link> is AGPL 3.0 licensed so you <Link href="https://github.com/Vencord/Backend" />
can host it yourself. </Translate>
</Forms.FormText> </Forms.FormText>
<Switch <Switch
key="backend" key="backend"
@ -137,13 +137,13 @@ function CloudTab() {
else else
settings.cloud.authenticated = v; settings.cloud.authenticated = v;
}} }}
note="This will request authorization if you have not yet set up cloud integrations." note={t("vencord.cloud.integrations.authorizationNote")}
> >
Enable Cloud Integrations {t("vencord.cloud.integrations.enable")}
</Switch> </Switch>
<Forms.FormTitle tag="h5">Backend URL</Forms.FormTitle> <Forms.FormTitle tag="h5">{t("vencord.cloud.integrations.backendUrl")}</Forms.FormTitle>
<Forms.FormText className={Margins.bottom8}> <Forms.FormText className={Margins.bottom8}>
Which backend to use when using cloud integrations. {t("vencord.cloud.integrations.backendNote")}
</Forms.FormText> </Forms.FormText>
<CheckedTextInput <CheckedTextInput
key="backendUrl" key="backendUrl"
@ -166,25 +166,24 @@ function CloudTab() {
await authorizeCloud(); await authorizeCloud();
}} }}
> >
Reauthorise {t("vencord.reauthorise")}
</Button> </Button>
<Button <Button
size={Button.Sizes.MEDIUM} size={Button.Sizes.MEDIUM}
color={Button.Colors.RED} color={Button.Colors.RED}
disabled={!settings.cloud.authenticated} disabled={!settings.cloud.authenticated}
onClick={() => Alerts.show({ onClick={() => Alerts.show({
title: "Are you sure?", title: t("vencord.areYouSure"),
body: "Once your data is erased, we cannot recover it. There's no going back!", body: t("vencord.cloud.integrations.eraseWarning"),
onConfirm: eraseAllData, onConfirm: eraseAllData,
confirmText: "Erase it!", confirmText: t("vencord.cloud.integrations.eraseIt"),
confirmColor: "vc-cloud-erase-data-danger-btn", confirmColor: "vc-cloud-erase-data-danger-btn",
cancelText: "Nevermind" cancelText: t("vencord.nevermind")
})} })}
> >
Erase All Data Erase All Data
</Button> </Button>
</Grid> </Grid>
<Forms.FormDivider className={Margins.top16} /> <Forms.FormDivider className={Margins.top16} />
</Forms.FormSection > </Forms.FormSection >
<SettingsSyncSection /> <SettingsSyncSection />

View file

@ -28,6 +28,7 @@ import { Margins } from "@utils/margins";
import { classes } from "@utils/misc"; import { classes } from "@utils/misc";
import { showItemInFolder } from "@utils/native"; import { showItemInFolder } from "@utils/native";
import { useAwaiter } from "@utils/react"; import { useAwaiter } from "@utils/react";
import { t } from "@utils/translation";
import { findByPropsLazy, findLazy } from "@webpack"; import { findByPropsLazy, findLazy } from "@webpack";
import { Card, Forms, React, showToast, TabBar, TextArea, useEffect, useRef, useState } from "@webpack/common"; import { Card, Forms, React, showToast, TabBar, TextArea, useEffect, useRef, useState } from "@webpack/common";
import type { ComponentType, Ref, SyntheticEvent } from "react"; import type { ComponentType, Ref, SyntheticEvent } from "react";
@ -202,60 +203,68 @@ function ThemesTab() {
return ( return (
<> <>
<Card className="vc-settings-card"> <Card className="vc-settings-card">
<Forms.FormTitle tag="h5">Find Themes:</Forms.FormTitle> <Forms.FormTitle tag="h5">{t("vencord.themes.findThemes")}</Forms.FormTitle>
<div style={{ marginBottom: ".5em", display: "flex", flexDirection: "column" }}> <div style={{ marginBottom: ".5em", display: "flex", flexDirection: "column" }}>
<Link style={{ marginRight: ".5em" }} href="https://betterdiscord.app/themes"> <Link style={{ marginRight: ".5em" }} href="https://betterdiscord.app/themes">
BetterDiscord Themes {t("vencord.themes.betterDiscord")}
</Link> </Link>
<Link href="https://github.com/search?q=discord+theme">GitHub</Link> <Link href="https://github.com/search?q=discord+theme">GitHub</Link>
</div> </div>
<Forms.FormText>If using the BD site, click on "Download" and place the downloaded .theme.css file into your themes folder.</Forms.FormText> <Forms.FormText>{t("vencord.themes.betterDiscordNote")}</Forms.FormText>
</Card> </Card>
<Forms.FormSection title="Local Themes"> <Forms.FormSection title={t("vencord.themes.local")}>
<QuickActionCard> <Card className="vc-settings-quick-actions-card">
<> <>
{IS_WEB ? {IS_WEB ?
( (
<QuickAction <Button
text={ size={Button.Sizes.SMALL}
<span style={{ position: "relative" }}> disabled={themeDirPending}
Upload Theme >
{t("vencord.themes.upload")}
<FileInput <FileInput
ref={fileInputRef} ref={fileInputRef}
onChange={onFileUpload} onChange={onFileUpload}
multiple={true} multiple={true}
filters={[{ extensions: ["css"] }]} filters={[{ extensions: ["css"] }]}
/> />
</span> </Button>
}
Icon={PlusIcon}
/>
) : ( ) : (
<QuickAction <QuickAction
text="Open Themes Folder" text={t("vencord.themes.openFolder")}
action={() => showItemInFolder(themeDir!)} action={() => showItemInFolder(themeDir!)}
disabled={themeDirPending} disabled={themeDirPending}
Icon={FolderIcon} >
/> {t("vencord.themes.openFolder")}
</Button>
)} )}
<QuickAction <Button
text="Load missing Themes" onClick={refreshLocalThemes}
action={refreshLocalThemes} size={Button.Sizes.SMALL}
Icon={RestartIcon} >
/> {t("vencord.themes.loadMissing")}
<QuickAction </Button>
text="Edit QuickCSS" <Button
action={() => VencordNative.quickCss.openEditor()} onClick={() => VencordNative.quickCss.openEditor()}
Icon={PaintbrushIcon} size={Button.Sizes.SMALL}
/> >
{t("vencord.themes.editQuickCss")}
</Button>
{Vencord.Settings.plugins.ClientTheme.enabled && ( {Vencord.Settings.plugins.ClientTheme.enabled && (
<QuickAction <Button
text="Edit ClientTheme" onClick={() => openModal(modalProps => (
action={() => openPluginModal(Vencord.Plugins.plugins.ClientTheme)} <PluginModal
Icon={PencilIcon} {...modalProps}
plugin={Vencord.Plugins.plugins.ClientTheme}
onRestartNeeded={() => { }}
/> />
))}
size={Button.Sizes.SMALL}
>
{t("clientTheme.edit")}
</Button>
)} )}
</> </>
</QuickActionCard> </QuickActionCard>
@ -295,12 +304,12 @@ function ThemesTab() {
return ( return (
<> <>
<Card className="vc-settings-card vc-text-selectable"> <Card className="vc-settings-card vc-text-selectable">
<Forms.FormTitle tag="h5">Paste links to css files here</Forms.FormTitle> <Forms.FormTitle tag="h5">{t("vencord.themes.pasteLinks")}</Forms.FormTitle>
<Forms.FormText>One link per line</Forms.FormText> <Forms.FormText>{t("vencord.themes.oneLinkPerLine")}</Forms.FormText>
<Forms.FormText>Make sure to use direct links to files (raw or github.io)!</Forms.FormText> <Forms.FormText>{t("vencord.themes.useDirect")}</Forms.FormText>
</Card> </Card>
<Forms.FormSection title="Online Themes" tag="h5"> <Forms.FormSection title={t("vencord.themes.online")} tag="h5">
<TextArea <TextArea
value={themeText} value={themeText}
onChange={setThemeText} onChange={setThemeText}
@ -329,13 +338,13 @@ function ThemesTab() {
className="vc-settings-tab-bar-item" className="vc-settings-tab-bar-item"
id={ThemeTab.LOCAL} id={ThemeTab.LOCAL}
> >
Local Themes {t("vencord.themes.local")}
</TabBar.Item> </TabBar.Item>
<TabBar.Item <TabBar.Item
className="vc-settings-tab-bar-item" className="vc-settings-tab-bar-item"
id={ThemeTab.ONLINE} id={ThemeTab.ONLINE}
> >
Online Themes {t("vencord.themes.online")}
</TabBar.Item> </TabBar.Item>
</TabBar> </TabBar>

View file

@ -16,12 +16,11 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { t } from "@utils/translation";
import { maybePromptToUpdate } from "@utils/updater"; import { maybePromptToUpdate } from "@utils/updater";
export function handleComponentFailed() { export function handleComponentFailed() {
maybePromptToUpdate( maybePromptToUpdate(
"Uh Oh! Failed to render this Page." + t("vencord.failureUpdate")
" However, there is an update available that might fix it." +
" Would you like to update and restart now?"
); );
} }

5
src/modules.d.ts vendored
View file

@ -43,6 +43,11 @@ declare module "~git-remote" {
export default remote; export default remote;
} }
declare module "~translations" {
const translations: Record<string, Record<string, any>>;
export default translations;
}
declare module "file://*" { declare module "file://*" {
const content: string; const content: string;
export default content; export default content;

View file

@ -0,0 +1,27 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Devs } from "@utils/constants";
import { setLocale } from "@utils/translation";
import definePlugin from "@utils/types";
import { i18n } from "@webpack/common";
export default definePlugin({
name: "Translation",
required: true,
description: "Assists with translating Vencord",
authors: [Devs.lewisakura],
flux: {
USER_SETTINGS_PROTO_UPDATE({ settings }) {
setLocale(settings.proto.localization.locale.value);
}
},
start() {
setLocale(i18n.getLocale());
}
});

View file

@ -10,6 +10,7 @@ import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
import { classes } from "@utils/misc"; import { classes } from "@utils/misc";
import { t } from "@utils/translation";
import definePlugin, { OptionType, StartAt } from "@utils/types"; import definePlugin, { OptionType, StartAt } from "@utils/types";
import { findByCodeLazy, findComponentByCodeLazy, findStoreLazy } from "@webpack"; import { findByCodeLazy, findComponentByCodeLazy, findStoreLazy } from "@webpack";
import { Button, Forms, useStateFromStores } from "@webpack/common"; import { Button, Forms, useStateFromStores } from "@webpack/common";
@ -64,8 +65,8 @@ function ThemeSettings() {
<div className="client-theme-settings"> <div className="client-theme-settings">
<div className="client-theme-container"> <div className="client-theme-container">
<div className="client-theme-settings-labels"> <div className="client-theme-settings-labels">
<Forms.FormTitle tag="h3">Theme Color</Forms.FormTitle> <Forms.FormTitle tag="h3">{t("clientTheme.settingsTitle")}</Forms.FormTitle>
<Forms.FormText>Add a color to your Discord client theme</Forms.FormText> <Forms.FormText>{t("clientTheme.settingsDescription")}</Forms.FormText>
</div> </div>
<ColorPicker <ColorPicker
color={parseInt(settings.store.color, 16)} color={parseInt(settings.store.color, 16)}
@ -78,12 +79,12 @@ function ThemeSettings() {
<Forms.FormDivider className={classes(Margins.top8, Margins.bottom8)} /> <Forms.FormDivider className={classes(Margins.top8, Margins.bottom8)} />
<div className={`client-theme-contrast-warning ${contrastWarning ? (isLightTheme ? "theme-dark" : "theme-light") : ""}`}> <div className={`client-theme-contrast-warning ${contrastWarning ? (isLightTheme ? "theme-dark" : "theme-light") : ""}`}>
<div className="client-theme-warning"> <div className="client-theme-warning">
<Forms.FormText>Warning, your theme won't look good:</Forms.FormText> <Forms.FormText>{t("clientTheme.warningTitle")}</Forms.FormText>
{contrastWarning && <Forms.FormText>Selected color won't contrast well with text</Forms.FormText>} {contrastWarning && <Forms.FormText>{t("clientTheme.warnings.badContrast")}</Forms.FormText>}
{nitroThemeEnabled && <Forms.FormText>Nitro themes aren't supported</Forms.FormText>} {nitroThemeEnabled && <Forms.FormText>{t("clientTheme.warnings.nitro")}</Forms.FormText>}
</div> </div>
{(contrastWarning && fixableContrast) && <Button onClick={() => setTheme(oppositeTheme)} color={Button.Colors.RED}>Switch to {oppositeTheme} mode</Button>} {(contrastWarning && fixableContrast) && <Button onClick={() => setTheme(oppositeTheme)} color={Button.Colors.RED}>{t(`clientTheme.switchToOpposite.${oppositeTheme}`)}</Button>}
{(nitroThemeEnabled) && <Button onClick={() => setTheme(theme)} color={Button.Colors.RED}>Disable Nitro Theme</Button>} {(nitroThemeEnabled) && <Button onClick={() => setTheme(theme)} color={Button.Colors.RED}>{t("clientTheme.disableNitroTheme")}</Button>}
</div> </div>
</>)} </>)}
</div> </div>
@ -92,18 +93,18 @@ function ThemeSettings() {
const settings = definePluginSettings({ const settings = definePluginSettings({
color: { color: {
description: "Color your Discord client theme will be based around. Light mode isn't supported", description: t("clientTheme.colorDescription"),
type: OptionType.COMPONENT, type: OptionType.COMPONENT,
default: "313338", default: "313338",
component: () => <ThemeSettings /> component: () => <ThemeSettings />
}, },
resetColor: { resetColor: {
description: "Reset Theme Color", description: t("clientTheme.resetColorDescription"),
type: OptionType.COMPONENT, type: OptionType.COMPONENT,
default: "313338", default: "313338",
component: () => ( component: () => (
<Button onClick={() => onPickColor(0x313338)}> <Button onClick={() => onPickColor(0x313338)}>
Reset Theme Color {t("clientTheme.resetButton")}
</Button> </Button>
) )
} }
@ -112,7 +113,7 @@ const settings = definePluginSettings({
export default definePlugin({ export default definePlugin({
name: "ClientTheme", name: "ClientTheme",
authors: [Devs.F53, Devs.Nuckyz], authors: [Devs.F53, Devs.Nuckyz],
description: "Recreation of the old client theme experiment. Add a color to your Discord client theme", description: t("clientTheme.description"),
settings, settings,
startAt: StartAt.DOMContentLoaded, startAt: StartAt.DOMContentLoaded,

View file

@ -23,6 +23,7 @@ import { OAuth2AuthorizeModal, UserStore } from "@webpack/common";
import { Logger } from "./Logger"; import { Logger } from "./Logger";
import { openModal } from "./modal"; import { openModal } from "./modal";
import { t } from "./translation";
export const cloudLogger = new Logger("Cloud", "#39b7e0"); export const cloudLogger = new Logger("Cloud", "#39b7e0");
export const getCloudUrl = () => new URL(Settings.cloud.url); export const getCloudUrl = () => new URL(Settings.cloud.url);
@ -83,8 +84,8 @@ export async function authorizeCloud() {
var { clientId, redirectUri } = await oauthConfiguration.json(); var { clientId, redirectUri } = await oauthConfiguration.json();
} catch { } catch {
showNotification({ showNotification({
title: "Cloud Integration", title: t("vencord.cloudIntegrations"),
body: "Setup failed (couldn't retrieve OAuth configuration)." body: t("vencord.cloud.integrations.setupFailure.oauth")
}); });
Settings.cloud.authenticated = false; Settings.cloud.authenticated = false;
return; return;
@ -113,22 +114,22 @@ export async function authorizeCloud() {
cloudLogger.info("Authorized with secret"); cloudLogger.info("Authorized with secret");
await setAuthorization(secret); await setAuthorization(secret);
showNotification({ showNotification({
title: "Cloud Integration", title: t("vencord.cloudIntegrations"),
body: "Cloud integrations enabled!" body: t("vencord.cloud.integrations.enabled")
}); });
Settings.cloud.authenticated = true; Settings.cloud.authenticated = true;
} else { } else {
showNotification({ showNotification({
title: "Cloud Integration", title: t("vencord.cloudIntegrations"),
body: "Setup failed (no secret returned?)." body: t("vencord.cloud.integrations.setupFailure.missingSecret")
}); });
Settings.cloud.authenticated = false; Settings.cloud.authenticated = false;
} }
} catch (e: any) { } catch (e: any) {
cloudLogger.error("Failed to authorize", e); cloudLogger.error("Failed to authorize", e);
showNotification({ showNotification({
title: "Cloud Integration", title: t("vencord.cloudIntegrations"),
body: `Setup failed (${e.toString()}).` body: t("vencord.cloud.integrations.setupFailure.generic", { error: e.toString() })
}); });
Settings.cloud.authenticated = false; Settings.cloud.authenticated = false;
} }

View file

@ -19,6 +19,7 @@
import { Clipboard, Toasts } from "@webpack/common"; import { Clipboard, Toasts } from "@webpack/common";
import { DevsById } from "./constants"; import { DevsById } from "./constants";
import { t } from "./translation";
/** /**
* Calls .join(" ") on the arguments * Calls .join(" ") on the arguments
@ -35,11 +36,13 @@ export function sleep(ms: number): Promise<void> {
return new Promise(r => setTimeout(r, ms)); return new Promise(r => setTimeout(r, ms));
} }
export function copyWithToast(text: string, toastMessage = "Copied to clipboard!") { export function copyWithToast(text: string, toastMessage?: string) {
toastMessage ??= t("vencord.copiedToClipboard");
if (Clipboard.SUPPORTS_COPY) { if (Clipboard.SUPPORTS_COPY) {
Clipboard.copy(text); Clipboard.copy(text);
} else { } else {
toastMessage = "Your browser does not support copying to clipboard"; toastMessage = t("vencord.clipboardNotSupported");
} }
Toasts.show({ Toasts.show({
message: toastMessage, message: toastMessage,

View file

@ -24,6 +24,7 @@ import { deflateSync, inflateSync } from "fflate";
import { getCloudAuth, getCloudUrl } from "./cloud"; import { getCloudAuth, getCloudUrl } from "./cloud";
import { Logger } from "./Logger"; import { Logger } from "./Logger";
import { relaunch } from "./native"; import { relaunch } from "./native";
import { t } from "./translation";
import { chooseFile, saveFile } from "./web"; import { chooseFile, saveFile } from "./web";
export async function importSettings(data: string) { export async function importSettings(data: string) {
@ -68,10 +69,10 @@ const toast = (type: number, message: string) =>
}); });
const toastSuccess = () => const toastSuccess = () =>
toast(Toasts.Type.SUCCESS, "Settings successfully imported. Restart to apply changes!"); toast(Toasts.Type.SUCCESS, t("vencord.importedSettings"));
const toastFailure = (err: any) => const toastFailure = (err: any) =>
toast(Toasts.Type.FAILURE, `Failed to import settings: ${String(err)}`); toast(Toasts.Type.FAILURE, t("vencord.failedToImport", { error: String(err) }));
export async function uploadSettingsBackup(showToast = true): Promise<void> { export async function uploadSettingsBackup(showToast = true): Promise<void> {
if (IS_DISCORD_DESKTOP) { if (IS_DISCORD_DESKTOP) {
@ -128,8 +129,8 @@ export async function putCloudSettings(manual?: boolean) {
if (!res.ok) { if (!res.ok) {
cloudSettingsLogger.error(`Failed to sync up, API returned ${res.status}`); cloudSettingsLogger.error(`Failed to sync up, API returned ${res.status}`);
showNotification({ showNotification({
title: "Cloud Settings", title: t("vencord.cloudSettings"),
body: `Could not synchronize settings to cloud (API returned ${res.status}).`, body: t("vencord.cloud.settings.syncErrorUp.api", { status: res.status.toString() }),
color: "var(--red-360)" color: "var(--red-360)"
}); });
return; return;
@ -143,16 +144,16 @@ export async function putCloudSettings(manual?: boolean) {
if (manual) { if (manual) {
showNotification({ showNotification({
title: "Cloud Settings", title: t("vencord.cloudSettings"),
body: "Synchronized settings to the cloud!", body: t("vencord.cloud.settings.syncSuccess"),
noPersist: true, noPersist: true,
}); });
} }
} catch (e: any) { } catch (e: any) {
cloudSettingsLogger.error("Failed to sync up", e); cloudSettingsLogger.error("Failed to sync up", e);
showNotification({ showNotification({
title: "Cloud Settings", title: t("vencord.cloudSettings"),
body: `Could not synchronize settings to the cloud (${e.toString()}).`, body: t("vencord.cloud.settings.syncErrorUp.generic", { error: e.toString() }),
color: "var(--red-360)" color: "var(--red-360)"
}); });
} }
@ -173,8 +174,8 @@ export async function getCloudSettings(shouldNotify = true, force = false) {
cloudSettingsLogger.info("No settings on the cloud"); cloudSettingsLogger.info("No settings on the cloud");
if (shouldNotify) if (shouldNotify)
showNotification({ showNotification({
title: "Cloud Settings", title: t("vencord.cloudSettings"),
body: "There are no settings in the cloud.", body: t("vencord.cloud.settings.nothingOnline"),
noPersist: true noPersist: true
}); });
return false; return false;
@ -184,8 +185,8 @@ export async function getCloudSettings(shouldNotify = true, force = false) {
cloudSettingsLogger.info("Settings up to date"); cloudSettingsLogger.info("Settings up to date");
if (shouldNotify) if (shouldNotify)
showNotification({ showNotification({
title: "Cloud Settings", title: t("vencord.cloudSettings"),
body: "Your settings are up to date.", body: t("vencord.cloud.settings.upToDate"),
noPersist: true noPersist: true
}); });
return false; return false;
@ -194,8 +195,8 @@ export async function getCloudSettings(shouldNotify = true, force = false) {
if (!res.ok) { if (!res.ok) {
cloudSettingsLogger.error(`Failed to sync down, API returned ${res.status}`); cloudSettingsLogger.error(`Failed to sync down, API returned ${res.status}`);
showNotification({ showNotification({
title: "Cloud Settings", title: t("vencord.cloudSettings"),
body: `Could not synchronize settings from the cloud (API returned ${res.status}).`, body: t("vencord.cloud.settings.syncErrorDown.api", { status: res.status.toString() }),
color: "var(--red-360)" color: "var(--red-360)"
}); });
return false; return false;
@ -208,8 +209,8 @@ export async function getCloudSettings(shouldNotify = true, force = false) {
if (!force && written < localWritten) { if (!force && written < localWritten) {
if (shouldNotify) if (shouldNotify)
showNotification({ showNotification({
title: "Cloud Settings", title: t("vencord.cloudSettings"),
body: "Your local settings are newer than the cloud ones.", body: t("vencord.cloud.settings.localNewer"),
noPersist: true, noPersist: true,
}); });
return; return;
@ -227,8 +228,8 @@ export async function getCloudSettings(shouldNotify = true, force = false) {
cloudSettingsLogger.info("Settings loaded from cloud successfully"); cloudSettingsLogger.info("Settings loaded from cloud successfully");
if (shouldNotify) if (shouldNotify)
showNotification({ showNotification({
title: "Cloud Settings", title: t("vencord.cloudSettings"),
body: "Your settings have been updated! Click here to restart to fully apply changes!", body: t("vencord.cloud.settings.updated"),
color: "var(--green-360)", color: "var(--green-360)",
onClick: IS_WEB ? () => location.reload() : relaunch, onClick: IS_WEB ? () => location.reload() : relaunch,
noPersist: true noPersist: true
@ -238,8 +239,8 @@ export async function getCloudSettings(shouldNotify = true, force = false) {
} catch (e: any) { } catch (e: any) {
cloudSettingsLogger.error("Failed to sync down", e); cloudSettingsLogger.error("Failed to sync down", e);
showNotification({ showNotification({
title: "Cloud Settings", title: t("vencord.cloudSettings"),
body: `Could not synchronize settings from the cloud (${e.toString()}).`, body: t("vencord.cloud.settings.syncErrorDown.generic", { error: e.toString() }),
color: "var(--red-360)" color: "var(--red-360)"
}); });
@ -257,8 +258,8 @@ export async function deleteCloudSettings() {
if (!res.ok) { if (!res.ok) {
cloudSettingsLogger.error(`Failed to delete, API returned ${res.status}`); cloudSettingsLogger.error(`Failed to delete, API returned ${res.status}`);
showNotification({ showNotification({
title: "Cloud Settings", title: t("vencord.cloudSettings"),
body: `Could not delete settings (API returned ${res.status}).`, body: t("vencord.cloud.settings.deleteError.api", { error: res.status.toString() }),
color: "var(--red-360)" color: "var(--red-360)"
}); });
return; return;
@ -266,15 +267,15 @@ export async function deleteCloudSettings() {
cloudSettingsLogger.info("Settings deleted from cloud successfully"); cloudSettingsLogger.info("Settings deleted from cloud successfully");
showNotification({ showNotification({
title: "Cloud Settings", title: t("vencord.cloudSettings"),
body: "Settings deleted from cloud!", body: t("vencord.cloud.settings.deleted"),
color: "var(--green-360)" color: "var(--green-360)"
}); });
} catch (e: any) { } catch (e: any) {
cloudSettingsLogger.error("Failed to delete", e); cloudSettingsLogger.error("Failed to delete", e);
showNotification({ showNotification({
title: "Cloud Settings", title: t("vencord.cloudSettings"),
body: `Could not delete settings (${e.toString()}).`, body: t("vencord.cloud.settings.deleteError.generic", { error: e.toString() }),
color: "var(--red-360)" color: "var(--red-360)"
}); });
} }

179
src/utils/translation.tsx Normal file
View file

@ -0,0 +1,179 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { negotiateLanguages } from "@fluent/langneg";
import { React } from "@webpack/common";
import translations from "~translations";
import { localStorage } from "./localStorage";
import { Logger } from "./Logger";
const logger = new Logger("Translations", "#7bc876");
let loadedLocale: Record<string, any>;
let lastDiscordLocale: string = localStorage.getItem("vcLocale")!;
let bestLocale: string;
export function setLocale(locale: string) {
if (locale === lastDiscordLocale) return;
localStorage.setItem("vcLocale", locale);
lastDiscordLocale = locale;
reloadLocale();
}
if (lastDiscordLocale) reloadLocale();
function reloadLocale() {
// finds the best locale based on the available ones
bestLocale = negotiateLanguages(
[lastDiscordLocale],
Object.keys(translations),
{
defaultLocale: "en",
strategy: "lookup",
}
)[0];
loadedLocale = translations[bestLocale];
logger.info("Changed locale to", bestLocale);
}
// derived from stackoverflow's string formatting function
function format(source: string, variables: Record<string, any>) {
for (const key in variables) {
let formatted: string;
switch (typeof variables[key]) {
case "number": {
formatted = new Intl.NumberFormat(bestLocale).format(variables[key]);
break;
}
default: {
formatted = variables[key].toString();
break;
}
}
source = source.replace(
new RegExp(`\\{${key}\\}`, "gi"),
formatted
);
}
return source;
}
// converts a dot-notation path to an object value
const dotProp = (key: string, object: any) =>
key.split(".").reduce((obj, key) => obj?.[key], object);
type Translation = string | ({ [rule in Intl.LDMLPluralRule]?: string } & { other: string; });
// translation retrieval function
function _t(key: string, bundle: any): Translation {
const translation = dotProp(key, bundle);
if (!translation) {
if (bundle !== translations.en) {
return _t(key, translations.en);
} else {
return key;
}
}
return translation;
}
/**
* Translates a key. Soft-fails and returns the key if it is not valid.
* @param key The key to translate.
* @param variables The variables to interpolate into the resultant string. If dealing with plurals, `count` must be set.
* @returns A translated string.
*/
export function t(key: string, variables?: Record<string, any>): string {
const getter = (): string => {
const translation = _t(key, loadedLocale);
if (typeof translation !== "string") {
if (!variables?.count)
throw new Error(`translation key ${key} is an object (is it a plural?)`);
const pluralTag: Intl.LDMLPluralRule = variables.count === 0 ? "zero" :
new Intl.PluralRules(bestLocale).select(variables.count);
if (translation[pluralTag]) {
return format(translation[pluralTag]!, variables);
} else if (translation.other) {
return format(translation.other, variables);
} else {
throw new Error(`translation key ${key} is an object and doesn't seem to be plural`);
}
}
if (!variables) return translation;
return format(translation, variables);
};
// top level support hax (thank you vee!!)
// tl;dr: this lets you use $t at the top level in objects by simulating a string, a la:
// {
// description: t("clientTheme.description")
// }
// and any future accesses of the description prop will result in an up to date translation
const descriptor = {
configurable: true,
enumerable: false,
writable: false,
value: getter
};
return Object.create(String.prototype, {
toString: descriptor,
valueOf: descriptor
});
}
interface TranslateProps {
/** The key to translate. */
i18nKey: string;
/** The variables to interpolate into the resultant string. If dealing with plurals, `count` must be set. */
variables?: Record<string, any>;
/** The component(s) to interpolate into the resultant string. */
children: JSX.Element | JSX.Element[];
}
/**
* A translation component. Follows the same rules as {@link t}, but lets you add components to strings.
* @param param0 Component props.
*/
export function Translate({ i18nKey, variables, children: trueChildren }: TranslateProps): JSX.Element {
const children = [trueChildren].flat();
const translation = t(i18nKey, variables);
const parts = translation.split(/(<\d+>.*?<\/\d+>)/g);
const finalChildren = parts.map((part, index) => {
const match = part.match(/<(\d+)>(.*?)<\/\d+>/);
if (match) {
const childIndex = parseInt(match[1], 10);
return React.cloneElement(children[childIndex], { key: index }, match[2]);
}
return part;
});
return <>{finalChildren}</>;
}

View file

@ -20,6 +20,7 @@ import gitHash from "~git-hash";
import { Logger } from "./Logger"; import { Logger } from "./Logger";
import { relaunch } from "./native"; import { relaunch } from "./native";
import { t } from "./translation";
import { IpcRes } from "./types"; import { IpcRes } from "./types";
export const UpdateLogger = /* #__PURE__*/ new Logger("Updater", "white"); export const UpdateLogger = /* #__PURE__*/ new Logger("Updater", "white");
@ -70,7 +71,7 @@ export async function maybePromptToUpdate(confirmMessage: string, checkForDev =
const isOutdated = await checkForUpdates(); const isOutdated = await checkForUpdates();
if (isOutdated) { if (isOutdated) {
const wantsUpdate = confirm(confirmMessage); const wantsUpdate = confirm(confirmMessage);
if (wantsUpdate && isNewer) return alert("Your local copy has more recent commits. Please stash or reset them."); if (wantsUpdate && isNewer) return alert(t("vencord.gitCopyNewer"));
if (wantsUpdate) { if (wantsUpdate) {
await update(); await update();
relaunch(); relaunch();
@ -78,6 +79,6 @@ export async function maybePromptToUpdate(confirmMessage: string, checkForDev =
} }
} catch (err) { } catch (err) {
UpdateLogger.error(err); UpdateLogger.error(err);
alert("That also failed :( Try updating or re-installing with the installer!"); alert(t("vencord.updaterRepeatFailed"));
} }
} }

View file

@ -0,0 +1,4 @@
{
"description": "Plugin description but in German. Ja!",
"edit": "Edit ClientTheme"
}

View file

@ -0,0 +1,7 @@
{
"pluginContributed": {
"zero": "Diese Person hat keine Plugins erstellt. Sie haben wahrscheinlich auf andere Weise <0>zu Vencord beigetragen</0>!",
"one": "Diese Person hat zu einem Plugin <0>beigetragen</0>!",
"other": "Diese Person hat zu {count} Plugins <0>beigetragen</0>!"
}
}

View file

@ -0,0 +1,19 @@
{
"colorDescription": "Color your Discord client theme will be based around. Light mode isn't supported",
"description": "Recreation of the old client theme experiment. Add a color to your Discord client theme",
"disableNitroTheme": "Disable Nitro Theme",
"edit": "Edit ClientTheme",
"resetButton": "Reset Theme Color",
"resetColorDescription": "Reset Theme Color",
"settingsDescription": "Add a color to your Discord client theme",
"settingsTitle": "Theme Color",
"switchToOpposite": {
"dark": "Switch to dark mode",
"light": "Switch to light mode"
},
"warningTitle": "Warning, your theme won't look good:",
"warnings": {
"badContrast": "Selected color won't contrast well with text",
"nitro": "Nitro themes aren't supported"
}
}

View file

@ -0,0 +1,3 @@
{
"description": "Assists with translating Vencord"
}

View file

@ -0,0 +1,131 @@
{
"areYouSure": "Are you sure?",
"backupAndRestore": {
"customQuickcss": "Custom QuickCSS",
"description": "You can import and export your Vencord settings as a JSON file. This allows you to easily transfer your settings to another device, or recover your settings after reinstalling Vencord or Discord.",
"exportContains": "Settings Export contains:",
"exportSettings": "Export Settings",
"importSettings": "Import Settings",
"importWarning": "Importing a settings file will overwrite your current settings.",
"pluginSettings": "Plugin Settings",
"themeLinks": "Theme Links"
},
"clipboardNotSupported": "Your browser does not support copying to clipboard",
"cloud": {
"integrations": {
"authorizationNote": "This will request authorization if you have not yet set up cloud integrations.",
"backendNote": "Which backend to use when using cloud integrations.",
"backendUrl": "Backend URL",
"description": "Vencord comes with a cloud integration that adds goodies like settings sync across devices. It <0>respects your privacy</0>, and the <1>source code</1> is AGPL 3.0 licensed so you can host it yourself.",
"enable": "Enable Cloud Integrations",
"enabled": "Cloud integrations enabled!",
"eraseAllData": "Erase All Data",
"eraseError": "Could not erase all data (API returned {status}), please contact support.",
"eraseIt": "Erase it!",
"eraseSuccess": "Successfully erased all data.",
"eraseWarning": "Once your data is erased, we cannot recover it. There's no going back!",
"reauthenticate": "We've noticed you have cloud integrations enabled in another client! Due to limitations, you will need to re-authenticate to continue using them. Click here to go to the settings page to do so!",
"setupFailure": {
"generic": "Setup failed ({error}).",
"missingSecret": "Setup failed (no secret returned?).",
"oauth": "Setup failed (couldn't retrieve OAuth configuration)."
}
},
"settings": {
"deleteCloudSettings": "Delete Cloud Settings",
"deleteError": {
"api": "Could not delete settings (API returned {status}).",
"generic": "Could not delete settings ({error})."
},
"deleted": "Settings deleted from cloud!",
"description": "Synchronize your settings to the cloud. This allows easy synchronization across multiple devices with minimal effort.",
"localNewer": "Your local settings are newer than the cloud ones.",
"nothingOnline": "There are no settings in the cloud.",
"overwriteWarning": "This will overwrite your local settings with the ones on the cloud. Use wisely!",
"syncErrorDown": {
"api": "Could not synchronize settings from the cloud (API returned {status}).",
"generic": "Could not synchronize settings from the cloud ({error})."
},
"syncErrorUp": {
"api": "Could not synchronize settings to the cloud (API returned {status}).",
"generic": "Could not synchronize settings to the cloud ({error})."
},
"syncFromCloud": "Sync from Cloud",
"syncSuccess": "Synchronized settings to the cloud!",
"syncToCloud": "Sync to Cloud",
"upToDate": "Your settings are up to date.",
"updated": "Your settings have been updated! Click here to restart to fully apply changes!"
}
},
"cloudIntegrations": "Cloud Integrations",
"cloudSettings": "Cloud Settings",
"copiedToClipboard": "Copied to clipboard!",
"errorBoundaryDescription": "An error occurred while rendering this Component. More info can be found below and in your console.",
"failedToImport": "Failed to import settings: {error}",
"failureUpdate": "Uh Oh! Failed to render this Page. However, there is an update available that might fix it. Would you like to update and restart now?",
"gitCopyNewer": "Your local copy has more recent commits. Please stash or reset them.",
"importedSettings": "Settings successfully imported. Restart to apply changes!",
"nevermind": "Nevermind",
"new": "NEW",
"noSearchResults": "No plugins meet search criteria.",
"noSettings": "There are no settings for this plugin.",
"ohNo": "Oh no!",
"pluginContributed": {
"one": "This person has <0>contributed</0> to one plugin!",
"other": "This person has <0>contributed</0> to {count} plugins!",
"zero": "This person has not made any plugins. They likely <0>contributed</0> to Vencord in other ways!"
},
"pluginFilters": "Filters",
"pluginHeader": {
"cogWheel": "Plugins with a cog wheel have settings you can modify!",
"iconInformation": "Press the cog wheel or info icon to get more info on a plugin.",
"managementHeader": "Plugin Management",
"reloadDescription": "Restart now to apply new plugins and their settings",
"reloadHeader": "Restart required!",
"restart": "Restart"
},
"pluginRequiredBy": "This plugin is required by:",
"pluginRestart": "Restart to apply changes!",
"plugins": "Plugins",
"pluginsNeedRestart": "The following plugins require a restart:",
"reauthorise": "Reauthorise",
"requiredPlugin": "This plugin is required for Vencord to function.",
"requiredPlugins": "Required Plugins",
"restartLater": "Later!",
"restartNow": "Restart now",
"restartRequired": "Restart required",
"saveAndClose": "Save & Close",
"search": {
"all": "Show All",
"disabled": "Show Disabled",
"enabled": "Show Enabled",
"new": "Show New",
"placeholder": "Search for a plugin..."
},
"settings": "Settings",
"settingsErrors": "You must fix all errors before saving",
"settingsSaveError": "Error while saving: {saveError}",
"settingsSync": "Settings Sync",
"themes": {
"betterDiscord": "BetterDiscord Themes",
"betterDiscordNote": "If using the BD site, click on \"Download\" and place the downloaded .theme.css file into your themes folder.",
"editQuickCss": "Edit QuickCSS",
"findThemes": "Find Themes:",
"loadMissing": "Load missing Themes",
"local": "Local Themes",
"oneLinkPerLine": "One link per line.",
"online": "Online Themes",
"openFolder": "Open Themes Folder",
"pasteLinks": "Paste links to css files here",
"upload": "Upload Theme",
"useDirect": "Make sure to use direct links to files (raw or github.io)!"
},
"update": {
"available": "A Vencord update is available!",
"clickToRestart": "Click here to restart",
"clickToView": "Click here to view the update",
"updated": "Vencord has been updated!"
},
"updaterRepeatFailed": "That also failed :( Try updating or re-installing with the installer!",
"warning": "Warning"
}