Preferences.jsx 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765
  1. import * as React from "react";
  2. import { useContext, useEffect, useState } from "react";
  3. import {
  4. Alert,
  5. CardActions,
  6. CardContent,
  7. Chip,
  8. FormControl,
  9. Select,
  10. Stack,
  11. Table,
  12. TableBody,
  13. TableCell,
  14. TableHead,
  15. TableRow,
  16. Tooltip,
  17. useMediaQuery,
  18. Typography,
  19. IconButton,
  20. Container,
  21. TextField,
  22. MenuItem,
  23. Card,
  24. Button,
  25. Dialog,
  26. DialogTitle,
  27. DialogContent,
  28. DialogActions,
  29. useTheme,
  30. } from "@mui/material";
  31. import EditIcon from "@mui/icons-material/Edit";
  32. import CloseIcon from "@mui/icons-material/Close";
  33. import PlayArrowIcon from "@mui/icons-material/PlayArrow";
  34. import { useLiveQuery } from "dexie-react-hooks";
  35. import { useTranslation } from "react-i18next";
  36. import { Info } from "@mui/icons-material";
  37. import { useOutletContext } from "react-router-dom";
  38. import userManager from "../app/UserManager";
  39. import { playSound, shortUrl, shuffle, sounds, validUrl } from "../app/utils";
  40. import session from "../app/Session";
  41. import routes from "./routes";
  42. import accountApi, { Permission, Role } from "../app/AccountApi";
  43. import { Pref, PrefGroup } from "./Pref";
  44. import { AccountContext } from "./App";
  45. import { Paragraph } from "./styles";
  46. import prefs, { THEME } from "../app/Prefs";
  47. import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
  48. import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs";
  49. import { UnauthorizedError } from "../app/errors";
  50. import { subscribeTopic } from "./SubscribeDialog";
  51. import notifier from "../app/Notifier";
  52. import { useIsLaunchedPWA, useNotificationPermissionListener } from "./hooks";
  53. const maybeUpdateAccountSettings = async (payload) => {
  54. if (!session.exists()) {
  55. return;
  56. }
  57. try {
  58. await accountApi.updateSettings(payload);
  59. } catch (e) {
  60. console.log(`[Preferences] Error updating account settings`, e);
  61. if (e instanceof UnauthorizedError) {
  62. await session.resetAndRedirect(routes.login);
  63. }
  64. }
  65. };
  66. const Preferences = () => (
  67. <Container maxWidth="md" sx={{ marginTop: 3, marginBottom: 3 }}>
  68. <Stack spacing={3}>
  69. <Notifications />
  70. <Reservations />
  71. <Users />
  72. <Appearance />
  73. </Stack>
  74. </Container>
  75. );
  76. const Notifications = () => {
  77. const { t } = useTranslation();
  78. const isLaunchedPWA = useIsLaunchedPWA();
  79. const pushPossible = useNotificationPermissionListener(() => notifier.pushPossible());
  80. return (
  81. <Card sx={{ p: 3 }} aria-label={t("prefs_notifications_title")}>
  82. <Typography variant="h5" sx={{ marginBottom: 2 }}>
  83. {t("prefs_notifications_title")}
  84. </Typography>
  85. <PrefGroup>
  86. <Sound />
  87. <MinPriority />
  88. <DeleteAfter />
  89. {!isLaunchedPWA && pushPossible && <WebPushEnabled />}
  90. </PrefGroup>
  91. </Card>
  92. );
  93. };
  94. const Sound = () => {
  95. const { t } = useTranslation();
  96. const labelId = "prefSound";
  97. const sound = useLiveQuery(async () => prefs.sound());
  98. const handleChange = async (ev) => {
  99. await prefs.setSound(ev.target.value);
  100. await maybeUpdateAccountSettings({
  101. notification: {
  102. sound: ev.target.value,
  103. },
  104. });
  105. };
  106. if (!sound) {
  107. return null; // While loading
  108. }
  109. let description;
  110. if (sound === "none") {
  111. description = t("prefs_notifications_sound_description_none");
  112. } else {
  113. description = t("prefs_notifications_sound_description_some", {
  114. sound: sounds[sound].label,
  115. });
  116. }
  117. return (
  118. <Pref labelId={labelId} title={t("prefs_notifications_sound_title")} description={description}>
  119. <div style={{ display: "flex", width: "100%" }}>
  120. <FormControl fullWidth variant="standard" sx={{ margin: 1 }}>
  121. <Select value={sound} onChange={handleChange} aria-labelledby={labelId}>
  122. <MenuItem value="none">{t("prefs_notifications_sound_no_sound")}</MenuItem>
  123. {Object.entries(sounds).map((s) => (
  124. <MenuItem key={s[0]} value={s[0]}>
  125. {s[1].label}
  126. </MenuItem>
  127. ))}
  128. </Select>
  129. </FormControl>
  130. <IconButton onClick={() => playSound(sound)} disabled={sound === "none"} aria-label={t("prefs_notifications_sound_play")}>
  131. <PlayArrowIcon />
  132. </IconButton>
  133. </div>
  134. </Pref>
  135. );
  136. };
  137. const MinPriority = () => {
  138. const { t } = useTranslation();
  139. const labelId = "prefMinPriority";
  140. const minPriority = useLiveQuery(async () => prefs.minPriority());
  141. const handleChange = async (ev) => {
  142. await prefs.setMinPriority(ev.target.value);
  143. await maybeUpdateAccountSettings({
  144. notification: {
  145. min_priority: ev.target.value,
  146. },
  147. });
  148. };
  149. if (!minPriority) {
  150. return null; // While loading
  151. }
  152. const priorities = {
  153. 1: t("priority_min"),
  154. 2: t("priority_low"),
  155. 3: t("priority_default"),
  156. 4: t("priority_high"),
  157. 5: t("priority_max"),
  158. };
  159. let description;
  160. if (minPriority === 1) {
  161. description = t("prefs_notifications_min_priority_description_any");
  162. } else if (minPriority === 5) {
  163. description = t("prefs_notifications_min_priority_description_max");
  164. } else {
  165. description = t("prefs_notifications_min_priority_description_x_or_higher", {
  166. number: minPriority,
  167. name: priorities[minPriority],
  168. });
  169. }
  170. return (
  171. <Pref labelId={labelId} title={t("prefs_notifications_min_priority_title")} description={description}>
  172. <FormControl fullWidth variant="standard" sx={{ m: 1 }}>
  173. <Select value={minPriority} onChange={handleChange} aria-labelledby={labelId}>
  174. <MenuItem value={1}>{t("prefs_notifications_min_priority_any")}</MenuItem>
  175. <MenuItem value={2}>{t("prefs_notifications_min_priority_low_and_higher")}</MenuItem>
  176. <MenuItem value={3}>{t("prefs_notifications_min_priority_default_and_higher")}</MenuItem>
  177. <MenuItem value={4}>{t("prefs_notifications_min_priority_high_and_higher")}</MenuItem>
  178. <MenuItem value={5}>{t("prefs_notifications_min_priority_max_only")}</MenuItem>
  179. </Select>
  180. </FormControl>
  181. </Pref>
  182. );
  183. };
  184. const DeleteAfter = () => {
  185. const { t } = useTranslation();
  186. const labelId = "prefDeleteAfter";
  187. const deleteAfter = useLiveQuery(async () => prefs.deleteAfter());
  188. const handleChange = async (ev) => {
  189. await prefs.setDeleteAfter(ev.target.value);
  190. await maybeUpdateAccountSettings({
  191. notification: {
  192. delete_after: ev.target.value,
  193. },
  194. });
  195. };
  196. if (deleteAfter === null || deleteAfter === undefined) {
  197. // !deleteAfter will not work with "0"
  198. return null; // While loading
  199. }
  200. const description = (() => {
  201. switch (deleteAfter) {
  202. case 0:
  203. return t("prefs_notifications_delete_after_never_description");
  204. case 10800:
  205. return t("prefs_notifications_delete_after_three_hours_description");
  206. case 86400:
  207. return t("prefs_notifications_delete_after_one_day_description");
  208. case 604800:
  209. return t("prefs_notifications_delete_after_one_week_description");
  210. case 2592000:
  211. return t("prefs_notifications_delete_after_one_month_description");
  212. default:
  213. return "";
  214. }
  215. })();
  216. return (
  217. <Pref labelId={labelId} title={t("prefs_notifications_delete_after_title")} description={description}>
  218. <FormControl fullWidth variant="standard" sx={{ m: 1 }}>
  219. <Select value={deleteAfter} onChange={handleChange} aria-labelledby={labelId}>
  220. <MenuItem value={0}>{t("prefs_notifications_delete_after_never")}</MenuItem>
  221. <MenuItem value={10800}>{t("prefs_notifications_delete_after_three_hours")}</MenuItem>
  222. <MenuItem value={86400}>{t("prefs_notifications_delete_after_one_day")}</MenuItem>
  223. <MenuItem value={604800}>{t("prefs_notifications_delete_after_one_week")}</MenuItem>
  224. <MenuItem value={2592000}>{t("prefs_notifications_delete_after_one_month")}</MenuItem>
  225. </Select>
  226. </FormControl>
  227. </Pref>
  228. );
  229. };
  230. const Theme = () => {
  231. const { t } = useTranslation();
  232. const labelId = "prefTheme";
  233. const theme = useLiveQuery(async () => prefs.theme());
  234. const handleChange = async (ev) => {
  235. await prefs.setTheme(ev.target.value);
  236. };
  237. return (
  238. <Pref labelId={labelId} title={t("prefs_appearance_theme_title")}>
  239. <FormControl fullWidth variant="standard" sx={{ m: 1 }}>
  240. <Select value={theme ?? THEME.SYSTEM} onChange={handleChange} aria-labelledby={labelId}>
  241. <MenuItem value={THEME.SYSTEM}>{t("prefs_appearance_theme_system")}</MenuItem>
  242. <MenuItem value={THEME.DARK}>{t("prefs_appearance_theme_dark")}</MenuItem>
  243. <MenuItem value={THEME.LIGHT}>{t("prefs_appearance_theme_light")}</MenuItem>
  244. </Select>
  245. </FormControl>
  246. </Pref>
  247. );
  248. };
  249. const WebPushEnabled = () => {
  250. const { t } = useTranslation();
  251. const labelId = "prefWebPushEnabled";
  252. const enabled = useLiveQuery(async () => prefs.webPushEnabled());
  253. const handleChange = async (ev) => {
  254. await prefs.setWebPushEnabled(ev.target.value);
  255. };
  256. return (
  257. <Pref
  258. labelId={labelId}
  259. title={t("prefs_notifications_web_push_title")}
  260. description={enabled ? t("prefs_notifications_web_push_enabled_description") : t("prefs_notifications_web_push_disabled_description")}
  261. >
  262. <FormControl fullWidth variant="standard" sx={{ m: 1 }}>
  263. <Select value={enabled ?? false} onChange={handleChange} aria-labelledby={labelId}>
  264. <MenuItem value>{t("prefs_notifications_web_push_enabled", { server: shortUrl(config.base_url) })}</MenuItem>
  265. <MenuItem value={false}>{t("prefs_notifications_web_push_disabled")}</MenuItem>
  266. </Select>
  267. </FormControl>
  268. </Pref>
  269. );
  270. };
  271. const Users = () => {
  272. const { t } = useTranslation();
  273. const [dialogKey, setDialogKey] = useState(0);
  274. const [dialogOpen, setDialogOpen] = useState(false);
  275. const users = useLiveQuery(() => userManager.all());
  276. const handleAddClick = () => {
  277. setDialogKey((prev) => prev + 1);
  278. setDialogOpen(true);
  279. };
  280. const handleDialogCancel = () => {
  281. setDialogOpen(false);
  282. };
  283. const handleDialogSubmit = async (user) => {
  284. setDialogOpen(false);
  285. try {
  286. await userManager.save(user);
  287. console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} added`);
  288. } catch (e) {
  289. console.log(`[Preferences] Error adding user.`, e);
  290. }
  291. };
  292. return (
  293. <Card sx={{ padding: 1 }} aria-label={t("prefs_users_title")}>
  294. <CardContent sx={{ paddingBottom: 1 }}>
  295. <Typography variant="h5" sx={{ marginBottom: 2 }}>
  296. {t("prefs_users_title")}
  297. </Typography>
  298. <Paragraph>
  299. {t("prefs_users_description")}
  300. {session.exists() && <>{` ${t("prefs_users_description_no_sync")}`}</>}
  301. </Paragraph>
  302. {users?.length > 0 && <UserTable users={users} />}
  303. </CardContent>
  304. <CardActions>
  305. <Button onClick={handleAddClick}>{t("prefs_users_add_button")}</Button>
  306. <UserDialog
  307. key={`userAddDialog${dialogKey}`}
  308. open={dialogOpen}
  309. user={null}
  310. users={users}
  311. onCancel={handleDialogCancel}
  312. onSubmit={handleDialogSubmit}
  313. />
  314. </CardActions>
  315. </Card>
  316. );
  317. };
  318. const UserTable = (props) => {
  319. const { t } = useTranslation();
  320. const [dialogKey, setDialogKey] = useState(0);
  321. const [dialogOpen, setDialogOpen] = useState(false);
  322. const [dialogUser, setDialogUser] = useState(null);
  323. const handleEditClick = (user) => {
  324. setDialogKey((prev) => prev + 1);
  325. setDialogUser(user);
  326. setDialogOpen(true);
  327. };
  328. const handleDialogCancel = () => {
  329. setDialogOpen(false);
  330. };
  331. const handleDialogSubmit = async (user) => {
  332. setDialogOpen(false);
  333. try {
  334. await userManager.save(user);
  335. console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} updated`);
  336. } catch (e) {
  337. console.log(`[Preferences] Error updating user.`, e);
  338. }
  339. };
  340. const handleDeleteClick = async (user) => {
  341. try {
  342. await userManager.delete(user.baseUrl);
  343. console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} deleted`);
  344. } catch (e) {
  345. console.error(`[Preferences] Error deleting user for ${user.baseUrl}`, e);
  346. }
  347. };
  348. return (
  349. <Table size="small" aria-label={t("prefs_users_table")}>
  350. <TableHead>
  351. <TableRow>
  352. <TableCell sx={{ paddingLeft: 0 }}>{t("prefs_users_table_user_header")}</TableCell>
  353. <TableCell>{t("prefs_users_table_base_url_header")}</TableCell>
  354. <TableCell />
  355. </TableRow>
  356. </TableHead>
  357. <TableBody>
  358. {props.users?.map((user) => (
  359. <TableRow key={user.baseUrl} sx={{ "&:last-child td, &:last-child th": { border: 0 } }}>
  360. <TableCell component="th" scope="row" sx={{ paddingLeft: 0 }} aria-label={t("prefs_users_table_user_header")}>
  361. {user.username}
  362. </TableCell>
  363. <TableCell aria-label={t("prefs_users_table_base_url_header")}>{user.baseUrl}</TableCell>
  364. <TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
  365. {(!session.exists() || user.baseUrl !== config.base_url) && (
  366. <>
  367. <IconButton onClick={() => handleEditClick(user)} aria-label={t("prefs_users_edit_button")}>
  368. <EditIcon />
  369. </IconButton>
  370. <IconButton onClick={() => handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}>
  371. <CloseIcon />
  372. </IconButton>
  373. </>
  374. )}
  375. {session.exists() && user.baseUrl === config.base_url && (
  376. <Tooltip title={t("prefs_users_table_cannot_delete_or_edit")}>
  377. <span>
  378. <IconButton disabled>
  379. <EditIcon />
  380. </IconButton>
  381. <IconButton disabled>
  382. <CloseIcon />
  383. </IconButton>
  384. </span>
  385. </Tooltip>
  386. )}
  387. </TableCell>
  388. </TableRow>
  389. ))}
  390. </TableBody>
  391. <UserDialog
  392. key={`userEditDialog${dialogKey}`}
  393. open={dialogOpen}
  394. user={dialogUser}
  395. users={props.users}
  396. onCancel={handleDialogCancel}
  397. onSubmit={handleDialogSubmit}
  398. />
  399. </Table>
  400. );
  401. };
  402. const UserDialog = (props) => {
  403. const theme = useTheme();
  404. const { t } = useTranslation();
  405. const [baseUrl, setBaseUrl] = useState("");
  406. const [username, setUsername] = useState("");
  407. const [password, setPassword] = useState("");
  408. const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
  409. const editMode = props.user !== null;
  410. const baseUrlValid = baseUrl.length === 0 || validUrl(baseUrl);
  411. const baseUrlExists = props.users?.map((user) => user.baseUrl).includes(baseUrl);
  412. const baseUrlError = baseUrl.length > 0 && (!baseUrlValid || baseUrlExists);
  413. const addButtonEnabled = (() => {
  414. if (editMode) {
  415. return username.length > 0 && password.length > 0;
  416. }
  417. return validUrl(baseUrl) && !baseUrlExists && username.length > 0 && password.length > 0;
  418. })();
  419. const handleSubmit = async () => {
  420. props.onSubmit({
  421. baseUrl,
  422. username,
  423. password,
  424. });
  425. };
  426. useEffect(() => {
  427. if (editMode) {
  428. setBaseUrl(props.user.baseUrl);
  429. setUsername(props.user.username);
  430. setPassword(props.user.password);
  431. }
  432. }, [editMode, props.user]);
  433. return (
  434. <Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
  435. <DialogTitle>{editMode ? t("prefs_users_dialog_title_edit") : t("prefs_users_dialog_title_add")}</DialogTitle>
  436. <DialogContent>
  437. {!editMode && (
  438. <TextField
  439. autoFocus
  440. margin="dense"
  441. id="baseUrl"
  442. label={t("prefs_users_dialog_base_url_label")}
  443. aria-label={t("prefs_users_dialog_base_url_label")}
  444. value={baseUrl}
  445. onChange={(ev) => setBaseUrl(ev.target.value)}
  446. type="url"
  447. fullWidth
  448. variant="standard"
  449. error={baseUrlError}
  450. helperText={
  451. baseUrl.length > 0 && !baseUrlValid
  452. ? t("prefs_users_dialog_base_url_invalid")
  453. : baseUrlExists
  454. ? t("prefs_users_dialog_base_url_exists")
  455. : ""
  456. }
  457. />
  458. )}
  459. <TextField
  460. autoFocus={editMode}
  461. margin="dense"
  462. id="username"
  463. label={t("prefs_users_dialog_username_label")}
  464. aria-label={t("prefs_users_dialog_username_label")}
  465. value={username}
  466. onChange={(ev) => setUsername(ev.target.value)}
  467. type="text"
  468. fullWidth
  469. variant="standard"
  470. />
  471. <TextField
  472. margin="dense"
  473. id="password"
  474. label={t("prefs_users_dialog_password_label")}
  475. aria-label={t("prefs_users_dialog_password_label")}
  476. type="password"
  477. value={password}
  478. onChange={(ev) => setPassword(ev.target.value)}
  479. fullWidth
  480. variant="standard"
  481. />
  482. </DialogContent>
  483. <DialogActions>
  484. <Button onClick={props.onCancel}>{t("common_cancel")}</Button>
  485. <Button onClick={handleSubmit} disabled={!addButtonEnabled}>
  486. {editMode ? t("common_save") : t("common_add")}
  487. </Button>
  488. </DialogActions>
  489. </Dialog>
  490. );
  491. };
  492. const Appearance = () => {
  493. const { t } = useTranslation();
  494. return (
  495. <Card sx={{ p: 3 }} aria-label={t("prefs_appearance_title")}>
  496. <Typography variant="h5" sx={{ marginBottom: 2 }}>
  497. {t("prefs_appearance_title")}
  498. </Typography>
  499. <PrefGroup>
  500. <Theme />
  501. <Language />
  502. </PrefGroup>
  503. </Card>
  504. );
  505. };
  506. const Language = () => {
  507. const { t, i18n } = useTranslation();
  508. const labelId = "prefLanguage";
  509. const lang = i18n.resolvedLanguage ?? "en";
  510. // Country flags are displayed using emoji. Emoji rendering is handled by platform fonts.
  511. // Windows in particular does not yet play nicely with flag emoji so for now, hide flags on Windows.
  512. const randomFlags = shuffle([
  513. "🇬🇧",
  514. "🇺🇸",
  515. "🇪🇸",
  516. "🇫🇷",
  517. "🇧🇬",
  518. "🇨🇿",
  519. "🇩🇪",
  520. "🇵🇱",
  521. "🇺🇦",
  522. "🇨🇳",
  523. "🇮🇹",
  524. "🇭🇺",
  525. "🇧🇷",
  526. "🇳🇱",
  527. "🇮🇩",
  528. "🇯🇵",
  529. "🇷🇺",
  530. "🇹🇷",
  531. "🇫🇮",
  532. ]).slice(0, 3);
  533. const showFlags = !navigator.userAgent.includes("Windows");
  534. let title = t("prefs_appearance_language_title");
  535. if (showFlags) {
  536. title += ` ${randomFlags.join(" ")}`;
  537. }
  538. const handleChange = async (ev) => {
  539. await i18n.changeLanguage(ev.target.value);
  540. await maybeUpdateAccountSettings({
  541. language: ev.target.value,
  542. });
  543. };
  544. // Remember: Flags are not languages. Don't put flags next to the language in the list.
  545. // Languages names from: https://www.omniglot.com/language/names.htm
  546. // Better: Sidebar in Wikipedia: https://en.wikipedia.org/wiki/Bokm%C3%A5l
  547. return (
  548. <Pref labelId={labelId} title={title}>
  549. <FormControl fullWidth variant="standard" sx={{ m: 1 }}>
  550. <Select value={lang} onChange={handleChange} aria-labelledby={labelId}>
  551. <MenuItem value="en">English</MenuItem>
  552. <MenuItem value="ar">العربية</MenuItem>
  553. <MenuItem value="id">Bahasa Indonesia</MenuItem>
  554. <MenuItem value="bg">Български</MenuItem>
  555. <MenuItem value="cs">Čeština</MenuItem>
  556. <MenuItem value="zh_Hant">繁體中文</MenuItem>
  557. <MenuItem value="zh_Hans">简体中文</MenuItem>
  558. <MenuItem value="da">Dansk</MenuItem>
  559. <MenuItem value="de">Deutsch</MenuItem>
  560. <MenuItem value="et">Eesti</MenuItem>
  561. <MenuItem value="es">Español</MenuItem>
  562. <MenuItem value="fr">Français</MenuItem>
  563. <MenuItem value="gl">Galego</MenuItem>
  564. <MenuItem value="it">Italiano</MenuItem>
  565. <MenuItem value="hu">Magyar</MenuItem>
  566. <MenuItem value="ko">한국어</MenuItem>
  567. <MenuItem value="ja">日本語</MenuItem>
  568. <MenuItem value="nl">Nederlands</MenuItem>
  569. <MenuItem value="nb_NO">Norsk bokmål</MenuItem>
  570. <MenuItem value="uk">Українська</MenuItem>
  571. <MenuItem value="pt">Português</MenuItem>
  572. <MenuItem value="pt_BR">Português (Brasil)</MenuItem>
  573. <MenuItem value="pl">Polski</MenuItem>
  574. <MenuItem value="ru">Русский</MenuItem>
  575. <MenuItem value="ro">Română</MenuItem>
  576. <MenuItem value="sk">Slovenčina</MenuItem>
  577. <MenuItem value="fi">Suomi</MenuItem>
  578. <MenuItem value="sv">Svenska</MenuItem>
  579. <MenuItem value="tr">Türkçe</MenuItem>
  580. <MenuItem value="ta">தமிழ்</MenuItem>
  581. </Select>
  582. </FormControl>
  583. </Pref>
  584. );
  585. };
  586. const Reservations = () => {
  587. const { t } = useTranslation();
  588. const { account } = useContext(AccountContext);
  589. const [dialogKey, setDialogKey] = useState(0);
  590. const [dialogOpen, setDialogOpen] = useState(false);
  591. if (!config.enable_reservations || !session.exists() || !account) {
  592. return <></>;
  593. }
  594. const reservations = account.reservations || [];
  595. const limitReached = account.role === Role.USER && account.stats.reservations_remaining === 0;
  596. const handleAddClick = () => {
  597. setDialogKey((prev) => prev + 1);
  598. setDialogOpen(true);
  599. };
  600. return (
  601. <Card sx={{ padding: 1 }} aria-label={t("prefs_reservations_title")}>
  602. <CardContent sx={{ paddingBottom: 1 }}>
  603. <Typography variant="h5" sx={{ marginBottom: 2 }}>
  604. {t("prefs_reservations_title")}
  605. </Typography>
  606. <Paragraph>{t("prefs_reservations_description")}</Paragraph>
  607. {reservations.length > 0 && <ReservationsTable reservations={reservations} />}
  608. {limitReached && <Alert severity="info">{t("prefs_reservations_limit_reached")}</Alert>}
  609. </CardContent>
  610. <CardActions>
  611. <Button onClick={handleAddClick} disabled={limitReached}>
  612. {t("prefs_reservations_add_button")}
  613. </Button>
  614. <ReserveAddDialog
  615. key={`reservationAddDialog${dialogKey}`}
  616. open={dialogOpen}
  617. reservations={reservations}
  618. onClose={() => setDialogOpen(false)}
  619. />
  620. </CardActions>
  621. </Card>
  622. );
  623. };
  624. const ReservationsTable = (props) => {
  625. const { t } = useTranslation();
  626. const [dialogKey, setDialogKey] = useState(0);
  627. const [dialogReservation, setDialogReservation] = useState(null);
  628. const [editDialogOpen, setEditDialogOpen] = useState(false);
  629. const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
  630. const { subscriptions } = useOutletContext();
  631. const localSubscriptions =
  632. subscriptions?.length > 0
  633. ? Object.assign({}, ...subscriptions.filter((s) => s.baseUrl === config.base_url).map((s) => ({ [s.topic]: s })))
  634. : {};
  635. const handleEditClick = (reservation) => {
  636. setDialogKey((prev) => prev + 1);
  637. setDialogReservation(reservation);
  638. setEditDialogOpen(true);
  639. };
  640. const handleDeleteClick = async (reservation) => {
  641. setDialogKey((prev) => prev + 1);
  642. setDialogReservation(reservation);
  643. setDeleteDialogOpen(true);
  644. };
  645. const handleSubscribeClick = async (reservation) => {
  646. await subscribeTopic(config.base_url, reservation.topic, {});
  647. };
  648. return (
  649. <Table size="small" aria-label={t("prefs_reservations_table")}>
  650. <TableHead>
  651. <TableRow>
  652. <TableCell sx={{ paddingLeft: 0 }}>{t("prefs_reservations_table_topic_header")}</TableCell>
  653. <TableCell>{t("prefs_reservations_table_access_header")}</TableCell>
  654. <TableCell />
  655. </TableRow>
  656. </TableHead>
  657. <TableBody>
  658. {props.reservations.map((reservation) => (
  659. <TableRow key={reservation.topic} sx={{ "&:last-child td, &:last-child th": { border: 0 } }}>
  660. <TableCell component="th" scope="row" sx={{ paddingLeft: 0 }} aria-label={t("prefs_reservations_table_topic_header")}>
  661. {reservation.topic}
  662. </TableCell>
  663. <TableCell aria-label={t("prefs_reservations_table_access_header")}>
  664. {reservation.everyone === Permission.READ_WRITE && (
  665. <>
  666. <PermissionReadWrite size="small" sx={{ verticalAlign: "bottom", mr: 1.5 }} />
  667. {t("prefs_reservations_table_everyone_read_write")}
  668. </>
  669. )}
  670. {reservation.everyone === Permission.READ_ONLY && (
  671. <>
  672. <PermissionRead size="small" sx={{ verticalAlign: "bottom", mr: 1.5 }} />
  673. {t("prefs_reservations_table_everyone_read_only")}
  674. </>
  675. )}
  676. {reservation.everyone === Permission.WRITE_ONLY && (
  677. <>
  678. <PermissionWrite size="small" sx={{ verticalAlign: "bottom", mr: 1.5 }} />
  679. {t("prefs_reservations_table_everyone_write_only")}
  680. </>
  681. )}
  682. {reservation.everyone === Permission.DENY_ALL && (
  683. <>
  684. <PermissionDenyAll size="small" sx={{ verticalAlign: "bottom", mr: 1.5 }} />
  685. {t("prefs_reservations_table_everyone_deny_all")}
  686. </>
  687. )}
  688. </TableCell>
  689. <TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
  690. {!localSubscriptions[reservation.topic] && (
  691. <Tooltip title={t("prefs_reservations_table_click_to_subscribe")}>
  692. <Chip
  693. icon={<Info />}
  694. onClick={() => handleSubscribeClick(reservation)}
  695. label={t("prefs_reservations_table_not_subscribed")}
  696. color="primary"
  697. variant="outlined"
  698. />
  699. </Tooltip>
  700. )}
  701. <IconButton onClick={() => handleEditClick(reservation)} aria-label={t("prefs_reservations_edit_button")}>
  702. <EditIcon />
  703. </IconButton>
  704. <IconButton onClick={() => handleDeleteClick(reservation)} aria-label={t("prefs_reservations_delete_button")}>
  705. <CloseIcon />
  706. </IconButton>
  707. </TableCell>
  708. </TableRow>
  709. ))}
  710. </TableBody>
  711. <ReserveEditDialog
  712. key={`reservationEditDialog${dialogKey}`}
  713. open={editDialogOpen}
  714. reservation={dialogReservation}
  715. reservations={props.reservations}
  716. onClose={() => setEditDialogOpen(false)}
  717. />
  718. <ReserveDeleteDialog
  719. key={`reservationDeleteDialog${dialogKey}`}
  720. open={deleteDialogOpen}
  721. topic={dialogReservation?.topic}
  722. onClose={() => setDeleteDialogOpen(false)}
  723. />
  724. </Table>
  725. );
  726. };
  727. export default Preferences;