feat: add navigation and user settings

This commit is contained in:
Fritz Heiden 2025-03-14 14:12:14 +01:00
parent 71b9fc22b7
commit ed8df13b2f
15 changed files with 1275 additions and 1 deletions

View File

@ -0,0 +1,4 @@
export const DEVICES_VIEW = "devices";
export const REMOTES_VIEW = "remotes";
export const RECORDINGS_VIEW = "recordings";
export const SETTINGS_VIEW = "settings";

View File

@ -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 (
<Modal
ref={props.ref}
id="createUserModal"
modalTitle="New User"
centered={true}
>
<div class="modal-body">
<Show when={error() !== ""}>
<div class="alert alert-danger" role="alert">
{error()}
</div>
</Show>
<div class="mb-3 row">
<label for="new_username" class="col-form-label col-sm-2">
Username
</label>
<ValidatedTextInput
class="col-sm-10"
id="new_username"
valid={isUsernameValid()}
value={username()}
onInput={(e) => setUsername(e.target.value)}
errorText={"Username already exists"}
/>
</div>
<div class="mb-3 row">
<label for="user_password" class="col-form-label col-sm-2">
Password
</label>
<ValidatedTextInput
class="col-sm-10"
id="user_password"
type="password"
valid={isPasswordValid()}
value={password()}
onInput={(e) => setPassword(e.target.value)}
errorText={"Invalid password"}
/>
</div>
<div class="mb-3 row">
<label for="is_admin" class="col-form-label col-sm-2">
Admin
</label>
<div class="col-sm-10 d-flex align-items-center">
<input
id="is_admin"
type="checkbox"
class="form-check-input"
checked={isAdmin()}
onChange={() => setAdmin(!isAdmin())}
/>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Cancel
</button>
<button
type="button"
onClick={handleCreateUser}
class="btn btn-primary"
disabled={!isUsernameValid() && !isPasswordValid()}
>
Create
</button>
</div>
</Modal>
);
}
CreateUserModal.Handler = new ModalHandler();
CreateUserModal.setUsers = setUsers;
CreateUserModal.onUserCreated = (callback) =>
eventEmitter.on(USER_CREATED_EVENT, callback);
export default CreateUserModal;

View File

@ -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 (
<Modal
ref={props.ref}
id="deleteUserModal"
modalTitle="Delete user"
centered={true}
>
<div class="modal-body">
<Show when={error() !== ""}>
<div class="alert alert-danger" role="alert">
{error()}
</div>
</Show>
<div class="mb-3 row">
<span>
Do you really want to delete the user {user().getUsername()}?
</span>
</div>
<div class="mb-3">
<label for="del_username" class="col-form-label">
Type <b>{user().getUsername()}</b> to confirm:
</label>
<ValidatedTextInput
id="del_username"
valid={isUsernameValid()}
value={username()}
onInput={(e) => setUsername(e.target.value)}
errorText={"Wrong input"}
/>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Cancel
</button>
<button
type="button"
onClick={handleDeleteUser}
class="btn btn-danger"
disabled={!isUsernameValid()}
>
Delete
</button>
</div>
</Modal>
);
}
DeleteUserModal.Handler = new ModalHandler();
DeleteUserModal.setUser = setUser;
DeleteUserModal.onUserDeleted = (callback) =>
eventEmitter.on(USER_DELETED_EVENT, callback);
export default DeleteUserModal;

View File

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

View File

@ -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 (
<>
<For each={modals}>
{(modal) => <modal.component ref={modal.ref} />}
</For>
</>
);
};
}
return {
getModals,
};
})();
export default ModalRegistry;

45
www/src/modals/modal.jsx Normal file
View File

