Finish web app translation

pull/212/head
Philipp Heckel 2022-04-08 10:44:35 -04:00
parent 893701c07b
commit 30726144b8
10 changed files with 272 additions and 132 deletions

View File

@ -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 <websiteLink>website</websiteLink> or <docsLink>documentation</docsLink>.",
"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 <docsLink>documentation</docsLink>.",
"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.<br/>If you have a minute, please <githubLink>report this on GitHub</githubLink>, or let us know via <discordLink>Discord</discordLink> or <matrixLink>Matrix</matrixLink>.",
"error_boundary_button_copy_stack_trace": "Copy stack trace",
"error_boundary_stack_trace": "Stack trace",
"error_boundary_gathering_info": "Gather more info ..."
}

View File

@ -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);
};
}

View File

@ -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 (
<AppBar position="fixed" sx={{
@ -66,6 +68,7 @@ const ActionBar = (props) => {
// 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) => {
<Paper>
<ClickAwayListener onClickAway={handleClose}>
<MenuList autoFocusItem={open} onKeyDown={handleListKeyDown}>
<MenuItem onClick={handleSendTestMessage}>Send test notification</MenuItem>
<MenuItem onClick={handleClearAll}>Clear all notifications</MenuItem>
<MenuItem onClick={handleUnsubscribe}>Unsubscribe</MenuItem>
<MenuItem onClick={handleSendTestMessage}>{t("action_bar_send_test_notification")}</MenuItem>
<MenuItem onClick={handleClearAll}>{t("action_bar_clear_notifications")}</MenuItem>
<MenuItem onClick={handleUnsubscribe}>{t("action_bar_unsubscribe")}</MenuItem>
</MenuList>
</ClickAwayListener>
</Paper>

View File

@ -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)}
/>
<Main>
<Toolbar/>

View File

@ -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 (
<div style={{margin: '20px'}}>
<h2>Oh no, ntfy crashed 😮</h2>
<h2>{t("error_boundary_title")} 😮</h2>
<p>
This should obviously not happen. Very sorry about this.<br/>
If you have a minute, please <a href="https://github.com/binwiederhier/ntfy/issues">report this on GitHub</a>, or let us
know via <a href="https://discord.gg/cT7ECsZj9w">Discord</a> or <a href="https://matrix.to/#/#ntfy:matrix.org">Matrix</a>.
<Trans
i18nKey="error_boundary_description"
components={{
githubLink: <Link href="https://github.com/binwiederhier/ntfy/issues"/>,
discordLink: <Link href="https://discord.gg/cT7ECsZj9w"/>,
matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org"/>
}}
/>
</p>
<p>
<Button variant="outlined" onClick={() => this.copyStack()}>Copy stack trace</Button>
<Button variant="outlined" onClick={() => this.copyStack()}>{t("error_boundary_button_copy_stack_trace")}</Button>
</p>
<h3>Stack trace</h3>
<h3>{t("error_boundary_stack_trace")}</h3>
{this.state.niceStack
? <pre>{this.state.niceStack}</pre>
: <><CircularProgress size="20px" sx={{verticalAlign: "text-bottom"}}/> Gather more info ...</>}
: <><CircularProgress size="20px" sx={{verticalAlign: "text-bottom"}}/> {t("error_boundary_gathering_info")}</>}
<pre>{this.state.originalStack}</pre>
</div>
);
@ -69,4 +76,5 @@ class ErrorBoundary extends React.Component {
}
}
const ErrorBoundary = withTranslation()(ErrorBoundaryImpl); // Adds props.t
export default ErrorBoundary;

View File

