diff --git a/public/lang/en-US.json b/public/lang/en-US.json new file mode 100644 index 0000000..4ce2391 --- /dev/null +++ b/public/lang/en-US.json @@ -0,0 +1,69 @@ +{ + "buttons.add": "Add", + "buttons.queue": "Add to Queue", + "buttons.addToPlaylist": "Add to Playlist", + "buttons.close": "Close", + "buttons.download": "Download as MP3", + "buttons.connect.getPing": "Get Ping", + "buttons.deletePlaylist": "Delete Playlist", + "buttons.likePlaylist": "Like Playlist", + "buttons.logout": "Logout", + "buttons.m3u": "Download as M3U", + "buttons.newPlaylist": "New Playlist", + "buttons.playNext": "Play Next", + "buttons.renamePlaylist": "Rename Playlist", + "buttons.search": "Search", + "buttons.share": "Copy Link", + "common.loader.lyrics": "Loading Lyrics", + "common.loader.playlists": "Loading Playlists", + "common.loader.tracks": "Loading Tracks", + "common.loader.user": "Loading User", + "components.addToPlaylist.title": "Add to Playlist", + "components.addToPlaylist.lastTrackButton.add": "Add", + "components.addToPlaylist.lastTrackButton.added": "Added", + "components.addToPlaylist.lastTrackButton.error": "Error", + "components.addToPlaylist.notifications.added": "Added {0} to {1}", + "components.addToPlaylist.notifications.failed": "Failed to add {0} to {1}", + "error.404": "Error 404", + "error.404.playlist": "Playlist Not Found.", + "error.404.track": "Track Not Found.", + "error.404.user": "User Not Found.", + "error.500": "Error 500", + "error.500.message": "Something went wrong!", + "lyrics.attribution": "Lyrics by {0}", + "lyrics.description": "Play a track to view its lyrics.", + "lyrics.error": "We couldn't find any lyrics for this track.", + "lyrics.notSynced": "These lyrics aren't synced with the track.", + "lyrics.title": "Lyrics", + "pages.connect.ping": "Ping: {0}", + "pages.connect.publicServers": "Public Servers", + "pages.connect.publicServers.error": "Couldn't contact public server registry", + "pages.connect.title": "Connect to Server", + "pages.connect.uptime": "Uptime: {0}", + "pages.home.charts": "Charts", + "pages.home.playlists": "Playlists", + "pages.home.welcome": "Welcome!", + "pages.home.welcomeUser": "Welcome, {0}!", + "pages.login.modal.0": "Because Pipe Bomb accounts can be used on any Pipe Bomb server without a centralized database, accounts don't use the standard username + password model.", + "pages.login.modal.1": "Your username and password are used to create a public and private key pair, which is also used to generate your user ID.", + "pages.login.modal.2": "If you can prove to a Pipe Bomb server that you have both the public and private keys used to generate your user ID, the server considers you the owner of the account.", + "pages.login.modal.3": "Because of this architecture, there is no \"registration\" of accounts. You can just enter any username and password, and your keys will be generated behind the scenes for you.", + "pages.login.modal.4": "However there is a caveat. You cannot change your username or password, as the new combination would generate completely different keys and a different user ID, effectively logging you into a different account.", + "pages.login.modal.5": "Because your account keys are generated using both your username and password, usernames are not unique. Two accounts with the same username but different passwords will generate different keys and a differrent user ID.", + "pages.login.modal.prompt": "How does this work?", + "pages.login.notice": "Enter the credentials to a new or existing account", + "pages.login.title": "Login", + "pages.search.top": "Top Results", + "pages.search.playlists": "Playlists", + "pages.search.loadMore": "More Results", + "pages.settings.title": "Settings", + "pages.settings.reloadNotice": "Reload for changes to take effect", + "pages.settings.equalizer": "Equalizer", + "pages.settings.equalizer.notice": "Play a track to load the EQ", + "pages.suggestions.description": "A collection of tracks similar to {0}", + "pages.suggestions.error": "Couldn't find any tracks like {0}", + "pages.suggestions.title": "Suggestions", + "pages.track.error": "Couldn't find any tracks like {0}", + "pages.track.similar": "Similar Tracks", + "pages.user.playlists": "Playlists" +} \ No newline at end of file diff --git a/public/lang/index.json b/public/lang/index.json new file mode 100644 index 0000000..70f923c --- /dev/null +++ b/public/lang/index.json @@ -0,0 +1,11 @@ +{ + "languages": { + "en-US": { + "displayName": "English (United States)" + } + }, + "primaryDialects": { + "en": "en-US" + }, + "default": "en-US" +} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index dc2da19..e3589e7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,8 +31,10 @@ import { getSetting, setSetting } from "./logic/SettingsIndex"; import Theme from "./logic/ThemeIndex"; import React from "react"; import RenamePlaylist from "./components/RenamePlaylist"; +import { initialiseLanguageAdapter } from "./logic/LanguageAdapter"; const theme = Theme.getTheme(getSetting("theme", "Classic")); +initialiseLanguageAdapter(); const App = React.memo(function App() { const navigate = useNavigate(); diff --git a/src/components/AddToPlaylist.tsx b/src/components/AddToPlaylist.tsx index 1efd407..0fa13e8 100644 --- a/src/components/AddToPlaylist.tsx +++ b/src/components/AddToPlaylist.tsx @@ -9,6 +9,8 @@ import Loader from './Loader'; import Playlist from 'pipebomb.js/dist/collection/Playlist'; import { createNotification } from './NotificationManager'; import React from 'react'; +import useTranslation from '../hooks/TranslationHook'; +import { localise } from '../logic/LanguageAdapter'; let openModal = () => {}; let addToPlaylist = (playlistID: string) => {}; @@ -25,7 +27,8 @@ export function addTrack(playlist: Playlist) { interface lastButton { playlistID: string, - value: string | JSX.Element + value: React.ReactNode, + isAdded: boolean } const AddToPlaylist = React.memo(function AddToPlaylist() { @@ -33,13 +36,19 @@ const AddToPlaylist = React.memo(function AddToPlaylist() { const [playlists, setPlaylists] = useState(PlaylistIndex.getInstance().getPlaylists()); const [lastTrackButton, setLastTrackButton] = useState({ playlistID: "", - value: "" + value: "", + isAdded: false }); + const lastTrackButtonAdd = useTranslation("components.addToPlaylist.lastTrackButton.add"); + const lastTrackButtonAdded = useTranslation("components.addToPlaylist.lastTrackButton.added"); + const lastTrackButtonError = useTranslation("components.addToPlaylist.lastTrackButton.error"); + openModal = () => { setLastTrackButton({ playlistID: "", - value: "" + value: "", + isAdded: false }); setVisible(true); } @@ -53,13 +62,14 @@ const AddToPlaylist = React.memo(function AddToPlaylist() { }, []); addToPlaylist = (playlistID: string) => { - if (!selectedTrack || (lastTrackButton.playlistID == playlistID && lastTrackButton.value == "Added")) return; + if (!selectedTrack || (lastTrackButton.playlistID == playlistID && lastTrackButton.isAdded)) return; PlaylistIndex.getInstance().getPlaylist(playlistID) .then(playlist => { setLastTrackButton({ playlistID: playlist.collectionID, - value: + value: , + isAdded: false }); if (!selectedTrack) return; @@ -71,11 +81,12 @@ const AddToPlaylist = React.memo(function AddToPlaylist() { trackName = (await selectedTrack.loadMetadata()).title; } createNotification({ - text: `Added ${trackName} to ${playlist.getName()}` + text: localise("components.addToPlaylist.notifications.added", trackName, playlist.getName()) }); setLastTrackButton({ playlistID: playlist.collectionID, - value: "Added" + value: lastTrackButtonAdded, + isAdded: true }); }).catch(async (error: any) => { console.error(error); @@ -84,34 +95,37 @@ const AddToPlaylist = React.memo(function AddToPlaylist() { trackName = (await selectedTrack.loadMetadata()).title; } createNotification({ - text: `Failed to add ${trackName} to ${playlist.getName()}` + text: localise("components.addToPlaylist.notifications.failed", trackName, playlist.getName()) }); setLastTrackButton({ playlistID: playlist.collectionID, - value: "Error" + value: lastTrackButtonError, + isAdded: false }); }); }) } + const playlistsLoaderText = useTranslation("common.loader.playlists"); + function generatePlaylistHTML() { - if (!playlists) return ; + if (!playlists) return ; return playlists.map(playlist => (
{playlist.getName()} - +
)); } return ( <> - setVisible(false)} title="Add to Playlist"> + setVisible(false)} title={useTranslation("components.addToPlaylist.title") as string}> { generatePlaylistHTML() } - + diff --git a/src/components/DeletePlaylist.tsx b/src/components/DeletePlaylist.tsx index 74acd3e..723ebd7 100644 --- a/src/components/DeletePlaylist.tsx +++ b/src/components/DeletePlaylist.tsx @@ -6,6 +6,7 @@ import styles from "../styles/AddToPlaylist.module.scss"; import { openCreatePlaylist } from "./CreatePlaylist"; import Playlist from 'pipebomb.js/dist/collection/Playlist'; import React from 'react'; +import useTranslation from '../hooks/TranslationHook'; let openModal = () => {}; let addToPlaylist = (playlist: Playlist) => {}; @@ -86,7 +87,7 @@ const AddToPlaylist = React.memo(function AddToPlaylist() { ))} - + diff --git a/src/components/Lyrics.tsx b/src/components/Lyrics.tsx index 91c6d1a..ae6af53 100644 --- a/src/components/Lyrics.tsx +++ b/src/components/Lyrics.tsx @@ -7,6 +7,7 @@ import AudioPlayer from "../logic/AudioPlayer"; import Loader from "./Loader"; import useWindowSize from "../hooks/WindowSizeHook"; import React from "react"; +import useTranslation from "../hooks/TranslationHook"; const Lyrics = React.memo(function Lyrics() { const track = useCurrentTrack(); @@ -76,8 +77,8 @@ const Lyrics = React.memo(function Lyrics() { if (!track) { return ( <> -

Lyrics

-

Play a track to view its lyrics.

+

{useTranslation("lyrics.title")}

+

{useTranslation("lyrics.description")}

) } @@ -85,8 +86,8 @@ const Lyrics = React.memo(function Lyrics() { if (lyrics === null) { return ( <> -

Lyrics

- +

{useTranslation("lyrics.title")}

+ ) } @@ -94,8 +95,8 @@ const Lyrics = React.memo(function Lyrics() { if (!lyrics) { return ( <> -

Lyrics

-

We couldn't find any lyrics for this track.

+

{useTranslation("lyrics.title")}

+

{useTranslation("lyrics.error")}

) } @@ -120,11 +121,11 @@ const Lyrics = React.memo(function Lyrics() { return (
-

Lyrics

+

{useTranslation("lyrics.title")}

{!lyrics.synced && ( -

These lyrics aren't synced with the track.

+

{useTranslation("lyrics.notSynced")}

)} -
Lyrics by { lyrics.provider }
+
{useTranslation("lyrics.title", lyrics.provider)}
diff --git a/src/components/NotificationManager.tsx b/src/components/NotificationManager.tsx index 0dc69c7..9d1ddc5 100644 --- a/src/components/NotificationManager.tsx +++ b/src/components/NotificationManager.tsx @@ -9,7 +9,7 @@ let notificationDupeCount = 0; let mouseInTime: number; export interface NotificationInfo { - text: string, + text: React.ReactNode, status?: "normal" | "warning" | "error" } diff --git a/src/components/PublicServer.tsx b/src/components/PublicServer.tsx index e631e88..12d09f7 100644 --- a/src/components/PublicServer.tsx +++ b/src/components/PublicServer.tsx @@ -7,6 +7,7 @@ import { formatTimeWords } from "../logic/Utils"; import { useState } from "react"; import ServerIndex from "../logic/ServerIndex"; import React from "react"; +import useTranslation from "../hooks/TranslationHook"; export interface PublicServerProps { server: ServerInfo, @@ -50,7 +51,7 @@ const PublicServer = React.memo(function PublicServer({ server, connectCallback function generatePingHTML() { if (ping == false) { return ( - + ) } @@ -76,7 +77,7 @@ const PublicServer = React.memo(function PublicServer({ server, connectCallback {server.address} - Uptime: {formatTimeWords(server.uptime)} + {useTranslation("pages.connect.uptime", {formatTimeWords(server.uptime)})}
Ping: { generatePingHTML() } diff --git a/src/hooks/TranslationHook.ts b/src/hooks/TranslationHook.ts new file mode 100644 index 0000000..f37f842 --- /dev/null +++ b/src/hooks/TranslationHook.ts @@ -0,0 +1,25 @@ +import { useState, useEffect, ReactNode } from "react"; +import { localise, registerLanguageChangeListener, unregisterLanguageChangeListener } from "../logic/LanguageAdapter"; + +/** + * Get the localised string from the provided translation key. + * @param translationKey the translation key to look up in the current language. + * @param args arguments to substitute for {0}, {1}, etc... in the localised string. + */ +export default function useTranslation(translationKey: string, ...args: (string | ReactNode)[]) : ReactNode { + const [translation, setTranslation] = useState(localise(translationKey, args)); + + function onLanguageChange() { + setTranslation(localise(translationKey, args)); + } + + useEffect(() => { + registerLanguageChangeListener(onLanguageChange); + + return () => { + unregisterLanguageChangeListener(onLanguageChange); + }; + }, []); + + return translation; +} \ No newline at end of file diff --git a/src/logic/Language.ts b/src/logic/Language.ts new file mode 100644 index 0000000..05eb5bb --- /dev/null +++ b/src/logic/Language.ts @@ -0,0 +1,42 @@ +import axios from 'axios'; + +export default class Language { + private languageId: string; + private displayName: string; + private translations?: Map; + + public constructor(languageId: string, displayName: string) { + this.languageId = languageId; + this.displayName = displayName; + } + + /** + * Resolve the language data, if not already loaded. + */ + public async resolve() : Promise { + // always update language to ensure the latest translations are present + const response = await axios.get(`/lang/${this.languageId}.json`); + this.translations = new Map(Object.entries(response.data)); + } + + /** + * Localise the given translation key to the localised string to use. + * @param key the translation key to find the localised translation for. + * @returns the localised string to display, without applying any formatting. If there is no translation, will return undefined. + */ + public localise(key: string): string | undefined { + return this.translations?.get(key); + } + + public getDisplayName(): string { + return this.displayName; + } + + /** + * Get the ID of the translation. + * @returns the standard translation id for this translation, such as en-US for English (United States) + */ + public getId(): string { + return this.languageId; + } +} \ No newline at end of file diff --git a/src/logic/LanguageAdapter.tsx b/src/logic/LanguageAdapter.tsx new file mode 100644 index 0000000..6c5a231 --- /dev/null +++ b/src/logic/LanguageAdapter.tsx @@ -0,0 +1,205 @@ +import Language from "./Language"; +import axios from 'axios'; +import { ReactNode, isValidElement, cloneElement } from "react"; + +// Interface for structure of the language metadata file +interface LanguageMeta { + displayName: string; +} + +interface LanguagesIndex { + languages: { + [key: string]: LanguageMeta + }, + primaryDialects: { + [key: string]: string + }, + default: string +} + +// Interface for Listeners +type LanguageChangeListener = () => void; + +let languages : Map = new Map(); +let primaryDialects : Map = new Map(); +let defaultLanguage : Language; +let currentLanguage : Language; + +let listeners : LanguageChangeListener[] = []; + +/** + * Get first preferred language by the browser that we also support. + * In the case of a generic language such as "en" being supported, any particular localisation + * ("en-US", "en-GB") will match. If the user has a particular localisation preferred for that language, + * at another point in the list, that will be prioritised. + * @returns the first language the user's browser prefers that we support. If there are no languages in common, returns + * the default language. + */ +function getPreferredLanguage() : string { + if (navigator.languages) { + // Find first language we support + for (let language of navigator.languages) { + let supportedLanguage = getSupportedLanguage(language, navigator.languages); + + if (supportedLanguage) { + return supportedLanguage; + } + } + } else { + // old browsers + let supportedLanguage = getSupportedLanguage(navigator.language, [navigator.language]); + + if (supportedLanguage) { + return supportedLanguage; + } + } + + return defaultLanguage.getId(); +} + +/** + * Finds a language code for a supported language that best suits the given language requested. + * @param language the language to find the supported language for. + * @param allLanguages the list of all languages requested, to assist with finding the most suitable language. + * That is, if a generic language code like "en" is requested, this will be checked to see if a more specific version is requested + * later. + * @returns the best supported language for the given requested language, or undefined if none match. + */ +function getSupportedLanguage(language: string, allLanguages: readonly string[]): string | undefined { + // first, prioritise exact matches + if (languages.has(language)) { + return language; + } + + // if a 2-letter code (generic language), find best specific localisation + if (language.length === 2) { + // check allLanguages to see if more specific localisation is preferred by the user. + for (let requestedLanguage of allLanguages) { + if (requestedLanguage.startsWith(language) && languages.has(requestedLanguage)) { + return requestedLanguage; + } + } + + // check primary dialect + let primaryDialect = primaryDialects.get(language); + + // ensure the primary dialect actually exists before using it + if (primaryDialect && languages.has(primaryDialect)) { + return primaryDialect; + } + + // check for any version of the language supported + for (let supportedLanguage of languages.keys()) { + if (supportedLanguage.startsWith(language)) { + return supportedLanguage; + } + } + } + + return undefined; +} + +export function initialiseLanguageAdapter() { + // set dummy value for current and default language to begin with. + defaultLanguage = currentLanguage = new Language("xx-XX", "No Translation"); + + // load language index for metadata such as supported languages + axios.get("/lang/index.json") + .then(response => response.data) + .then((data: LanguagesIndex) => { + // read index data + for (const [key, value] of Object.entries(data.languages)) { + languages.set(key, new Language(key, value.displayName)); + } + + for (const [key, value] of Object.entries(data.primaryDialects)) { + primaryDialects.set(key, value); + } + + // load default language + defaultLanguage = loadLanguage(data.default); + + // load preferred language + loadLanguage(getPreferredLanguage()); + }) + .catch(error => { + // TODO how should errors be handled? Build in the default en-US translation, or just show translation keys? Show error page? + console.log(error); + }); +} + +export function loadLanguage(language: string) : Language { + console.log("Loading Language: " + language); + + currentLanguage = languages.get(language); + + // notify all listeners after load + currentLanguage.resolve().then(() => { + listeners.forEach(listener => listener()); + }); + + return currentLanguage; +} + +/** + * Localise the given translation key to the current language. + * @param key the translation key to localise. + * @param args the args to substitute for {%d} instances in the localisation. + * @returns the localisation for the given translation key, searching first in the current language, falling back on the default language, and finally using the translation key + * if no translation is found. + */ +export function localise(key: string, ...args: ReactNode[]): ReactNode[] { + let localisedTemplate : string = currentLanguage.localise(key) ?? defaultLanguage.localise(key) ?? key; + + // Insert Args + // First, get the args in order that they are present in the template. + let indicesToArg : Map = new Map(); + + for (let i = 0; i < args.length; i++) { + let index = localisedTemplate.indexOf(`{${i}}`); + + if (index > -1) { + indicesToArg.set(index, args[i]); + } + } + + let orderedIndices = [...indicesToArg.keys()].sort(); + let split = localisedTemplate.split(/{\d+}/); + let components : ReactNode[] = []; + + // add parts of the split with the args added between components + for (let i = 0; i < split.length; i++) { + // append literal, filtering out blank strings that would just become empty spans + let literal = split[i]; + + if (literal.length > 0) { + components.push(literal); + } + + // append arg + if (i < orderedIndices.length) { + let index = orderedIndices[i]; + let arg : ReactNode = indicesToArg.get(index); + + if (isValidElement(arg)) { + components.push(cloneElement(arg, { key: "arg_" + i })); + } else { + components.push(arg); + } + } + } + + return components; +} + +export function registerLanguageChangeListener(listener: LanguageChangeListener): void { + listeners.push(listener); +} + +export function unregisterLanguageChangeListener(listener: LanguageChangeListener): void { + let i : number = listeners.indexOf(listener); + + if (i > -1) { + listeners.splice(i, 1); + } +} \ No newline at end of file diff --git a/src/pages/Chart.tsx b/src/pages/Chart.tsx index bac759c..30f802a 100644 --- a/src/pages/Chart.tsx +++ b/src/pages/Chart.tsx @@ -14,6 +14,7 @@ import Track from "pipebomb.js/dist/music/Track"; import PipeBombConnection from "../logic/PipeBombConnection"; import { ViewportList } from "react-viewport-list"; import React from "react"; +import useTranslation from "../hooks/TranslationHook"; const ChartPage = React.memo(function ChartPage() { let paramID: any = useParams().chartID; @@ -90,9 +91,9 @@ const ChartPage = React.memo(function ChartPage() { - Add to Queue - Copy Link - Download as M3U + {useTranslation("buttons.queue")} + {useTranslation("buttons.share")} + {useTranslation("buttons.m3u")} diff --git a/src/pages/Connect.tsx b/src/pages/Connect.tsx index 418489a..ca68f85 100644 --- a/src/pages/Connect.tsx +++ b/src/pages/Connect.tsx @@ -8,6 +8,7 @@ import ServerInfo from "pipebomb.js/dist/ServerInfo"; import Loader from "../components/Loader"; import PublicServer from "../components/PublicServer"; import React from "react"; +import useTranslation from "../hooks/TranslationHook"; const ConnectPage = React.memo(function ConnectPage() { const serverIndex = ServerIndex.getInstance(); @@ -46,7 +47,7 @@ const ConnectPage = React.memo(function ConnectPage() { function generateRegistryHTML() { if (registryServers === false) { return ( - Couldn't contact public server registry + {useTranslation("pages.connect.publicServers.error")} ) } @@ -69,13 +70,13 @@ const ConnectPage = React.memo(function ConnectPage() { return (
-

Connect to Server

+

{useTranslation("pages.connect.title")}

setValue(e.target.value)} initialValue={value} /> - +
@@ -83,7 +84,7 @@ const ConnectPage = React.memo(function ConnectPage() { ))}
- Public Servers + {useTranslation("pages.connect.publicServers")} { generateRegistryHTML() }
) diff --git a/src/pages/ErrorPage.tsx b/src/pages/ErrorPage.tsx new file mode 100644 index 0000000..99f710d --- /dev/null +++ b/src/pages/ErrorPage.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import { Text } from "@nextui-org/react" +import useTranslation from "../hooks/TranslationHook"; + +interface ErrorProps { + type: string, + cause?: string +} + +const ErrorPage = ({type, cause}: ErrorProps) => { + return <> + {useTranslation(`error.${type}`)} + {useTranslation(`error.${type}.${cause ?? "message"}`)} + +} + +export default ErrorPage; \ No newline at end of file diff --git a/src/pages/ExternalPlaylistPage.tsx b/src/pages/ExternalPlaylistPage.tsx index 7a6a3e5..74a7b43 100644 --- a/src/pages/ExternalPlaylistPage.tsx +++ b/src/pages/ExternalPlaylistPage.tsx @@ -14,6 +14,8 @@ import { convertTracklistToM3u } from "../logic/Utils"; import PlaylistTop from "../components/PlaylistTop"; import { ViewportList } from "react-viewport-list"; import React from "react"; +import useTranslation from "../hooks/TranslationHook"; +import ErrorPage from "./ErrorPage"; const ExternalPlaylistPage = React.memo(function ExternalPlaylistPage() { const collectionID = useParams().collectionID; @@ -49,8 +51,7 @@ const ExternalPlaylistPage = React.memo(function ExternalPlaylistPage() { if (collection === false) { return ( <> - Error 404 - Playlist Not Found. + ) } @@ -99,10 +100,10 @@ const ExternalPlaylistPage = React.memo(function ExternalPlaylistPage() { <> - Add to Queue - Copy Link - Like Playlist - Download as M3U + {useTranslation("buttons.queue")} + {useTranslation("buttons.share")} + {useTranslation("buttons.likePlaylist")} + {useTranslation("buttons.m3u")} } /> {tracklist && ( diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 9461d8b..049828a 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -10,6 +10,7 @@ import ChartIndex from "../logic/ChartIndex"; import SquareChart from "../components/SquareChart"; import PlaylistCollection from "../components/PlaylistCollection"; import React from "react"; +import useTranslation from "../hooks/TranslationHook"; const HomePage = React.memo(function HomePage() { const [playlists, setPlaylists] = useState(PlaylistIndex.getInstance().getPlaylists()); @@ -33,6 +34,8 @@ const HomePage = React.memo(function HomePage() { } }, []); + const playlistsText = useTranslation("pages.home.playlists"); + function generatePlaylistHTML() { if (playlists === null) { return ( @@ -45,7 +48,7 @@ const HomePage = React.memo(function HomePage() { if (playlists.length) { return ( <> - Playlists + {playlistsText} ) @@ -54,6 +57,8 @@ const HomePage = React.memo(function HomePage() { return null; } + const chartsText = useTranslation("pages.home.charts"); + function generateChartHTML() { if (charts === null) { return ( @@ -66,7 +71,7 @@ const HomePage = React.memo(function HomePage() { if (charts.length) { return ( <> - Charts + {chartsText}
{charts.map((chart, index) => (
@@ -86,9 +91,9 @@ const HomePage = React.memo(function HomePage() { {userData ? ( -

Welcome, {userData.username}!

+

{useTranslation("pages.home.welcomeUser", userData.username)}

) : ( -

Welcome!

+

{useTranslation("pages.home.welcome")}

)}
diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 8e9a347..3c3fee0 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -5,6 +5,7 @@ import { useRef, useState } from "react"; import PipeBombConnection from "../logic/PipeBombConnection"; import CustomModal from "../components/CustomModal"; import React from "react"; +import useTranslation from "../hooks/TranslationHook"; const LoginPage = React.memo(function LoginPage() { const authStatus = useAuthenticationStatus(); @@ -65,9 +66,9 @@ const LoginPage = React.memo(function LoginPage() { return ( <>
-

Login

+

{useTranslation("pages.login.title")}

-

Enter the credentials to a new or existing account

+

{useTranslation("pages.login.notice")}

setUsername(e.currentTarget.value)} helperColor={usernameStatus} helperText={usernameMessage} />
@@ -82,18 +83,18 @@ const LoginPage = React.memo(function LoginPage() { ) }
- +
setModalOpen(false)}> -

Because Pipe Bomb accounts can be used on any Pipe Bomb server without a centralized database, accounts don't use the standard username + password model.

-

Your username and password are used to create a public and private key pair, which is also used to generate your user ID.

-

If you can prove to a Pipe Bomb server that you have both the public and private keys used to generate your user ID, the server considers you the owner of the account.

-

Because of this architecture, there is no "registration" of accounts. You can just enter any username and password, and your keys will be generated behind the scenes for you.

-

However there is a caveat. You cannot change your username or password, as the new combination would generate completely different keys and a different user ID, effectively logging you into a different account.

-

Because your account keys are generated using both your username and password, usernames are not unique. Two accounts with the same username but different passwords will generate different keys and a differrent user ID.

- +

{useTranslation("pages.login.modal.0")}

+

{useTranslation("pages.login.modal.1")}

+

{useTranslation("pages.login.modal.2")}

+

{useTranslation("pages.login.modal.3")}

+

{useTranslation("pages.login.modal.4")}

+

{useTranslation("pages.login.modal.5")}

+
) diff --git a/src/pages/Playlist.tsx b/src/pages/Playlist.tsx index a1a4a63..2304d2a 100644 --- a/src/pages/Playlist.tsx +++ b/src/pages/Playlist.tsx @@ -18,6 +18,8 @@ import PlaylistTop from "../components/PlaylistTop"; import useIsSelf from "../hooks/IsSelfHook"; import { ViewportList } from "react-viewport-list"; import { openRenamePlaylist } from "../components/RenamePlaylist" +import useTranslation from "../hooks/TranslationHook"; +import ErrorPage from "./ErrorPage"; let lastPlaylistID = ""; @@ -31,6 +33,13 @@ const PlaylistPage = React.memo(function PlaylistPage() { const navigate = useNavigate(); const self = useIsSelf(playlist?.owner); + // context menu + const queueTranslation = useTranslation("buttons.queue"); + const shareTranslation = useTranslation("buttons.share"); + const renamePlaylistTranslation = useTranslation("buttons.renamePlaylist"); + const likeTranslation = useTranslation("buttons.likePlaylist"); + const m3uTranslation = useTranslation("buttons.m3u"); + const deletePlaylistTranslation = useTranslation("buttons.deletePlaylist"); const playlistID: string = paramID; @@ -90,8 +99,7 @@ const PlaylistPage = React.memo(function PlaylistPage() { if (errorCode == 400) { return ( <> - Error 404 - Playlist Not Found. + ) } @@ -99,8 +107,7 @@ const PlaylistPage = React.memo(function PlaylistPage() { if (paramID === undefined || errorCode != 0) { return ( <> - Error 500 - Something went wrong! + ) } @@ -118,7 +125,6 @@ const PlaylistPage = React.memo(function PlaylistPage() { ) } - const newTrackList: Track[] = trackList || []; function playPlaylist() { @@ -180,20 +186,20 @@ const PlaylistPage = React.memo(function PlaylistPage() { if (self) { return ( - Add to Queue - Copy Link - Rename Playlist - Download as M3U - Delete Playlist + {queueTranslation} + {shareTranslation} + {renamePlaylistTranslation} + {m3uTranslation} + {deletePlaylistTranslation} ); } else { return ( - Add to Queue - Copy Link - Like Playlist - Download as M3U + {queueTranslation} + {shareTranslation} + {likeTranslation} + {m3uTranslation} ) } diff --git a/src/pages/Search.tsx b/src/pages/Search.tsx index c664cc9..faad79d 100644 --- a/src/pages/Search.tsx +++ b/src/pages/Search.tsx @@ -13,6 +13,7 @@ import { TbMoodEmpty } from "react-icons/tb"; import CenterIcon from "../components/CenterIcon"; import PlaylistCollection from "../components/PlaylistCollection"; import React from "react"; +import useTranslation from "../hooks/TranslationHook"; interface storedResults { tracks: Track[], @@ -125,11 +126,11 @@ const SearchPage = React.memo(function SearchPage() { const firstTracks = newTracklist.splice(0, 5); if (playlists.length) { return <> - Top Results + {useTranslation("pages.search.top")} {firstTracks.map((item, index) => ( ))} - Playlists + {useTranslation("pages.search.playlists")}
{playlists.map((item, index) => ( @@ -138,7 +139,7 @@ const SearchPage = React.memo(function SearchPage() {
{!!newTracklist.length && ( - More Results + {useTranslation("pages.search.loadMore")} )} {newTracklist.map((item, index) => ( @@ -146,12 +147,12 @@ const SearchPage = React.memo(function SearchPage() { } else { return <> - Top Results + {useTranslation("pages.search.top")} {firstTracks.map((item, index) => ( ))} {!!newTracklist.length && ( - More Results + {useTranslation("pages.search.loadMore")} )} {newTracklist.map((item, index) => ( @@ -183,7 +184,7 @@ const SearchPage = React.memo(function SearchPage() { className={styles.button} auto > - Search + {useTranslation("buttons.search")}
{ generateServices() } diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 3b7cf54..a74f43d 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -10,6 +10,7 @@ import useSetting from "../hooks/SettingHook"; import styles from "../styles/SettingsPage.module.scss" import { useResizeDetector } from "react-resize-detector"; import React from "react"; +import useTranslation from "../hooks/TranslationHook"; const themeObject = Theme.getTheme(getSetting("theme", "Classic")); @@ -96,12 +97,12 @@ const SettingsPage = React.memo(function SettingsPage() { return ( <> -

Settings

+

{useTranslation("pages.settings.title")}

Theme {themeChanged && ( -

Reload for changes to take effect

+

{useTranslation("pages.settings.reloadNotice")}

)} @@ -116,9 +117,9 @@ const SettingsPage = React.memo(function SettingsPage() { - Equalizer + {useTranslation("pages.settings.equalizer")} {!nodes.length ? ( -

Play a track to load the EQ

+

{useTranslation("pages.settings.equalizer.notice")}

) : ( <>
@@ -139,7 +140,7 @@ const SettingsPage = React.memo(function SettingsPage() { - + ) }); diff --git a/src/pages/SuggestionsPlaylist.tsx b/src/pages/SuggestionsPlaylist.tsx index 3511fa5..d973929 100644 --- a/src/pages/SuggestionsPlaylist.tsx +++ b/src/pages/SuggestionsPlaylist.tsx @@ -12,6 +12,8 @@ import TrackList from "pipebomb.js/dist/collection/TrackList"; import useTrack from "../hooks/TrackHook"; import useTrackMeta from "../hooks/TrackMetaHook"; import React from "react"; +import useTranslation from "../hooks/TranslationHook"; +import ErrorPage from "./ErrorPage"; const SuggestionsPlaylist = React.memo(function SuggestionsPlaylist() { let paramID: any = useParams().ID; @@ -42,8 +44,7 @@ const SuggestionsPlaylist = React.memo(function SuggestionsPlaylist() { if (!trackMeta) { return <> - Error 404 - Track Not Found. + } @@ -51,7 +52,7 @@ const SuggestionsPlaylist = React.memo(function SuggestionsPlaylist() { if (suggestions === null) { return <> {trackMeta && ( - A collection of tracks similar to {trackMeta.title} + {useTranslation("pages.suggestions.description", {trackMeta.title})} )} @@ -59,13 +60,13 @@ const SuggestionsPlaylist = React.memo(function SuggestionsPlaylist() { if (!suggestions || !suggestions.getTrackList().length) { const title = trackMeta ? trackMeta.title : "Unknown Track"; - return Couldn't find any tracks like {title} + return {useTranslation("pages.suggestions.error", {title})} } return ( <> {trackMeta && ( - A collection of tracks similar to {trackMeta.title} + {useTranslation("pages.suggestions.description", {trackMeta.title})} )} @@ -98,7 +99,7 @@ const SuggestionsPlaylist = React.memo(function SuggestionsPlaylist() { return ( <> - Suggestions + {useTranslation("pages.suggestions.title")} { generateListHTML() } ) diff --git a/src/pages/TrackPage.tsx b/src/pages/TrackPage.tsx index 808c072..6f4f59b 100644 --- a/src/pages/TrackPage.tsx +++ b/src/pages/TrackPage.tsx @@ -20,6 +20,8 @@ import { useResizeDetector } from "react-resize-detector"; import { BiPlus } from "react-icons/bi"; import PipeBombConnection from "../logic/PipeBombConnection"; import React from "react"; +import useTranslation from "../hooks/TranslationHook"; +import ErrorPage from "./ErrorPage"; const TrackPage = React.memo(function TrackPage() { let paramID: any = useParams().ID; @@ -65,8 +67,7 @@ const TrackPage = React.memo(function TrackPage() { if (!trackMeta || !track) { return <> - Error 404 - Track Not Found. + } @@ -131,7 +132,7 @@ const TrackPage = React.memo(function TrackPage() { if (suggestions === null) { return ( <> - Similar Tracks + {useTranslation("pages.track.similar")}
@@ -144,8 +145,8 @@ const TrackPage = React.memo(function TrackPage() { const title = trackMeta ? trackMeta.title : "this"; return ( <> - Similar Tracks - Couldn't find any tracks like {title} + {useTranslation("pages.track.similar")} + {useTranslation("pages.track.error", {title})} ) } @@ -154,7 +155,7 @@ const TrackPage = React.memo(function TrackPage() { <> - Similar Tracks + {useTranslation("pages.track.similar")} @@ -219,11 +220,11 @@ const TrackPage = React.memo(function TrackPage() { - Play Next - Add to Queue - Copy Link - Add to Playlist - Download as MP3 + {useTranslation("buttons.playNext")} + {useTranslation("buttons.queue")} + {useTranslation("buttons.share")} + {useTranslation("buttons.addToPlaylist")} + {useTranslation("buttons.download")}