@ -0,0 +1,45 @@
import { mergeProps } from "solid-js";
function Modal(props) {
props = mergeProps(
{ size: "", id: "modal", modalTitle: "Modal", centered: false },
props
);
return (
<div
class={"modal fade " + props.size}
id={props.id}
tabindex="-1"
aria-labelledby={props.id + "Label"}
aria-hidden="true"
ref={props.ref}
role="dialog"
>
<div
class="modal-dialog modal-dialog-scrollable modal-fullscreen-md-down modal-lg"
classList={{ "modal-dialog-centered": props.centered }}
>
<div
class="modal-content"
style={props.fullHeight ? "height: 100%" : ""}
>
<div class="modal-header">
<h1 class="modal-title fs-5" id={props.id + "Label"}>
{props.modalTitle}
</h1>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Schliessen"
/>
</div>
{props.children}
</div>
</div>
</div>
);
}
export default Modal;

View File

@ -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 (
<Modal
ref={props.ref}
id="userSettingsModal"
modalTitle={`Settings for ${user().getUsername()}`}
centered={true}
>
<div class="modal-body">
<Show when={error() !== ""}>
<div class="alert alert-danger" role="alert">
{error()}
</div>
</Show>
<div class="mb-3 row">
<label for="is_admin" class="col-form-label col-sm-2">
Admin
</label>
<div class="col-sm-10 d-flex align-items-center">
<input
id="is_admin"
type="checkbox"
class="form-check-input"
checked={isAdmin()}
disabled={!canEditAdmin()}
onChange={() => setAdmin(!isAdmin())}
/>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Cancel
</button>
<button
type="button"
onClick={handleSaveSettings}
class="btn btn-primary"
>
Save
</button>
</div>
</Modal>
);
}
UserSettingsModal.Handler = new ModalHandler();
UserSettingsModal.setUser = setUser;
UserSettingsModal.onUserUpdated = (callback) =>
eventEmitter.on(USER_UPDATED_EVENT, callback);
export default UserSettingsModal;

211
www/src/modules/list.jsx Normal file
View File

@ -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 (
<div class="list-group-item d-flex p-0">
<div class="d-flex flex-fill border-bottom border-2 fw-bold overflow-y-scroll">
<Show when={props.selectable}>
<div
class="d-flex align-items-center justify-content-center border-end py-2 px-3"
onClick={() => handleToggleAllItems()}
>
<input
class="form-check-input m-0"
type="checkbox"
checked={allItemsSelected()}
id={""}
/>
</div>
</Show>
<For each={columns}>
{(column) => (
<div
class={"d-flex p-2" + (column.width ? "" : " flex-fill")}
style={column.width ? `width:${column.width}em` : ""}
>
<Show when={column.withIcons}>
<div class="pe-2" style="color: transparent">
<i class="bi-folder2" />
</div>
</Show>
<div class="text-truncate">
<span>{column.name}</span>
</div>
</div>
)}
</For>
</div>
</div>
);
}
function renderListItem(listItem, columns) {
columns = columns.filter((column) => !column.hidden);
let item = listItem.item;
return (
<a
role="button"
class="list-group-item list-group-item-action d-flex p-0 overflow-x-hidden border-0 border-bottom border-1"
style="max-width: 100%"
>
<Show when={props.selectable}>
<div
class="d-flex align-items-center justify-content-center border-end py-2 px-3"
onClick={() => listItem.setSelected(!listItem.selected())}
>
<input
class="form-check-input m-0"
type="checkbox"
checked={listItem.selected()}
id={""}
/>
</div>
</Show>
<div
class="d-flex flex-fill overflow-hidden"
onClick={() => handleListItemClick(item)}
>
<For each={columns}>
{(column) => (
<div
class={
"d-flex p-2 align-items-center" +
(column.width ? " flex-shrink-0" : " flex-fill")
}
style={
"overflow: hidden; " +
(column.width ? `width:${column.width}em` : "")
}
>
<Show when={column.withIcons}>
<Show when={!item[column.id].icon}>
<div class="pe-2" style="color: transparent">
<i class="bi-folder2" />
</div>
</Show>
<Show when={item[column.id].icon}>
<div class="pe-2">
<i class={item[column.id].icon} />
</div>
</Show>
</Show>
<Switch>
<Match when={item[column.id].html}>
{item[column.id].html}
</Match>
<Match when={item[column.id].text}>
<div class="text-truncate">
<span>{item[column.id]?.text}</span>
</div>
</Match>
</Switch>
</div>
)}
</For>
</div>
</a>
);
}
return (
<div
style={props.style}
class={
"list-group d-flex flex-column overflow-hidden " + (props.class || "")
}
>
{renderHeaderRow(props.columns)}
<div
class="list-group-item p-0 flex-fill overflow-y-scroll"
style="min-width: 0"
onScroll={(event) => setScrollTop(event.target.scrollTop)}
ref={setItemsContainerElement}
>
<For each={listItems()}>
{(listItem) => renderListItem(listItem, props.columns)}
</For>
<div
style="width: 100%; height: 3em;"
ref={setLazyLoadTriggerElement}
></div>
</div>
</div>
);
}
export default List;

