From ed8df13b2ff753cd038a71f31f2d5659dc02132d Mon Sep 17 00:00:00 2001 From: Fritz Heiden Date: Fri, 14 Mar 2025 14:12:14 +0100 Subject: [PATCH] feat: add navigation and user settings --- www/src/data/constants.js | 4 + www/src/modals/create-user-modal.jsx | 132 ++++++++++++ www/src/modals/delete-user-modal.jsx | 88 ++++++++ www/src/modals/modal-handler.js | 31 +++ www/src/modals/modal-registry.jsx | 49 +++++ www/src/modals/modal.jsx | 45 ++++ www/src/modals/user-settings-modal.jsx | 88 ++++++++ www/src/modules/list.jsx | 211 +++++++++++++++++++ www/src/modules/settings/change-password.jsx | 127 +++++++++++ www/src/modules/settings/users-list.jsx | 80 +++++++ www/src/modules/validated-text-input.jsx | 33 +++ www/src/tools/event-emitter.js | 36 ++++ www/src/tools/url-utils.js | 74 +++++++ www/src/views/main-view.jsx | 206 +++++++++++++++++- www/src/views/settings-view.jsx | 72 +++++++ 15 files changed, 1275 insertions(+), 1 deletion(-) create mode 100644 www/src/data/constants.js create mode 100644 www/src/modals/create-user-modal.jsx create mode 100644 www/src/modals/delete-user-modal.jsx create mode 100644 www/src/modals/modal-handler.js create mode 100644 www/src/modals/modal-registry.jsx create mode 100644 www/src/modals/modal.jsx create mode 100644 www/src/modals/user-settings-modal.jsx create mode 100644 www/src/modules/list.jsx create mode 100644 www/src/modules/settings/change-password.jsx create mode 100644 www/src/modules/settings/users-list.jsx create mode 100644 www/src/modules/validated-text-input.jsx create mode 100644 www/src/tools/event-emitter.js create mode 100644 www/src/tools/url-utils.js create mode 100644 www/src/views/settings-view.jsx diff --git a/www/src/data/constants.js b/www/src/data/constants.js new file mode 100644 index 0000000..86adb85 --- /dev/null +++ b/www/src/data/constants.js @@ -0,0 +1,4 @@ +export const DEVICES_VIEW = "devices"; +export const REMOTES_VIEW = "remotes"; +export const RECORDINGS_VIEW = "recordings"; +export const SETTINGS_VIEW = "settings"; diff --git a/www/src/modals/create-user-modal.jsx b/www/src/modals/create-user-modal.jsx new file mode 100644 index 0000000..7950455 --- /dev/null +++ b/www/src/modals/create-user-modal.jsx @@ -0,0 +1,132 @@ +import { createEffect, createSignal } from "solid-js"; +import Modal from "./modal.jsx"; +import UserService from "../services/user-service.js"; +import EventEmitter from "../tools/event-emitter.js"; +import ValidatedTextInput from "../modules/validated-text-input.jsx"; +import ModalHandler from "./modal-handler.js"; + +const [users, setUsers] = createSignal([]); +const eventEmitter = new EventEmitter(); +const USER_CREATED_EVENT = "success"; + +const MIN_PASSWORD_LENGTH = 6; + +function CreateUserModal(props) { + const [username, setUsername] = createSignal(""); + const [isUsernameValid, setUsernameValid] = createSignal(true); + const [password, setPassword] = createSignal(""); + const [isPasswordValid, setPasswordValid] = createSignal(true); + const [isAdmin, setAdmin] = createSignal(false); + const [error, setError] = createSignal(""); + + createEffect(() => setUsernameValid(checkIfUsernameValid(username()))); + createEffect(() => setPasswordValid(checkIfPasswordValid(password()))); + + function checkIfUsernameValid(name) { + for (let user of users()) { + if (user.getUsername() !== name) continue; + return false; + } + return true; + } + + function checkIfPasswordValid(password) { + return password.length >= MIN_PASSWORD_LENGTH; + } + + async function handleCreateUser() { + let user; + try { + user = await UserService.createUser({ + username: username(), + password: password(), + isAdmin: isAdmin(), + }); + } catch (e) { + setError(e.message); + return; + } + setUsername(""); + setPassword(""); + CreateUserModal.Handler.hide(); + eventEmitter.dispatchEvent(USER_CREATED_EVENT, user); + } + + return ( + + + + + ); +} + +CreateUserModal.Handler = new ModalHandler(); +CreateUserModal.setUsers = setUsers; +CreateUserModal.onUserCreated = (callback) => + eventEmitter.on(USER_CREATED_EVENT, callback); + +export default CreateUserModal; diff --git a/www/src/modals/delete-user-modal.jsx b/www/src/modals/delete-user-modal.jsx new file mode 100644 index 0000000..e787530 --- /dev/null +++ b/www/src/modals/delete-user-modal.jsx @@ -0,0 +1,88 @@ +import { createEffect, createSignal } from "solid-js"; +import Modal from "./modal.jsx"; +import UserService from "../services/user-service.js"; +import EventEmitter from "../tools/event-emitter.js"; +import User from "../data/user.js"; +import ValidatedTextInput from "../modules/validated-text-input.jsx"; +import ModalHandler from "./modal-handler.js"; + +const [user, setUser] = createSignal(new User()); +const eventEmitter = new EventEmitter(); +const USER_DELETED_EVENT = "success"; + +function DeleteUserModal(props) { + const [username, setUsername] = createSignal(""); + const [isUsernameValid, setUsernameValid] = createSignal(true); + const [error, setError] = createSignal(""); + + createEffect(() => setUsernameValid(checkIfUsernameValid(username()))); + + function checkIfUsernameValid(name) { + return name === user().getUsername(); + } + + async function handleDeleteUser() { + try { + await UserService.deleteUser(user().getId()); + } catch (e) { + setError(e.message); + throw e; + } + DeleteUserModal.Handler.hide(); + eventEmitter.dispatchEvent(USER_DELETED_EVENT, user); + } + + return ( + + + + + ); +} + +DeleteUserModal.Handler = new ModalHandler(); +DeleteUserModal.setUser = setUser; +DeleteUserModal.onUserDeleted = (callback) => + eventEmitter.on(USER_DELETED_EVENT, callback); + +export default DeleteUserModal; diff --git a/www/src/modals/modal-handler.js b/www/src/modals/modal-handler.js new file mode 100644 index 0000000..0ccd7db --- /dev/null +++ b/www/src/modals/modal-handler.js @@ -0,0 +1,31 @@ +function ModalHandler() { + let _ref; + let _modalRef; + let _modalId; + + function setRef(ref) { + _ref = ref; + _modalRef = new bootstrap.Modal(ref); + } + + function show() { + _modalRef.show(); + } + + function hide() { + _modalRef.hide(); + } + + function setModalId(modalId) { + _modalId = modalId; + } + + return { + setRef, + show, + hide, + setModalId, + }; +} + +export default ModalHandler; diff --git a/www/src/modals/modal-registry.jsx b/www/src/modals/modal-registry.jsx new file mode 100644 index 0000000..d9a6850 --- /dev/null +++ b/www/src/modals/modal-registry.jsx @@ -0,0 +1,49 @@ +import { onMount } from "solid-js"; + +import CreateUserModal from "./create-user-modal.jsx"; +import DeleteUserModal from "./delete-user-modal.jsx"; +import UserSettingsModal from "./user-settings-modal.jsx"; + +const ModalRegistry = (function () { + const modals = [ + { + id: "createUserModal", + component: CreateUserModal, + ref: null, + }, + { + id: "deleteUserModal", + component: DeleteUserModal, + ref: null, + }, + { + id: "userSettingsModal", + component: UserSettingsModal, + ref: null, + }, + ]; + + function getModals(props) { + return function () { + onMount(() => { + modals.forEach((modal) => { + modal.component.Handler.setRef(modal.ref); + modal.component.Handler.setModalId(modal.id); + }); + }); + return ( + <> + + {(modal) => } + + + ); + }; + } + + return { + getModals, + }; +})(); + +export default ModalRegistry; diff --git a/www/src/modals/modal.jsx b/www/src/modals/modal.jsx new file mode 100644 index 0000000..44f989b --- /dev/null +++ b/www/src/modals/modal.jsx @@ -0,0 +1,45 @@ +import { mergeProps } from "solid-js"; + +function Modal(props) { + props = mergeProps( + { size: "", id: "modal", modalTitle: "Modal", centered: false }, + props + ); + + return ( + + ); +} + +export default Modal; diff --git a/www/src/modals/user-settings-modal.jsx b/www/src/modals/user-settings-modal.jsx new file mode 100644 index 0000000..d833318 --- /dev/null +++ b/www/src/modals/user-settings-modal.jsx @@ -0,0 +1,88 @@ +import { + createEffect, + createMemo, + createResource, + createSignal, +} from "solid-js"; +import EventEmitter from "../tools/event-emitter.js"; +import User from "../data/user.js"; +import Modal from "./modal.jsx"; +import ValidatedTextInput from "../modules/validated-text-input.jsx"; +import ModalHandler from "./modal-handler.js"; +import UserService from "../services/user-service.js"; + +const [user, setUser] = createSignal(new User()); +const eventEmitter = new EventEmitter(); +const USER_UPDATED_EVENT = "user-updated"; + +function UserSettingsModal(props) { + const [isAdmin, setAdmin] = createSignal(false); + const [error, setError] = createSignal(""); + const [userInfo] = createResource(() => UserService.getUserInfo()); + let canEditAdmin = createMemo( + () => userInfo.loading || user().getId() !== userInfo().user_id + ); + createEffect(() => setAdmin(user().isAdmin())); + + async function handleSaveSettings() { + try { + await UserService.updateUser(user().getId(), { isAdmin: isAdmin() }); + } catch (error) { + setError(error.message); + return; + } + UserSettingsModal.Handler.hide(); + eventEmitter.dispatchEvent(USER_UPDATED_EVENT, user()); + } + + return ( + + + + + ); +} + +UserSettingsModal.Handler = new ModalHandler(); +UserSettingsModal.setUser = setUser; +UserSettingsModal.onUserUpdated = (callback) => + eventEmitter.on(USER_UPDATED_EVENT, callback); + +export default UserSettingsModal; diff --git a/www/src/modules/list.jsx b/www/src/modules/list.jsx new file mode 100644 index 0000000..d346c25 --- /dev/null +++ b/www/src/modules/list.jsx @@ -0,0 +1,211 @@ +import { + Match, + Show, + createEffect, + createMemo, + createSignal, + mergeProps, +} from "solid-js"; + +function List(props) { + props = mergeProps({ items: [], showHeader: true, selectable: true }, props); + const [listItems, setListItems] = createSignal([]); + const selectedItems = createMemo(() => + listItems() + .filter((item) => item.selected()) + .map((item) => item.item) + ); + const allItemsSelected = createMemo(() => + listItems().every((item) => item.selected()) + ); + createEffect(() => { + setListItems( + props.items.map((item) => { + const [selected, setSelected] = createSignal(false); + return { + selected, + setSelected, + item: item, + }; + }) + ); + }); + createEffect(() => { + if (!props.onListItemsSelect) return; + props.onListItemsSelect(selectedItems()); + }); + + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + if (props.onLazyLoad) { + props.onLazyLoad(); + } + } + }); + }); + + const [lazyLoadTriggerElement, setLazyLoadTriggerElement] = createSignal(); + createEffect(() => { + observer.observe(lazyLoadTriggerElement()); + }); + + const [scrollTop, setScrollTop] = createSignal(0); + const itemsMutationObserver = new MutationObserver(() => { + itemsContainerElement().scrollTop = scrollTop(); + }); + const [itemsContainerElement, setItemsContainerElement] = createSignal(); + createEffect(() => { + if (!itemsContainerElement()) return; + itemsMutationObserver.observe(itemsContainerElement(), { childList: true }); + }); + + function handleListItemClick(item) { + if (!props.onListItemClick) return; + props.onListItemClick(item); + } + + function handleToggleAllItems() { + if (allItemsSelected()) { + listItems().forEach((item) => item.setSelected(false)); + return; + } + listItems().forEach((item) => item.setSelected(true)); + } + + function renderHeaderRow(columns) { + columns = columns.filter((column) => !column.hidden); + if (!props.showHeader) return null; + return ( +
+
+ +
handleToggleAllItems()} + > + +
+
+ + {(column) => ( +
+ +
+ +
+
+
+ {column.name} +
+
+ )} +
+
+
+ ); + } + + function renderListItem(listItem, columns) { + columns = columns.filter((column) => !column.hidden); + let item = listItem.item; + return ( + + +
listItem.setSelected(!listItem.selected())} + > + +
+
+
handleListItemClick(item)} + > + + {(column) => ( +
+ + +
+ +
+
+ +
+ +
+
+
+ + + {item[column.id].html} + + +
+ {item[column.id]?.text} +
+
+
+
+ )} +
+
+
+ ); + } + + return ( +
+ {renderHeaderRow(props.columns)} +
setScrollTop(event.target.scrollTop)} + ref={setItemsContainerElement} + > + + {(listItem) => renderListItem(listItem, props.columns)} + +
+
+
+ ); +} + +export default List; diff --git a/www/src/modules/settings/change-password.jsx b/www/src/modules/settings/change-password.jsx new file mode 100644 index 0000000..ff8bd97 --- /dev/null +++ b/www/src/modules/settings/change-password.jsx @@ -0,0 +1,127 @@ +import { createEffect, createSignal } from "solid-js"; +import UserService from "../../services/user-service"; +import ValidatedTextInput from "../validated-text-input"; + +function ChangePasswordSettings() { + const PASSWORD_MIN_LENGTH = 6; + + const [error, setError] = createSignal(""); + const [success, setSuccess] = createSignal(false); + + const [currentPassword, setCurrentPassword] = createSignal(""); + const [newPassword, setNewPassword] = createSignal(""); + const [newPasswordRepeat, setNewPasswordRepeat] = createSignal(""); + + const [isCurrentPasswordValid, setCurrentPasswordValid] = createSignal(true); + const [isNewPasswordValid, setNewPasswordValid] = createSignal(true); + const [isNewPasswordRepeatValid, setNewPasswordRepeatValid] = + createSignal(true); + const [isValid, setValid] = createSignal(false); + + const [currentPasswordActive, setCurrentPasswordActive] = createSignal(false); + const [newPasswordRepeatActive, setNewPasswordRepeatActive] = + createSignal(false); + + createEffect(() => + setValid( + isCurrentPasswordValid() && + isNewPasswordValid() && + isNewPasswordRepeatValid() + ) + ); + createEffect(() => setCurrentPasswordValid(currentPassword().length > 0)); + createEffect(() => + setNewPasswordValid(newPassword().length >= PASSWORD_MIN_LENGTH) + ); + createEffect(() => + setNewPasswordRepeatValid(newPassword() === newPasswordRepeat()) + ); + createEffect(() => (error() ? setSuccess(false) : null)); + createEffect(() => + success() ? setTimeout(() => setSuccess(false), 5000) : null + ); + + async function handleChangePassword() { + try { + await UserService.changePassword({ + currentPassword: currentPassword(), + newPassword: newPassword(), + }); + setSuccess(true); + } catch (error) { + setError(error.message); + } + } + + return ( +
+ + +
Change password
+
+ + setCurrentPassword(e.target.value)} + /> +
+
+ + setNewPassword(e.target.value)} + errorText={`Neues Passwort muss mindestens ${PASSWORD_MIN_LENGTH} Zeichen lang + sein.`} + /> +
+
+ + setNewPasswordRepeat(e.target.value)} + errorText={"Passwörter müssen übereinstimmen"} + /> +
+
+ +
+
+ ); +} + +export default ChangePasswordSettings; diff --git a/www/src/modules/settings/users-list.jsx b/www/src/modules/settings/users-list.jsx new file mode 100644 index 0000000..1c73ab1 --- /dev/null +++ b/www/src/modules/settings/users-list.jsx @@ -0,0 +1,80 @@ +import { createSignal, onMount } from "solid-js"; +import List from "../list.jsx"; +import UserService from "../../services/user-service.js"; +import CreateUserModal from "../../modals/create-user-modal.jsx"; +import DeleteUserModal from "../../modals/delete-user-modal.jsx"; +import UserSettingsModal from "../../modals/user-settings-modal.jsx"; + +function UserList() { + const [users, setUsers] = createSignal([]); + + onMount(() => { + updateUsers(); + CreateUserModal.onUserCreated(() => updateUsers()); + UserSettingsModal.onUserUpdated(() => updateUsers()); + DeleteUserModal.onUserDeleted(() => updateUsers()); + }); + + async function updateUsers() { + let users = await UserService.getUsers(); + setUsers(users); + } + + function handleShowUserSettings(user) { + UserSettingsModal.setUser(user); + UserSettingsModal.Handler.show(); + } + + function handleCreateUser() { + CreateUserModal.setUsers(users()); + CreateUserModal.Handler.show(); + } + + function handleDeleteUser(user) { + DeleteUserModal.setUser(user); + DeleteUserModal.Handler.show(); + } + + return ( + <> +
+ +
+ ({ + username: { text: user.getUsername() }, + is_admin: { text: user.isAdmin() ? "Yes" : "No" }, + options: { + html: ( + <> + + + + ), + }, + }))} + /> + + ); +} + +export default UserList; diff --git a/www/src/modules/validated-text-input.jsx b/www/src/modules/validated-text-input.jsx new file mode 100644 index 0000000..c92e67d --- /dev/null +++ b/www/src/modules/validated-text-input.jsx @@ -0,0 +1,33 @@ +import { createSignal, mergeProps } from "solid-js"; + +function ValidatedTextInput(props) { + props = mergeProps( + { type: "text", valid: true, onInput: () => {}, errorText: "" }, + props + ); + let [isActive, setActive] = createSignal(false); + return ( +
+ setActive(true)} + /> +
+ {props.errorText} +
+
+ ); +} + +export default ValidatedTextInput; diff --git a/www/src/tools/event-emitter.js b/www/src/tools/event-emitter.js new file mode 100644 index 0000000..8c3ab57 --- /dev/null +++ b/www/src/tools/event-emitter.js @@ -0,0 +1,36 @@ +function EventEmitter() { + let instance; + + let listeners = {}; + + function on(eventName, callback) { + if (!listeners[eventName]) { + listeners[eventName] = []; + } + + if (listeners[eventName].indexOf(callback) !== -1) return; + + listeners[eventName].push(callback); + } + + function dispatchEvent(eventName, payload) { + if (!listeners[eventName]) return; + listeners[eventName].forEach((listener) => listener(payload)); + } + + function off(eventName, callback) { + if (!listeners[eventName]) return; + let index = listeners[eventName].indexOf(callback); + listeners[eventName].splice(index, 1); + } + + instance = { + on, + off, + dispatchEvent, + }; + + return instance; +} + +export default EventEmitter; diff --git a/www/src/tools/url-utils.js b/www/src/tools/url-utils.js new file mode 100644 index 0000000..6caf2b3 --- /dev/null +++ b/www/src/tools/url-utils.js @@ -0,0 +1,74 @@ +function UrlUtils() { + function getUrl() { + return window.location.href; + } + + function setUrl(url) { + history.pushState(null, null, url); + } + + function addQueryParameter(url, key, value) { + let [baseUrl, queryString] = url.split("?"); + let queryParameters = queryString ? queryString.split("&") : []; + + let hasKey = false; + for (let i = 0; i < queryParameters.length; i++) { + const [paramKey] = queryParameters[i].split("="); + if (paramKey === key) { + queryParameters[i] = `${key}=${value}`; + hasKey = true; + break; + } + } + + if (!hasKey) { + queryParameters.push(`${key}=${value}`); + } + + const newUrl = `${baseUrl}?${queryParameters.join("&")}`; + return newUrl; + } + + function removeQueryParameter(url, key) { + let [baseUrl, queryString] = url.split("?"); + let queryParameters = queryString ? queryString.split("&") : []; + + for (let i = 0; i < queryParameters.length; i++) { + const [paramKey] = queryParameters[i].split("="); + if (paramKey === key) { + queryParameters.splice(i, 1); + break; + } + } + + const newUrl = `${baseUrl}?${queryParameters.join("&")}`; + return newUrl; + } + + function getQueryParameter(key) { + let url = getUrl(); + let [baseUrl, queryString] = url.split("?"); + let queryParameters = queryString ? queryString.split("&") : []; + + for (let i = 0; i < queryParameters.length; i++) { + const [paramKey, paramValue] = queryParameters[i].split("="); + if (paramKey === key) { + return decodeURIComponent(paramValue); + } + } + + return null; + } + + return { + addQueryParameter, + removeQueryParameter, + getQueryParameter, + getUrl, + setUrl, + }; +} + +UrlUtils = new UrlUtils(); + +export default UrlUtils; diff --git a/www/src/views/main-view.jsx b/www/src/views/main-view.jsx index fa263e4..0e3a46e 100644 --- a/www/src/views/main-view.jsx +++ b/www/src/views/main-view.jsx @@ -1,9 +1,213 @@ +import { + createSignal, + mergeProps, + createResource, + createEffect, + onMount, + onCleanup, +} from "solid-js"; +import { computePosition, shift, autoUpdate, offset } from "@floating-ui/dom"; + +import UserService from "../services/user-service.js"; +import ModalRegistry from "../modals/modal-registry.jsx"; +import UrlUtils from "../tools/url-utils.js"; +import SettingsView from "./settings-view.jsx"; + +import { + DEVICES_VIEW, + REMOTES_VIEW, + RECORDINGS_VIEW, + SETTINGS_VIEW, +} from "../data/constants.js"; + +let [activeView, setActiveView] = createSignal(DEVICES_VIEW); + const MainView = function (props) { + props = mergeProps({ onLogout: () => {} }, props); + + const [userInfo] = createResource(() => UserService.getUserInfo()); + const [username, setUsername] = createSignal(""); + + createEffect(() => { + if (userInfo.loading) return; + setUsername(userInfo().username); + }); + + let Modals = ModalRegistry.getModals(); + + let userButtonRef; + let userMenuRef; + let userMenuBackRef; + let cleanUserMenu; + + onMount(() => { + setViewFromUrl(); + window.addEventListener("popstate", setViewFromUrl); + }); + + onCleanup(() => { + window.removeEventListener("popstate", setViewFromUrl); + }); + + function setViewFromUrl() { + let view = UrlUtils.getQueryParameter("view"); + if (view) { + setActiveView(view); + } + } + + function handleLogout() { + UserService.logout(); + props.onLogout(); + } + + function toggleUserMenu() { + if (userMenuRef.style.display === "none") { + showUserMenu(); + } else { + hideUserMenu(); + } + } + + function showUserMenu() { + cleanUserMenu = autoUpdate(userButtonRef, userMenuRef, () => + computePosition(userButtonRef, userMenuRef, { + placement: "bottom-end", + middleware: [offset(6), shift()], + }).then(({ x, y }) => { + Object.assign(userMenuRef.style, { + left: `${x}px`, + top: `${y}px`, + }); + }) + ); + userMenuRef.style.display = "block"; + userMenuBackRef.style.display = "block"; + } + + function hideUserMenu() { + userMenuRef.style.display = "none"; + userMenuBackRef.style.display = "none"; + cleanUserMenu(); + } + + function handleViewChange(view) { + if (activeView() === view) return; + setActiveView(view); + let url = UrlUtils.getUrl(); + url = UrlUtils.addQueryParameter(url, "view", view); + UrlUtils.setUrl(url); + } + function render() { - return
; + return ( +
+ + + +
+ ); + } + + function HeaderBar() { + return ( +
+
+
+ + + +
+
+ +
+ +
+
+
+ ); + } + + function ActiveView(props) { + return ( +
+ + + + + +
+ ); } return render(); }; +MainView.setActiveView = setActiveView; + export default MainView; diff --git a/www/src/views/settings-view.jsx b/www/src/views/settings-view.jsx new file mode 100644 index 0000000..9014223 --- /dev/null +++ b/www/src/views/settings-view.jsx @@ -0,0 +1,72 @@ +import { Show, createEffect, createResource, createSignal } from "solid-js"; +import ChangePasswordSettings from "../modules/settings/change-password"; +import UserList from "../modules/settings/users-list"; +import UserService from "../services/user-service"; + +function SettingsView(props) { + const ACCOUNT_TAB = "account"; + const USERS_TAB = "users"; + + const [activeTab, setActiveTab] = createSignal(ACCOUNT_TAB); + const [userInfo] = createResource(() => UserService.getUserInfo()); + + const tabs = [ + { + id: ACCOUNT_TAB, + text: "Account", + render: renderAccountSettings, + }, + { + id: USERS_TAB, + text: "Users", + adminOnly: true, + render: () => renderUserSettings, + }, + ]; + + function renderAccountSettings() { + return ( + <> + + + ); + } + + function renderUserSettings() { + return ( + <> + + + ); + } + + return ( +
+
+

Settings

+ + {(tab) => ( + + + + )} + +
+ + {(tab) => {tab.render()}} + +
+ ); +} + +export default SettingsView;