@ -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}
/>}
<SendDialog
key={`sendDialog${dialogKey}`} // Resets dialog when canceled/closed
<PublishDialog
key={`publishDialog${dialogKey}`} // Resets dialog when canceled/closed
openMode={dialogOpenMode}
baseUrl={subscription?.baseUrl ?? window.location.origin}
topic={subscription?.topic ?? ""}
message={message}
onClose={handleSendDialogClose}
onDragEnter={() => 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) => {
<TextField
autoFocus
margin="dense"
placeholder="Type a message here"
placeholder={t("message_bar_type_message")}
type="text"
fullWidth
variant="standard"
@ -101,7 +102,7 @@ const MessageBar = (props) => {
open={snackOpen}
autoHideDuration={3000}
onClose={() => setSnackOpen(false)}
message="Error publishing message"
message={t("message_bar_error_publishing")}
/>
</Portal>
</Paper>

View File

@ -48,10 +48,11 @@ const Preferences = () => {
};
const Notifications = () => {
const { t } = useTranslation();
return (
<Card sx={{p: 3}}>
<Typography variant="h5">
Notifications
{t("prefs_notifications_title")}
</Typography>
<PrefGroup>
<Sound/>
@ -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 (
<Pref title="Notification sound">
<Pref title={t("prefs_notifications_sound_title")}>
<div style={{ display: 'flex', width: '100%' }}>
<FormControl fullWidth variant="standard" sx={{ margin: 1 }}>
<Select value={sound} onChange={handleChange}>
<MenuItem value={"none"}>No sound</MenuItem>
<MenuItem value={"none"}>{t("prefs_notifications_sound_no_sound")}</MenuItem>
<MenuItem value={"ding"}>Ding</MenuItem>
<MenuItem value={"juntos"}>Juntos</MenuItem>
<MenuItem value={"pristine"}>Pristine</MenuItem>
@ -94,6 +96,7 @@ const Sound = () => {
};
const MinPriority = () => {
const { t } = useTranslation();
const minPriority = useLiveQuery(async () => prefs.minPriority());
const handleChange = async (ev) => {
await prefs.setMinPriority(ev.target.value);
@ -102,14 +105,14 @@ const MinPriority = () => {
return null; // While loading
}
return (
<Pref title="Minimum priority">
<Pref title={t("prefs_notifications_min_priority_title")}>
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
<Select value={minPriority} onChange={handleChange}>
<MenuItem value={1}>Any priority</MenuItem>
<MenuItem value={2}>Low priority and higher</MenuItem>
<MenuItem value={3}>Default priority and higher</MenuItem>
<MenuItem value={4}>High priority and higher</MenuItem>
<MenuItem value={5}>Only max priority</MenuItem>
<MenuItem value={1}>{t("prefs_notifications_min_priority_any")}</MenuItem>
<MenuItem value={2}>{t("prefs_notifications_min_priority_low_and_higher")}</MenuItem>
<MenuItem value={3}>{t("prefs_notifications_min_priority_default_and_higher")}</MenuItem>
<MenuItem value={4}>{t("prefs_notifications_min_priority_high_and_higher")}</MenuItem>
<MenuItem value={5}>{t("prefs_notifications_min_priority_max_only")}</MenuItem>
</Select>
</FormControl>
</Pref>
@ -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 (
<Pref title="Delete notifications">
<Pref title={t("prefs_notifications_delete_after_title")}>
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
<Select value={deleteAfter} onChange={handleChange}>
<MenuItem value={0}>Never</MenuItem>
<MenuItem value={10800}>After three hours</MenuItem>
<MenuItem value={86400}>After one day</MenuItem>
<MenuItem value={604800}>After one week</MenuItem>
<MenuItem value={2592000}>After one month</MenuItem>
<MenuItem value={0}>{t("prefs_notifications_delete_after_never")}</MenuItem>
<MenuItem value={10800}>{t("prefs_notifications_delete_after_three_hours")}</MenuItem>
<MenuItem value={86400}>{t("prefs_notifications_delete_after_one_day")}</MenuItem>
<MenuItem value={604800}>{t("prefs_notifications_delete_after_one_week")}</MenuItem>
<MenuItem value={2592000}>{t("prefs_notifications_delete_after_one_month")}</MenuItem>
</Select>
</FormControl>
</Pref>
@ -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 = () => {
<Card sx={{ padding: 1 }}>
<CardContent>
<Typography variant="h5">
Manage users
{t("prefs_users_title")}
</Typography>
<Paragraph>
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")}
</Paragraph>
{users?.length > 0 && <UserTable users={users}/>}
</CardContent>
<CardActions>
<Button onClick={handleAddClick}>Add user</Button>
<Button onClick={handleAddClick}>{t("prefs_users_add_button")}</Button>
<UserDialog
key={`userAddDialog${dialogKey}`}
open={dialogOpen}
@ -223,6 +227,7 @@ const Users = () => {
};
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) => {
<Table size="small">
<TableHead>
<TableRow>
<TableCell>User</TableCell>
<TableCell>Service URL</TableCell>
<TableCell>{t("prefs_users_table_user_header")}</TableCell>
<TableCell>{t("prefs_users_table_base_url_header")}</TableCell>
<TableCell/>
</TableRow>
</TableHead>
@ -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 (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
<DialogTitle>{editMode ? "Edit user" : "Add user"}</DialogTitle>
<DialogTitle>{editMode ? t("prefs_users_dialog_title_edit") : t("prefs_users_dialog_title_add")}</DialogTitle>
<DialogContent>
{!editMode && <TextField
autoFocus
margin="dense"
id="baseUrl"
label="Service URL, e.g. https://ntfy.sh"
label={t("prefs_users_dialog_base_url_label")}
value={baseUrl}
onChange={ev => 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) => {
<TextField
margin="dense"
id="password"
label="Password"
label={t("prefs_users_dialog_password_label")}
type="password"
value={password}
onChange={ev => setPassword(ev.target.value)}
@ -356,18 +362,19 @@ const UserDialog = (props) => {
/>
</DialogContent>
<DialogActions>
<Button onClick={props.onCancel}>Cancel</Button>
<Button onClick={handleSubmit} disabled={!addButtonEnabled}>{editMode ? "Save" : "Add"}</Button>
<Button onClick={props.onCancel}>{t("prefs_users_dialog_button_cancel")}</Button>
<Button onClick={handleSubmit} disabled={!addButtonEnabled}>{editMode ? t("prefs_users_dialog_button_save") : t("prefs_users_dialog_button_add")}</Button>
</DialogActions>
</Dialog>
);
};
const Appearance = () => {
const { t } = useTranslation();
return (
<Card sx={{p: 3}}>
<Typography variant="h5">
Appearance
{t("prefs_appearance_title")}
</Typography>
<PrefGroup>
<Language/>
@ -379,7 +386,7 @@ const Appearance = () => {
const Language = () => {
const { t, i18n } = useTranslation();
return (
<Pref title="Language">
<Pref title={t("prefs_appearance_language_title")}>
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
<Select value={i18n.language} onChange={(ev) => i18n.changeLanguage(ev.target.value)}>
<MenuItem value="en">English</MenuItem>

View File

@ -25,8 +25,10 @@ import DialogFooter from "./DialogFooter";
import api from "../app/Api";
import userManager from "../app/UserManager";
import EmojiPicker from "./EmojiPicker";
import {Trans, useTranslation} from "react-i18next";
const SendDialog = (props) => {
const PublishDialog = (props) => {
const { t } = useTranslation();
const [baseUrl, setBaseUrl] = useState("");
const [topic, setTopic] = useState("");
const [message, setMessage] = useState("");
@ -123,10 +125,13 @@ const SendDialog = (props) => {
const headers = maybeWithBasicAuth({}, user);
const progressFn = (ev) => {
if (ev.loaded > 0 && ev.total > 0) {
const percent = Math.round(ev.loaded * 100.0 / ev.total);
setStatus(`Uploading ${formatBytes(ev.loaded)}/${formatBytes(ev.total)} (${percent}%) ...`);
setStatus(t("publish_dialog_progress_uploading_detail", {
loaded: formatBytes(ev.loaded),
total: formatBytes(ev.total),
percent: Math.round(ev.loaded * 100.0 / ev.total)
}));
} else {
setStatus(`Uploading ...`);
setStatus(t("publish_dialog_progress_uploading"));
}
};
const request = api.publishXHR(url, body, headers, progressFn);
@ -135,7 +140,7 @@ const SendDialog = (props) => {
if (!publishAnother) {
props.onClose();
} else {
setStatus("Message published");
setStatus(t("publish_dialog_message_published"));
setActiveRequest(null);
}
} catch (e) {
@ -152,11 +157,14 @@ const SendDialog = (props) => {
const fileSizeLimitReached = fileSizeLimit > 0 && file.size > fileSizeLimit;
const quotaReached = remainingBytes > 0 && file.size > remainingBytes;
if (fileSizeLimitReached && quotaReached) {
return setAttachFileError(`exceeds ${formatBytes(fileSizeLimit)} file limit and quota, ${formatBytes(remainingBytes)} remaining`);
return setAttachFileError(t("publish_dialog_attachment_limits_file_and_quota_reached", {
fileSizeLimit: formatBytes(fileSizeLimit),
remainingBytes: formatBytes(remainingBytes)
}));
} else if (fileSizeLimitReached) {
return setAttachFileError(`exceeds ${formatBytes(fileSizeLimit)} file limit`);
return setAttachFileError(t("publish_dialog_attachment_limits_file_reached", { fileSizeLimit: formatBytes(fileSizeLimit) }));
} else if (quotaReached) {
return setAttachFileError(`exceeds quota, ${formatBytes(remainingBytes)} remaining`);
return setAttachFileError(t("publish_dialog_attachment_limits_quota_reached", { remainingBytes: formatBytes(remainingBytes) }));
}
setAttachFileError("");
} catch (e) {
@ -188,7 +196,7 @@ const SendDialog = (props) => {
const handleAttachFileDragLeave = () => {
setDropZone(false);
if (props.openMode === SendDialog.OPEN_MODE_DRAG) {
if (props.openMode === PublishDialog.OPEN_MODE_DRAG) {
props.onClose(); // Only close dialog if it was not open before dragging file in
}
};
@ -205,6 +213,14 @@ const SendDialog = (props) => {
setEmojiPickerAnchorEl(null);
};
const priorities = {
1: { label: t("publish_dialog_priority_min"), file: priority1 },
2: { label: t("publish_dialog_priority_low"), file: priority2 },
3: { label: t("publish_dialog_priority_default"), file: priority3 },
4: { label: t("publish_dialog_priority_high"), file: priority4 },
5: { label: t("publish_dialog_priority_max"), file: priority5 }
};
return (
<>
{dropZone && <DropArea
@ -212,7 +228,7 @@ const SendDialog = (props) => {
onDragLeave={handleAttachFileDragLeave}/>
}
<Dialog maxWidth="md" open={open} onClose={props.onCancel} fullScreen={fullScreen}>
<DialogTitle>{(baseUrl && topic) ? `Publish to ${topicShortUrl(baseUrl, topic)}` : "Publish message"}</DialogTitle>
<DialogTitle>{(baseUrl && topic) ? t("publish_dialog_title_topic", { topic: topicShortUrl(baseUrl, topic) }) : t("publish_dialog_title_no_topic")}</DialogTitle>
<DialogContent>
{dropZone && <DropBox/>}
{showTopicUrl &&
@ -223,8 +239,8 @@ const SendDialog = (props) => {
}}>
<TextField
margin="dense"
label="Server URL"
placeholder="Server URL, e.g. https://example.com"
label={t("publish_dialog_base_url_label")}
placeholder={t("publish_dialog_base_url_placeholder")}
value={baseUrl}
onChange={ev => setBaseUrl(ev.target.value)}
disabled={disabled}
@ -234,8 +250,8 @@ const SendDialog = (props) => {
/>
<TextField
margin="dense"
label="Topic"
placeholder="Topic name, e.g. phil_alerts"
label={t("publish_dialog_topic_label")}
placeholder={t("publish_dialog_topic_placeholder")}
value={topic}
onChange={ev => setTopic(ev.target.value)}
disabled={disabled}
@ -248,19 +264,19 @@ const SendDialog = (props) => {
}
<TextField
margin="dense"
label="Title"
label={t("publish_dialog_title_label")}
placeholder={t("publish_dialog_title_placeholder")}
value={title}
onChange={ev => setTitle(ev.target.value)}
disabled={disabled}
type="text"
fullWidth
variant="standard"
placeholder="Notification title, e.g. Disk space alert"
/>
<TextField
margin="dense"
label="Message"
placeholder="Type a message here"
label={t("publish_dialog_message_label")}
placeholder={t("publish_dialog_message_placeholder")}
value={message}
onChange={ev => setMessage(ev.target.value)}
disabled={disabled}
@ -282,8 +298,8 @@ const SendDialog = (props) => {
</DialogIconButton>
<TextField
margin="dense"
label="Tags"
placeholder="Comma-separated list of tags, e.g. warning, srv1-backup"
label={t("publish_dialog_tags_label")}
placeholder={t("publish_dialog_tags_placeholder")}
value={tags}
onChange={ev => setTags(ev.target.value)}
disabled={disabled}
@ -298,7 +314,7 @@ const SendDialog = (props) => {
>
<InputLabel/>
<Select
label="Priority"
label={t("publish_dialog_priority_label")}
margin="dense"
value={priority}
onChange={(ev) => setPriority(ev.target.value)}
@ -322,8 +338,8 @@ const SendDialog = (props) => {
}}>
<TextField
margin="dense"
label="Click URL"
placeholder="URL that is opened when notification is clicked"
label={t("publish_dialog_click_label")}
placeholder={t("publish_dialog_click_placeholder")}
value={clickUrl}
onChange={ev => setClickUrl(ev.target.value)}
disabled={disabled}
@ -340,8 +356,8 @@ const SendDialog = (props) => {
}}>
<TextField
margin="dense"
label="Email"
placeholder="Address to forward the message to, e.g. phil@example.com"
label={t("publish_dialog_email_label")}
placeholder={t("publish_dialog_email_placeholder")}
value={email}
onChange={ev => setEmail(ev.target.value)}
disabled={disabled}
@ -360,8 +376,8 @@ const SendDialog = (props) => {
}}>
<TextField
margin="dense"
label="Attachment URL"
placeholder="Attach file by URL, e.g. https://f-droid.org/F-Droid.apk"
label={t("publish_dialog_attach_label")}
placeholder={t("publish_dialog_attach_placeholder")}
value={attachUrl}
onChange={ev => {
const url = ev.target.value;
@ -385,8 +401,8 @@ const SendDialog = (props) => {
/>
<TextField
margin="dense"
label="Filename"
placeholder="Attachment filename"
label={t("publish_dialog_filename_label")}
placeholder={t("publish_dialog_filename_placeholder")}
value={filename}
onChange={ev => {
setFilename(ev.target.value);
@ -424,8 +440,8 @@ const SendDialog = (props) => {
}}>
<TextField
margin="dense"
label="Delay"
placeholder="Delay delivery, e.g. 1649029748, 30m, or tomorrow, 9am"
label={t("publish_dialog_delay_label")}
placeholder={t("publish_dialog_delay_placeholder")}
value={delay}
onChange={ev => setDelay(ev.target.value)}
disabled={disabled}
@ -436,33 +452,37 @@ const SendDialog = (props) => {
</ClosableRow>
}
<Typography variant="body1" sx={{marginTop: 2, marginBottom: 1}}>
Other features:
{t("publish_dialog_other_features")}
</Typography>
<div>
{!showClickUrl && <Chip clickable disabled={disabled} label="Click URL" onClick={() => setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showEmail && <Chip clickable disabled={disabled} label="Forward to email" onClick={() => setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showAttachUrl && !showAttachFile && <Chip clickable disabled={disabled} label="Attach file by URL" onClick={() => setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showAttachFile && !showAttachUrl && <Chip clickable disabled={disabled} label="Attach local file" onClick={() => handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showDelay && <Chip clickable disabled={disabled} label="Delay delivery" onClick={() => setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showTopicUrl && <Chip clickable disabled={disabled} label="Change topic" onClick={() => setShowTopicUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showClickUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_click_label")} onClick={() => setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showEmail && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_email_label")} onClick={() => setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showAttachUrl && !showAttachFile && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_url_label")} onClick={() => setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showAttachFile && !showAttachUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_file_label")} onClick={() => handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showDelay && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_delay_label")} onClick={() => setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showTopicUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_topic_label")} onClick={() => setShowTopicUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
</div>
<Typography variant="body1" sx={{marginTop: 1, marginBottom: 1}}>
For examples and a detailed description of all send features, please
refer to the <Link href="/docs" target="_blank">documentation</Link>.
<Trans
i18nKey="publish_dialog_details_examples_description"
components={{
docsLink: <Link href="https://ntfy.sh/docs" target="_blank" rel="noopener"/>
}}
/>
</Typography>
</DialogContent>
<DialogFooter status={status}>
{activeRequest && <Button onClick={() => activeRequest.abort()}>Cancel sending</Button>}
{activeRequest && <Button onClick={() => activeRequest.abort()}>{t("publish_dialog_button_cancel_sending")}</Button>}
{!activeRequest &&
<>
<FormControlLabel
label="Publish another"
label={t("publish_dialog_checkbox_publish_another")}
sx={{marginRight: 2}}
control={
<Checkbox size="small" checked={publishAnother} onChange={(ev) => setPublishAnother(ev.target.checked)} />
} />
<Button onClick={props.onClose}>Cancel</Button>
<Button onClick={handleSubmit} disabled={!sendButtonEnabled}>Send</Button>
<Button onClick={props.onClose}>{t("publish_dialog_button_cancel")}</Button>
<Button onClick={handleSubmit} disabled={!sendButtonEnabled}>{t("publish_dialog_button_send")}</Button>
</>
}
</DialogFooter>
@ -506,11 +526,12 @@ const DialogIconButton = (props) => {
};
const AttachmentBox = (props) => {
const { t } = useTranslation();
const file = props.file;
return (
<>
<Typography variant="body1" sx={{marginTop: 2}}>
Attached file:
{t("publish_dialog_attached_file_title")}
</Typography>
<Box sx={{
display: 'flex',
@ -523,6 +544,7 @@ const AttachmentBox = (props) => {
<ExpandingTextField
minWidth={140}
variant="body2"
placeholder={t("publish_dialog_attached_file_filename_placeholder")}
value={props.filename}
onChange={(ev) => props.onChangeFilename(ev.target.value)}
disabled={props.disabled}
@ -568,7 +590,7 @@ const ExpandingTextField = (props) => {
</Typography>
<TextField
margin="dense"
placeholder="Attachment filename"
placeholder={props.placeholder}
value={props.value}
onChange={props.onChange}
type="text"
@ -610,6 +632,7 @@ const DropArea = (props) => {
};
const DropBox = () => {
const { t } = useTranslation();
return (
<Box sx={{
position: 'absolute',
@ -635,21 +658,13 @@ const DropBox = () => {
alignItems: "center",
}}
>
<Typography variant="h5">Drop file here</Typography>
<Typography variant="h5">{t("publish_dialog_drop_file_here")}</Typography>
</Box>
</Box>
);
}
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;

View File

@ -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 (
<>
<DialogTitle>Subscribe to topic</DialogTitle>
<DialogTitle>{t("subscribe_dialog_subscribe_title")}</DialogTitle>
<DialogContent>
<DialogContentText>
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")}
</DialogContentText>
<TextField
autoFocus
margin="dense"
id="topic"
placeholder="Topic name, e.g. phil_alerts"
placeholder={t("subscribe_dialog_subscribe_topic_placeholder")}
inputProps={{ maxLength: 64 }}
value={props.topic}
onChange={ev => props.setTopic(ev.target.value)}
@ -111,7 +112,7 @@ const SubscribePage = (props) => {
<FormControlLabel
sx={{pt: 1}}
control={<Checkbox onChange={handleUseAnotherChanged}/>}
label="Use another server" />
label={t("subscribe_dialog_subscribe_use_another_label")} />
{anotherServerVisible && <Autocomplete
freeSolo
options={existingBaseUrls}
@ -124,14 +125,15 @@ const SubscribePage = (props) => {
/>}
</DialogContent>
<DialogFooter status={errorText}>
<Button onClick={props.onCancel}>Cancel</Button>
<Button onClick={handleSubscribe} disabled={!subscribeButtonEnabled}>Subscribe</Button>
<Button onClick={props.onCancel}>{t("subscribe_dialog_subscribe_button_cancel")}</Button>
<Button onClick={handleSubscribe} disabled={!subscribeButtonEnabled}>{t("subscribe_dialog_subscribe_button_subscribe")}</Button>
</DialogFooter>
</>
);
};
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 (
<>
<DialogTitle>Login required</DialogTitle>
<DialogTitle>{t("subscribe_dialog_login_title")}</DialogTitle>
<DialogContent>
<DialogContentText>
This topic is password-protected. Please enter username and
password to subscribe.
{t("subscribe_dialog_login_description")}
</DialogContentText>
<TextField
autoFocus
margin="dense"
id="username"
label="Username, e.g. phil"
label={t("subscribe_dialog_login_username_label")}
value={username}
onChange={ev => setUsername(ev.target.value)}
type="text"
@ -171,7 +172,7 @@ const LoginPage = (props) => {
<TextField
margin="dense"
id="password"
label="Password"
label={t("subscribe_dialog_login_password_label")}
type="password"
value={password}
onChange={ev => setPassword(ev.target.value)}
@ -180,8 +181,8 @@ const LoginPage = (props) => {
/>
</DialogContent>
<DialogFooter status={errorText}>
<Button onClick={props.onBack}>Back</Button>
<Button onClick={handleLogin}>Login</Button>
<Button onClick={props.onBack}>{t("subscribe_dialog_login_button_back")}</Button>
<Button onClick={handleLogin}>{t("subscribe_dialog_login_button_login")}</Button>
</DialogFooter>
</>
);

View File

@ -13,4 +13,5 @@ const routes = {
return `/${subscription.topic}`;
}
};
export default routes;