View File

@ -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 (
<div class="border rounded bg-dark p-3">
<div
class={"alert alert-danger" + (error() ? "" : " d-none")}
role="alert"
>
{error()}
</div>
<div
class={"alert alert-success" + (success() ? "" : " d-none")}
role="alert"
>
Changing password successful.
</div>
<div class="fw-bold mb-3">Change password</div>
<div class="row mb-3">
<label for="currentPassword" class="col-sm-2 col-form-label">
Current Password
</label>
<ValidatedTextInput
class="col-sm-10"
id="currentPassword"
type="password"
valid={isCurrentPasswordValid()}
value={currentPassword()}
onInput={(e) => setCurrentPassword(e.target.value)}
/>
</div>
<div class="row mb-3">
<label for="newPassword" class="col-sm-2 col-form-label">
New Password
</label>
<ValidatedTextInput
class="col-sm-10"
id="newPassword"
type="password"
valid={isNewPasswordValid()}
value={newPassword()}
onInput={(e) => setNewPassword(e.target.value)}
errorText={`Neues Passwort muss mindestens ${PASSWORD_MIN_LENGTH} Zeichen lang
sein.`}
/>
</div>
<div class="row mb-3">
<label for="newPasswordRepeat" class="col-sm-2 col-form-label">
New Passwort repeat
</label>
<ValidatedTextInput
class="col-sm-10"
id="newPasswordRepeat"
type="password"
valid={isNewPasswordRepeatValid()}
value={newPasswordRepeat()}
onInput={(e) => setNewPasswordRepeat(e.target.value)}
errorText={"Passwörter müssen übereinstimmen"}
/>
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<button
class="btn btn-primary"
type="button"
disabled={!isValid()}
onClick={handleChangePassword}
>
Change
</button>
</div>
</div>
);
}
export default ChangePasswordSettings;

View File

@ -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 (
<>
<div class="d-flex flex-row justify-content-end">
<button class="btn btn-dark me-2 mb-3" onClick={handleCreateUser}>
<i class="bi bi-plus me-2"></i>
New User
</button>
</div>
<List
class="h-100"
columns={[
{ id: "username", name: "Username" },
{ id: "is_admin", name: "Admin", width: 10 },
{ id: "options", name: "Options", width: 10 },
]}
items={users().map((user) => ({
username: { text: user.getUsername() },
is_admin: { text: user.isAdmin() ? "Yes" : "No" },
options: {
html: (
<>
<button
class="btn btn-outline-secondary me-2"
onClick={() => handleShowUserSettings(user)}
>
<i class="bi bi-gear-fill"></i>
</button>
<button
class="btn btn-outline-secondary me-2"
onClick={() => handleDeleteUser(user)}
>
<i class="bi bi-trash-fill"></i>
</button>
</>
),
},
}))}
/>
</>
);
}
export default UserList;

View File

