diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 78c9c00b..ba226435 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -1,4 +1,10 @@ { + "action_bar_settings": "Settings", + "action_bar_send_test_notification": "Send test notification", + "action_bar_clear_notifications": "Clear all notifications", + "action_bar_unsubscribe": "Unsubscribe", + "message_bar_type_message": "Type a message here", + "message_bar_error_publishing": "Error publishing message", "nav_topics_title": "Subscribed topics", "nav_button_all_notifications": "All notifications", "nav_button_settings": "Settings", @@ -31,5 +37,103 @@ "notifications_example": "Example", "notifications_more_details": "For more information, check out the website or documentation.", "notifications_loading": "Loading notifications ...", - "emoji_picker_search_placeholder": "Search emoji" + "publish_dialog_title_topic": "Publish to {{topic}}", + "publish_dialog_title_no_topic": "Publish message", + "publish_dialog_progress_uploading": "Uploading ...", + "publish_dialog_progress_uploading_detail": "Uploading {{loaded}}/{{total}} ({{percent}}%) ...", + "publish_dialog_message_published": "Message published", + "publish_dialog_attachment_limits_file_and_quota_reached": "exceeds {{fileSizeLimit}} file limit and quota, {{remainingBytes}} remaining", + "publish_dialog_attachment_limits_file_reached": "exceeds {{fileSizeLimit}} file limit", + "publish_dialog_attachment_limits_quota_reached": "exceeds quota,{{remainingBytes}} remaining", + "publish_dialog_priority_min": "Min. priority", + "publish_dialog_priority_low": "Low priority", + "publish_dialog_priority_default": "Default priority", + "publish_dialog_priority_high": "High priority", + "publish_dialog_priority_max": "Max. priority", + "publish_dialog_base_url_label": "Server URL", + "publish_dialog_base_url_placeholder": "Server URL, e.g. https://example.com", + "publish_dialog_topic_label": "Topic name", + "publish_dialog_topic_placeholder": "Topic name, e.g. phil_alerts", + "publish_dialog_title_label": "Title", + "publish_dialog_title_placeholder": "Notification title, e.g. Disk space alert", + "publish_dialog_message_label": "Message", + "publish_dialog_message_placeholder": "Type a message here", + "publish_dialog_tags_label": "Tags", + "publish_dialog_tags_placeholder": "Comma-separated list of tags, e.g. warning, srv1-backup", + "publish_dialog_priority_label": "Priority", + "publish_dialog_click_label": "Click URL", + "publish_dialog_click_placeholder": "URL that is opened when notification is clicked", + "publish_dialog_email_label": "Email", + "publish_dialog_email_placeholder": "Address to forward the message to, e.g. phil@example.com", + "publish_dialog_attach_label": "Attachment URL", + "publish_dialog_attach_placeholder": "Attach file by URL, e.g. https://f-droid.org/F-Droid.apk", + "publish_dialog_filename_label": "Filename", + "publish_dialog_filename_placeholder": "Attachment filename", + "publish_dialog_delay_label": "Delay", + "publish_dialog_delay_placeholder": "Delay delivery, e.g. 1649029748, 30m, or tomorrow, 9am", + "publish_dialog_other_features": "Other features:", + "publish_dialog_chip_click_label": "Click URL", + "publish_dialog_chip_email_label": "Forward to email", + "publish_dialog_chip_attach_url_label": "Attach file by URL", + "publish_dialog_chip_attach_file_label": "Attach local file", + "publish_dialog_chip_delay_label": "Delay delivery", + "publish_dialog_chip_topic_label": "Change topic", + "publish_dialog_details_examples_description": "For examples and a detailed description of all send features, please refer to the documentation.", + "publish_dialog_button_cancel_sending": "Cancel sending", + "publish_dialog_button_cancel": "Cancel", + "publish_dialog_button_send": "Send", + "publish_dialog_checkbox_publish_another": "Publish another", + "publish_dialog_attached_file_title": "Attached file:", + "publish_dialog_attached_file_filename_placeholder": "Attachment filename", + "publish_dialog_drop_file_here": "Drop file here", + "emoji_picker_search_placeholder": "Search emoji", + "subscribe_dialog_subscribe_title": "Subscribe to topic", + "subscribe_dialog_subscribe_description": "Topics may not be password-protected, so choose a name that's not easy to guess. Once subscribed, you can PUT/POST notifications.", + "subscribe_dialog_subscribe_topic_placeholder": "Topic name, e.g. phil_alerts", + "subscribe_dialog_subscribe_use_another_label": "Use another server", + "subscribe_dialog_subscribe_button_cancel": "Cancel", + "subscribe_dialog_subscribe_button_subscribe": "Subscribe", + "subscribe_dialog_login_title": "Login required", + "subscribe_dialog_login_description": "This topic is password-protected. Please enter username and password to subscribe.", + "subscribe_dialog_login_username_label": "Username, e.g. phil", + "subscribe_dialog_login_password_label": "Password", + "subscribe_dialog_login_button_back": "Back", + "subscribe_dialog_login_button_login": "Login", + "subscribe_dialog_error_user_not_authorized": "User {{username}} not authorized", + "subscribe_dialog_error_user_anonymous": "anonymous", + "prefs_notifications_title": "Notifications", + "prefs_notifications_sound_title": "Notification sound", + "prefs_notifications_sound_no_sound": "No sound", + "prefs_notifications_min_priority_title": "Minimum priority", + "prefs_notifications_min_priority_any": "Any priority", + "prefs_notifications_min_priority_low_and_higher": "Low priority and higher", + "prefs_notifications_min_priority_default_and_higher": "Default priority and higher", + "prefs_notifications_min_priority_high_and_higher": "High priority and higher", + "prefs_notifications_min_priority_max_only": "Only max priority", + "prefs_notifications_delete_after_title": "Delete notifications", + "prefs_notifications_delete_after_never": "Never", + "prefs_notifications_delete_after_three_hours": "After three hours", + "prefs_notifications_delete_after_one_day": "After one day", + "prefs_notifications_delete_after_one_week": "After one week", + "prefs_notifications_delete_after_one_month": "After one month", + "prefs_users_title": "Manage users", + "prefs_users_description": "Add/remove users for your protected topics here. Please note that username and password are stored in the browser's local storage.", + "prefs_users_add_button": "Add user", + "prefs_users_table_user_header": "User", + "prefs_users_table_base_url_header": "Service URL", + "prefs_users_dialog_title_add": "Add user", + "prefs_users_dialog_title_edit": "Edit user", + "prefs_users_dialog_base_url_label": "Service URL, e.g. https://ntfy.sh", + "prefs_users_dialog_username_label": "Username, e.g. phil", + "prefs_users_dialog_password_label": "Password", + "prefs_users_dialog_button_cancel": "Cancel", + "prefs_users_dialog_button_add": "Add", + "prefs_users_dialog_button_save": "Save", + "prefs_appearance_title": "Appearance", + "prefs_appearance_language_title": "Language", + "error_boundary_title": "Oh no, ntfy crashed", + "error_boundary_description": "This should obviously not happen. Very sorry about this.
If you have a minute, please report this on GitHub, or let us know via Discord or Matrix.", + "error_boundary_button_copy_stack_trace": "Copy stack trace", + "error_boundary_stack_trace": "Stack trace", + "error_boundary_gathering_info": "Gather more info ..." } diff --git a/web/src/app/Connection.js b/web/src/app/Connection.js index 55778023..fbc54cc0 100644 --- a/web/src/app/Connection.js +++ b/web/src/app/Connection.js @@ -71,7 +71,7 @@ class Connection { this.onStateChanged(this.subscriptionId, ConnectionState.Connecting); } }; - this.ws.onerror = (event) => { + this.ws.onerrgoogle.ccor = (event) => { console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Error occurred: ${event}`, event); }; } diff --git a/web/src/components/ActionBar.js b/web/src/components/ActionBar.js index bb10b1c4..7e3ce44b 100644 --- a/web/src/components/ActionBar.js +++ b/web/src/components/ActionBar.js @@ -22,14 +22,16 @@ import api from "../app/Api"; import routes from "./routes"; import subscriptionManager from "../app/SubscriptionManager"; import logo from "../img/ntfy.svg"; +import {useTranslation} from "react-i18next"; const ActionBar = (props) => { + const { t } = useTranslation(); const location = useLocation(); let title = "ntfy"; if (props.selected) { title = topicShortUrl(props.selected.baseUrl, props.selected.topic); } else if (location.pathname === "/settings") { - title = "Settings"; + title = t("action_bar_settings"); } return ( { // Originally from https://mui.com/components/menus/#MenuListComposition.js const SettingsIcons = (props) => { + const { t } = useTranslation(); const navigate = useNavigate(); const [open, setOpen] = useState(false); const anchorRef = useRef(null); @@ -189,9 +192,9 @@ const SettingsIcons = (props) => { - Send test notification - Clear all notifications - Unsubscribe + {t("action_bar_send_test_notification")} + {t("action_bar_clear_notifications")} + {t("action_bar_unsubscribe")} diff --git a/web/src/components/App.js b/web/src/components/App.js index d7a251f1..1ecf878c 100644 --- a/web/src/components/App.js +++ b/web/src/components/App.js @@ -19,7 +19,7 @@ import {expandUrl} from "../app/utils"; import ErrorBoundary from "./ErrorBoundary"; import routes from "./routes"; import {useAutoSubscribe, useBackgroundProcesses, useConnectionListeners} from "./hooks"; -import SendDialog from "./SendDialog"; +import PublishDialog from "./PublishDialog"; import Messaging from "./Messaging"; import "./i18n"; // Translations! import {Backdrop, CircularProgress} from "@mui/material"; @@ -91,7 +91,7 @@ const Layout = () => { mobileDrawerOpen={mobileDrawerOpen} onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} onNotificationGranted={setNotificationsGranted} - onPublishMessageClick={() => setSendDialogOpenMode(SendDialog.OPEN_MODE_DEFAULT)} + onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)} />
diff --git a/web/src/components/ErrorBoundary.js b/web/src/components/ErrorBoundary.js index d309f4b0..0a3393c5 100644 --- a/web/src/components/ErrorBoundary.js +++ b/web/src/components/ErrorBoundary.js @@ -1,9 +1,10 @@ import * as React from "react"; import StackTrace from "stacktrace-js"; -import {CircularProgress} from "@mui/material"; +import {CircularProgress, Link} from "@mui/material"; import Button from "@mui/material/Button"; +import {Trans, withTranslation} from "react-i18next"; -class ErrorBoundary extends React.Component { +class ErrorBoundaryImpl extends React.Component { constructor(props) { super(props); this.state = { @@ -45,22 +46,28 @@ class ErrorBoundary extends React.Component { } render() { + const { t } = this.props; if (this.state.error) { return (
-

Oh no, ntfy crashed 😮

+

{t("error_boundary_title")} 😮

- This should obviously not happen. Very sorry about this.
- If you have a minute, please report this on GitHub, or let us - know via Discord or Matrix. + , + discordLink: , + matrixLink: + }} + />

- +

-

Stack trace

+

{t("error_boundary_stack_trace")}

{this.state.niceStack ?
{this.state.niceStack}
- : <> Gather more info ...} + : <> {t("error_boundary_gathering_info")}}
{this.state.originalStack}
); @@ -69,4 +76,5 @@ class ErrorBoundary extends React.Component { } } +const ErrorBoundary = withTranslation()(ErrorBoundaryImpl); // Adds props.t export default ErrorBoundary; diff --git a/web/src/components/Messaging.js b/web/src/components/Messaging.js index 934872a5..b4418459 100644 --- a/web/src/components/Messaging.js +++ b/web/src/components/Messaging.js @@ -1,15 +1,15 @@ import * as React from 'react'; import {useState} from 'react'; import Navigation from "./Navigation"; -import {topicUrl} from "../app/utils"; import Paper from "@mui/material/Paper"; import IconButton from "@mui/material/IconButton"; import TextField from "@mui/material/TextField"; import SendIcon from "@mui/icons-material/Send"; import api from "../app/Api"; -import SendDialog from "./SendDialog"; +import PublishDialog from "./PublishDialog"; import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; import {Portal, Snackbar} from "@mui/material"; +import {useTranslation} from "react-i18next"; const Messaging = (props) => { const [message, setMessage] = useState(""); @@ -19,10 +19,10 @@ const Messaging = (props) => { const subscription = props.selected; const handleOpenDialogClick = () => { - props.onDialogOpenModeChange(SendDialog.OPEN_MODE_DEFAULT); + props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT); }; - const handleSendDialogClose = () => { + const handleDialogClose = () => { props.onDialogOpenModeChange(""); setDialogKey(prev => prev+1); }; @@ -35,21 +35,22 @@ const Messaging = (props) => { onMessageChange={setMessage} onOpenDialogClick={handleOpenDialogClick} />} - props.onDialogOpenModeChange(prev => (prev) ? prev : SendDialog.OPEN_MODE_DRAG)} // Only update if not already open - onResetOpenMode={() => props.onDialogOpenModeChange(SendDialog.OPEN_MODE_DEFAULT)} + onClose={handleDialogClose} + onDragEnter={() => props.onDialogOpenModeChange(prev => (prev) ? prev : PublishDialog.OPEN_MODE_DRAG)} // Only update if not already open + onResetOpenMode={() => props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT)} /> ); } const MessageBar = (props) => { + const { t } = useTranslation(); const subscription = props.subscription; const [snackOpen, setSnackOpen] = useState(false); const handleSendClick = async () => { @@ -80,7 +81,7 @@ const MessageBar = (props) => { { open={snackOpen} autoHideDuration={3000} onClose={() => setSnackOpen(false)} - message="Error publishing message" + message={t("message_bar_error_publishing")} /> diff --git a/web/src/components/Preferences.js b/web/src/components/Preferences.js index ab1406ac..0d1820a3 100644 --- a/web/src/components/Preferences.js +++ b/web/src/components/Preferences.js @@ -48,10 +48,11 @@ const Preferences = () => { }; const Notifications = () => { + const { t } = useTranslation(); return ( - Notifications + {t("prefs_notifications_title")} @@ -63,6 +64,7 @@ const Notifications = () => { }; const Sound = () => { + const { t } = useTranslation(); const sound = useLiveQuery(async () => prefs.sound()); const handleChange = async (ev) => { await prefs.setSound(ev.target.value); @@ -71,11 +73,11 @@ const Sound = () => { return null; // While loading } return ( - +
- Any priority - Low priority and higher - Default priority and higher - High priority and higher - Only max priority + {t("prefs_notifications_min_priority_any")} + {t("prefs_notifications_min_priority_low_and_higher")} + {t("prefs_notifications_min_priority_default_and_higher")} + {t("prefs_notifications_min_priority_high_and_higher")} + {t("prefs_notifications_min_priority_max_only")} @@ -117,6 +120,7 @@ const MinPriority = () => { }; const DeleteAfter = () => { + const { t } = useTranslation(); const deleteAfter = useLiveQuery(async () => prefs.deleteAfter()); const handleChange = async (ev) => { await prefs.setDeleteAfter(ev.target.value); @@ -125,14 +129,14 @@ const DeleteAfter = () => { return null; // While loading } return ( - + @@ -176,6 +180,7 @@ const Pref = (props) => { }; const Users = () => { + const { t } = useTranslation(); const [dialogKey, setDialogKey] = useState(0); const [dialogOpen, setDialogOpen] = useState(false); const users = useLiveQuery(() => userManager.all()); @@ -199,16 +204,15 @@ const Users = () => { - Manage users + {t("prefs_users_title")} - Add/remove users for your protected topics here. Please note that username and password are - stored in the browser's local storage. + {t("prefs_users_description")} {users?.length > 0 && } - + { }; const UserTable = (props) => { + const { t } = useTranslation(); const [dialogKey, setDialogKey] = useState(0); const [dialogOpen, setDialogOpen] = useState(false); const [dialogUser, setDialogUser] = useState(null); @@ -255,8 +260,8 @@ const UserTable = (props) => { - User - Service URL + {t("prefs_users_table_user_header")} + {t("prefs_users_table_base_url_header")} @@ -292,6 +297,7 @@ const UserTable = (props) => { }; const UserDialog = (props) => { + const { t } = useTranslation(); const [baseUrl, setBaseUrl] = useState(""); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); @@ -320,13 +326,13 @@ const UserDialog = (props) => { }, [editMode, props.user]); return ( - {editMode ? "Edit user" : "Add user"} + {editMode ? t("prefs_users_dialog_title_edit") : t("prefs_users_dialog_title_add")} {!editMode && setBaseUrl(ev.target.value)} type="url" @@ -337,7 +343,7 @@ const UserDialog = (props) => { autoFocus={editMode} margin="dense" id="username" - label="Username, e.g. phil" + label={t("prefs_users_dialog_username_label")} value={username} onChange={ev => setUsername(ev.target.value)} type="text" @@ -347,7 +353,7 @@ const UserDialog = (props) => { setPassword(ev.target.value)} @@ -356,18 +362,19 @@ const UserDialog = (props) => { /> - - + + ); }; const Appearance = () => { + const { t } = useTranslation(); return ( - Appearance + {t("prefs_appearance_title")} @@ -379,7 +386,7 @@ const Appearance = () => { const Language = () => { const { t, i18n } = useTranslation(); return ( - + setPriority(ev.target.value)} @@ -322,8 +338,8 @@ const SendDialog = (props) => { }}> setClickUrl(ev.target.value)} disabled={disabled} @@ -340,8 +356,8 @@ const SendDialog = (props) => { }}> setEmail(ev.target.value)} disabled={disabled} @@ -360,8 +376,8 @@ const SendDialog = (props) => { }}> { const url = ev.target.value; @@ -385,8 +401,8 @@ const SendDialog = (props) => { /> { setFilename(ev.target.value); @@ -424,8 +440,8 @@ const SendDialog = (props) => { }}> setDelay(ev.target.value)} disabled={disabled} @@ -436,33 +452,37 @@ const SendDialog = (props) => { } - Other features: + {t("publish_dialog_other_features")}
- {!showClickUrl && setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} - {!showEmail && setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>} - {!showAttachUrl && !showAttachFile && setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} - {!showAttachFile && !showAttachUrl && handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>} - {!showDelay && setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>} - {!showTopicUrl && setShowTopicUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} + {!showClickUrl && setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} + {!showEmail && setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>} + {!showAttachUrl && !showAttachFile && setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} + {!showAttachFile && !showAttachUrl && handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>} + {!showDelay && setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>} + {!showTopicUrl && setShowTopicUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
- For examples and a detailed description of all send features, please - refer to the documentation. + + }} + /> - {activeRequest && } + {activeRequest && } {!activeRequest && <> setPublishAnother(ev.target.checked)} /> } /> - - + + } @@ -506,11 +526,12 @@ const DialogIconButton = (props) => { }; const AttachmentBox = (props) => { + const { t } = useTranslation(); const file = props.file; return ( <> - Attached file: + {t("publish_dialog_attached_file_title")} { props.onChangeFilename(ev.target.value)} disabled={props.disabled} @@ -568,7 +590,7 @@ const ExpandingTextField = (props) => { { }; const DropBox = () => { + const { t } = useTranslation(); return ( { alignItems: "center", }} > - Drop file here + {t("publish_dialog_drop_file_here")} ); } -const priorities = { - 1: { label: "Min. priority", file: priority1 }, - 2: { label: "Low priority", file: priority2 }, - 3: { label: "Default priority", file: priority3 }, - 4: { label: "High priority", file: priority4 }, - 5: { label: "Max. priority", file: priority5 } -}; +PublishDialog.OPEN_MODE_DEFAULT = "default"; +PublishDialog.OPEN_MODE_DRAG = "drag"; -SendDialog.OPEN_MODE_DEFAULT = "default"; -SendDialog.OPEN_MODE_DRAG = "drag"; - -export default SendDialog; +export default PublishDialog; diff --git a/web/src/components/SubscribeDialog.js b/web/src/components/SubscribeDialog.js index 5ed75207..6a6d8764 100644 --- a/web/src/components/SubscribeDialog.js +++ b/web/src/components/SubscribeDialog.js @@ -14,6 +14,7 @@ import userManager from "../app/UserManager"; import subscriptionManager from "../app/SubscriptionManager"; import poller from "../app/Poller"; import DialogFooter from "./DialogFooter"; +import {useTranslation} from "react-i18next"; const publicBaseUrl = "https://ntfy.sh"; @@ -51,6 +52,7 @@ const SubscribeDialog = (props) => { }; const SubscribePage = (props) => { + const { t } = useTranslation(); const [anotherServerVisible, setAnotherServerVisible] = useState(false); const [errorText, setErrorText] = useState(""); const baseUrl = (anotherServerVisible) ? props.baseUrl : window.location.origin; @@ -60,12 +62,12 @@ const SubscribePage = (props) => { .filter(s => s !== window.location.origin); const handleSubscribe = async () => { const user = await userManager.get(baseUrl); // May be undefined - const username = (user) ? user.username : "anonymous"; + const username = (user) ? user.username : t("subscribe_dialog_error_user_anonymous"); const success = await api.auth(baseUrl, topic, user); if (!success) { console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`); if (user) { - setErrorText(`User ${username} not authorized`); + setErrorText(t("subscribe_dialog_error_user_not_authorized", { username: username })); return; } else { props.onNeedsLogin(); @@ -90,17 +92,16 @@ const SubscribePage = (props) => { })(); return ( <> - Subscribe to topic + {t("subscribe_dialog_subscribe_title")} - Topics may not be password-protected, so choose a name that's not easy to guess. - Once subscribed, you can PUT/POST notifications. + {t("subscribe_dialog_subscribe_description")} props.setTopic(ev.target.value)} @@ -111,7 +112,7 @@ const SubscribePage = (props) => { } - label="Use another server" /> + label={t("subscribe_dialog_subscribe_use_another_label")} /> {anotherServerVisible && { />} - - + + ); }; const LoginPage = (props) => { + const { t } = useTranslation(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [errorText, setErrorText] = useState(""); @@ -142,7 +144,7 @@ const LoginPage = (props) => { const success = await api.auth(baseUrl, topic, user); if (!success) { console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`); - setErrorText(`User ${username} not authorized`); + setErrorText(t("subscribe_dialog_error_user_not_authorized", { username: username })); return; } console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`); @@ -151,17 +153,16 @@ const LoginPage = (props) => { }; return ( <> - Login required + {t("subscribe_dialog_login_title")} - This topic is password-protected. Please enter username and - password to subscribe. + {t("subscribe_dialog_login_description")} setUsername(ev.target.value)} type="text" @@ -171,7 +172,7 @@ const LoginPage = (props) => { setPassword(ev.target.value)} @@ -180,8 +181,8 @@ const LoginPage = (props) => { /> - - + + ); diff --git a/web/src/components/routes.js b/web/src/components/routes.js index 81042391..7a7a7857 100644 --- a/web/src/components/routes.js +++ b/web/src/components/routes.js @@ -13,4 +13,5 @@ const routes = { return `/${subscription.topic}`; } }; + export default routes;