@ -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 (
<div class={props.class}>
<input
type={props.type}
class={
"form-control" + (!isActive() || props.valid ? "" : " is-invalid")
}
id={props.id}
value={props.value}
onInput={props.onInput}
onFocusOut={() => setActive(true)}
/>
<div
class={
"invalid-feedback" +
(!isActive() || props.valid ? " d-none" : " d-block")
}
>
{props.errorText}
</div>
</div>
);
}
export default ValidatedTextInput;

View File

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

View File

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

View File

@ -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 <div class="d-flex flex-column" style="height:100vh"></div>;
return (
<div class="d-flex flex-column" style="height:100vh">
<Modals></Modals>
<HeaderBar></HeaderBar>
<ActiveView class={"bg-body-tertiary"}></ActiveView>
</div>
);
}
function HeaderBar() {
return (
<div>
<div class="container-xl px-3 py-2 d-flex bg-dark-subtle">
<div class="d-flex align-items-center flex-fill">
<button
class={
"btn me-2" +
(activeView() === DEVICES_VIEW ? " btn-secondary" : " btn-dark")
}
onClick={() => handleViewChange(DEVICES_VIEW)}
>
<i class="bi-folder2 me-2"></i>
Devices
</button>
<button
class={
"btn me-2" +
(activeView() === REMOTES_VIEW ? " btn-secondary" : " btn-dark")
}
onClick={() => handleViewChange(REMOTES_VIEW)}
>
<i class="bi-gear-fill me-2"></i>
Remotes
</button>
<button
class={
"btn me-2" +
(activeView() === RECORDINGS_VIEW
? " btn-secondary"
: " btn-dark")
}
onClick={() => handleViewChange(RECORDINGS_VIEW)}
>
<i class="bi bi-terminal me-2"></i>
Recordings
</button>
</div>
<div class="d-flex align-items-center">
<button
ref={userButtonRef}
class="btn btn-dark rounded-circle p-0"
style="width: 2.4em; height: 2.4em"
onClick={toggleUserMenu}
>
<i class="bi bi-person-fill"></i>
</button>
<div
onClick={hideUserMenu}
ref={userMenuBackRef}
style="position:absolute;width:100vw;height:100vh;left:0;right:0;z-index:1060;display:none"
></div>
<div
class="list-group position-absolute"
style="display: none; z-index: 1070"
ref={userMenuRef}
>
<li class="list-group-item bg-dark-subtle">{username()}</li>
<button
type="button"
class="list-group-item list-group-item-action"
onClick={() => {
handleViewChange(SETTINGS_VIEW);
hideUserMenu();
}}
>
<i class="bi bi-gear-fill me-2"></i>
Settings
</button>
<button
type="button"
class="list-group-item list-group-item-action pe-auto"
onClick={() => handleLogout()}
>
<i class="bi bi-box-arrow-in-right me-2"></i>
Log Out
</button>
</div>
</div>
</div>
</div>
);
}
function ActiveView(props) {
return (
<div class="container-xl p-0 d-flex flex-column flex-fill overflow-hidden">
<Switch>
<Match when={activeView() === SETTINGS_VIEW}>
<SettingsView {...props} />
</Match>
</Switch>
</div>
);
}
return render();
};
MainView.setActiveView = setActiveView;
export default MainView;

View File

@ -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 (
<>
<ChangePasswordSettings />
</>
);
}
function renderUserSettings() {
return (
<>
<UserList />
</>
);
}
return (
<div class={"flex-fill p-3 d-flex flex-column " + props.class}>
<div class="d-flex align-items-center mb-3">
<h3 class="me-3 mb-0">Settings</h3>
<For each={tabs}>
{(tab) => (
<Show
when={!tab.adminOnly || (!userInfo.loading && userInfo().is_admin)}
>
<button
class={
"btn ms-2" +
(activeTab() === tab.id ? " btn-secondary" : " btn-dark")
}
onClick={() => setActiveTab(tab.id)}
>
{tab.text}
</button>
</Show>
)}
</For>
</div>
<For each={tabs}>
{(tab) => <Show when={tab.id === activeTab()}>{tab.render()}</Show>}
</For>
</div>
);
}
export default SettingsView;