jf_activity: paginated list in ui

added POST route for pagination to activity route, a count route, and
modified Search and PaginatedList a bit to support lists without search
fields (essentially just running an empty search). Visible by clicking
on a user's name in the accounts tab.
This commit is contained in:
Harvey Tindall
2025-12-20 18:27:39 +00:00
parent d72a5c91cf
commit b6459b665d
16 changed files with 738 additions and 193 deletions
+470 -49
View File
@@ -16,6 +16,7 @@ import { DiscordUser, newDiscordSearch } from "../modules/discord";
import { SearchConfiguration, QueryType, SearchableItem, SearchableItemDataAttribute } from "../modules/search";
import { HiddenInputField } from "./ui";
import { PaginatedList } from "./list";
import { TableRow } from "./row";
declare var window: GlobalWindow;
@@ -177,9 +178,8 @@ const queries = (): { [field: string]: QueryType } => {
};
};
class User implements UserDTO, SearchableItem {
class User extends TableRow implements UserDTO, SearchableItem {
private _id = "";
private _row: HTMLTableRowElement;
private _check: HTMLInputElement;
private _username: HTMLSpanElement;
private _admin: HTMLSpanElement;
@@ -692,12 +692,11 @@ class User implements UserDTO, SearchableItem {
private _uncheckEvent = () => new CustomEvent("accountUncheckEvent", { detail: this.id });
constructor(user: UserDTO) {
this._row = document.createElement("tr") as HTMLTableRowElement;
this._row.classList.add("border-b", "border-dashed", "dark:border-dotted", "dark:border-stone-700");
super();
let innerHTML = `
<td><input type="checkbox" class="accounts-select-user" value=""></td>
<td><div class="flex flex-row gap-2 items-center">
<span class="accounts-username"></span>
<span class="accounts-username hover:underline hover:cursor-pointer"></span>
<div class="flex flex-row gap-2 items-baseline">
<span class="accounts-label-container" title="${window.lang.strings("label")}"></span>
<span class="accounts-admin chip ~info hidden"></span>
@@ -743,6 +742,15 @@ class User implements UserDTO, SearchableItem {
this._check = this._row.querySelector("input[type=checkbox].accounts-select-user") as HTMLInputElement;
this._accounts_admin = this._row.querySelector("input[type=checkbox].accounts-access-jfa") as HTMLInputElement;
this._username = this._row.querySelector(".accounts-username") as HTMLSpanElement;
this._username.onclick = () =>
document.dispatchEvent(
new CustomEvent("accounts-show-details", {
detail: {
username: this.name,
id: this.id,
},
}),
);
this._admin = this._row.querySelector(".accounts-admin") as HTMLSpanElement;
this._disabled = this._row.querySelector(".accounts-disabled") as HTMLSpanElement;
this._email = this._row.querySelector(".accounts-email-container") as HTMLInputElement;
@@ -922,14 +930,11 @@ class User implements UserDTO, SearchableItem {
this.referrals_enabled = user.referrals_enabled;
};
asElement = (): HTMLTableRowElement => {
return this._row;
};
remove = () => {
if (this.selected) {
document.dispatchEvent(this._uncheckEvent());
}
this._row.remove();
super.remove();
};
}
@@ -950,6 +955,8 @@ declare interface ExtendExpiryDTO {
}
export class accountsList extends PaginatedList {
private _details: UserInfo;
private _table = document.getElementById("accounts-table") as HTMLTableElement;
protected _container = document.getElementById("accounts-list") as HTMLTableSectionElement;
private _addUserButton = document.getElementById("accounts-add-user") as HTMLSpanElement;
@@ -1000,8 +1007,8 @@ export class accountsList extends PaginatedList {
private _selectAllState: SelectAllState = SelectAllState.None;
// private _users: { [id: string]: user };
// private _ordering: string[] = [];
get users(): { [id: string]: User } {
return this._search.items as { [id: string]: User };
get users(): Map<string, User> {
return this._search.items as Map<string, User>;
}
// set users(v: { [id: string]: user }) { this._search.items = v as SearchableItems; }
@@ -1040,6 +1047,27 @@ export class accountsList extends PaginatedList {
}
};
details(username: string, jfId: string) {
this.unbindPageEvents();
console.debug("Loading details for ", username, jfId);
this._details.load(
username,
jfId,
() => {
this.unbindPageEvents();
this._table.classList.add("unfocused");
this._details.hidden = false;
this.processSelectedAccounts();
},
() => {
this._details.hidden = true;
this._table.classList.remove("unfocused");
this.processSelectedAccounts();
this.bindPageEvents();
},
);
}
constructor() {
super({
loader: document.getElementById("accounts-loader"),
@@ -1060,8 +1088,8 @@ export class accountsList extends PaginatedList {
maxItemsLoadedForSearch: 200,
appendNewItems: (resp: paginatedDTO) => {
for (let u of (resp as UsersDTO).users || []) {
if (u.id in this.users) {
this.users[u.id].update(u);
if (this.users.has(u.id)) {
this.users.get(u.id).update(u);
} else {
this.add(u);
}
@@ -1074,15 +1102,15 @@ export class accountsList extends PaginatedList {
);
},
replaceWithNewItems: (resp: paginatedDTO) => {
let accountsOnDOM: { [id: string]: boolean } = {};
let accountsOnDOM = new Map<string, boolean>();
for (let id of Object.keys(this.users)) {
accountsOnDOM[id] = true;
for (let id of this.users.keys()) {
accountsOnDOM.set(id, true);
}
for (let u of (resp as UsersDTO).users || []) {
if (u.id in accountsOnDOM) {
this.users[u.id].update(u);
delete accountsOnDOM[u.id];
if (accountsOnDOM.has(u.id)) {
this.users.get(u.id).update(u);
accountsOnDOM.delete(u.id);
} else {
this.add(u);
}
@@ -1090,9 +1118,9 @@ export class accountsList extends PaginatedList {
// Delete accounts w/ remaining IDs (those not in resp.users)
// console.log("Removing", Object.keys(accountsOnDOM).length, "from DOM");
for (let id in accountsOnDOM) {
this.users[id].remove();
delete this.users[id];
for (let id of accountsOnDOM.keys()) {
this.users.get(id).remove();
this.users.delete(id);
}
this._search.setOrdering(
@@ -1404,6 +1432,11 @@ export class accountsList extends PaginatedList {
this.registerURLListener();
this._details = new UserInfo(document.getElementsByClassName("accounts-details")[0] as HTMLElement);
document.addEventListener("accounts-show-details", (ev: ShowDetailsEvent) => {
this.details(ev.detail.username, ev.detail.id);
});
// Get rid of nasty CSS
window.modals.announce.onclose = () => {
const preview = document.getElementById("announce-preview") as HTMLDivElement;
@@ -1448,8 +1481,8 @@ export class accountsList extends PaginatedList {
if (next == SelectAllState.None) {
// Deselect -all- users, rather than just visible ones, to be safe.
for (let id in this.users) {
this.users[id].setSelected(false, false);
for (let id of this.users.keys()) {
this.users.get(id).setSelected(false, false);
}
this._selectAll.checked = false;
this._selectAll.indeterminate = false;
@@ -1461,7 +1494,7 @@ export class accountsList extends PaginatedList {
const selectAllVisible = () => {
let count = 0;
for (let id of this._visible) {
this.users[id].setSelected(true, false);
this.users.get(id).setSelected(true, false);
count++;
}
console.debug("Selected", count);
@@ -1495,15 +1528,14 @@ export class accountsList extends PaginatedList {
for (let id of this._search.ordering) {
if (!(inRange || id == startID)) continue;
inRange = true;
if (!this._container.contains(this.users[id].asElement())) continue;
this.users[id].selected = true;
if (!this._container.contains(this.users.get(id).asElement())) continue;
this.users.get(id).selected = true;
if (id == endID) return;
}
};
add = (u: UserDTO) => {
let domAccount = new User(u);
this.users[u.id] = domAccount;
this.users.set(u.id, new User(u));
// console.log("after appending lengths:", Object.keys(this.users).length, Object.keys(this._search.items).length);
};
@@ -1551,35 +1583,36 @@ export class accountsList extends PaginatedList {
if (window.emailEnabled || window.telegramEnabled) {
this._announceButton.parentElement.classList.remove("unfocused");
}
let anyNonExpiries = list.length == 0 ? true : false;
let allNonExpiries = true;
let noContactCount = 0;
let referralState = Number(this.users[list[0]].referrals_enabled); // -1 = hide, 0 = show "enable", 1 = show "disable"
let referralState = Number(this.users.get(list[0]).referrals_enabled); // -1 = hide, 0 = show "enable", 1 = show "disable"
// Only show enable/disable button if all selected have the same state.
this._shouldEnable = this.users[list[0]].disabled;
this._shouldEnable = this.users.get(list[0]).disabled;
let showDisableEnable = true;
for (let id of list) {
if (!anyNonExpiries && !this.users[id].expiry) {
if (!anyNonExpiries && !this.users.get(id).expiry) {
anyNonExpiries = true;
this._expiryDropdown.classList.add("unfocused");
}
if (this.users[id].expiry) {
if (this.users.get(id).expiry) {
allNonExpiries = false;
}
if (showDisableEnable && this.users[id].disabled != this._shouldEnable) {
if (showDisableEnable && this.users.get(id).disabled != this._shouldEnable) {
showDisableEnable = false;
this._disableEnable.parentElement.classList.add("unfocused");
}
if (!showDisableEnable && anyNonExpiries) {
break;
}
if (!this.users[id].lastNotifyMethod()) {
if (!this.users.get(id).lastNotifyMethod()) {
noContactCount++;
}
if (
window.referralsEnabled &&
referralState != -1 &&
Number(this.users[id].referrals_enabled) != referralState
Number(this.users.get(id).referrals_enabled) != referralState
) {
referralState = -1;
}
@@ -1638,9 +1671,10 @@ export class accountsList extends PaginatedList {
};
private _collectUsers = (): string[] => {
if (!this._details.hidden && this._details.jfId != "") return [this._details.jfId];
let list: string[] = [];
for (let id of this._visible) {
if (this.users[id].selected) {
if (this.users.get(id).selected) {
list.push(id);
}
}
@@ -1993,7 +2027,7 @@ export class accountsList extends PaginatedList {
let list = this._collectUsers();
let manualUser: User;
for (let id of list) {
let user = this.users[id];
let user = this.users.get(id);
if (!user.lastNotifyMethod() && !user.email) {
manualUser = user;
break;
@@ -2065,8 +2099,8 @@ export class accountsList extends PaginatedList {
(() => {
let innerHTML = "";
for (let id in this.users) {
innerHTML += `<option value="${id}">${this.users[id].name}</option>`;
for (let id of this.users.keys()) {
innerHTML += `<option value="${id}">${this.users.get(id).name}</option>`;
}
this._userSelect.innerHTML = innerHTML;
})();
@@ -2137,7 +2171,7 @@ export class accountsList extends PaginatedList {
let list = this._collectUsers();
// Check if we're disabling or enabling
if (this.users[list[0]].referrals_enabled) {
if (this.users.get(list[0]).referrals_enabled) {
_delete("/users/referral", { users: list }, (req: XMLHttpRequest) => {
if (req.readyState != 4 || req.status != 200) return;
window.notifications.customSuccess(
@@ -2285,8 +2319,8 @@ export class accountsList extends PaginatedList {
let id = users.length > 0 ? users[0] : "";
if (!id) invalid = true;
else {
date = new Date(this.users[id].expiry * 1000);
if (this.users[id].expiry == 0) date = new Date();
date = new Date(this.users.get(id).expiry * 1000);
if (this.users.get(id).expiry == 0) date = new Date();
date.setMonth(date.getMonth() + +fields[0].value);
date.setDate(date.getDate() + +fields[1].value);
date.setHours(date.getHours() + +fields[2].value);
@@ -2429,7 +2463,7 @@ export class accountsList extends PaginatedList {
this._c.searchBox.value = `id:"${userID}"`;
this._search.onSearchBoxChange();
this._search.onServerSearch();
if (userID in this.users) this.users[userID].focus();
if (userID in this.users) this.users.get(userID).focus();
};
public static readonly _accountURLEvent = "account-url";
@@ -2545,12 +2579,12 @@ class Column {
}
// Sorts the user list. previouslyActive is whether this column was previously sorted by, indicating that the direction should change.
sort = (users: { [id: string]: User }): string[] => {
let userIDs = Object.keys(users);
sort = (users: Map<string, User>): string[] => {
let userIDs = Array.from(users.keys());
userIDs.sort((a: string, b: string): number => {
let av: GetterReturnType = this._getter.call(users[a]);
let av: GetterReturnType = this._getter.call(users.get(a));
if (typeof av === "string") av = av.toLowerCase();
let bv: GetterReturnType = this._getter.call(users[b]);
let bv: GetterReturnType = this._getter.call(users.get(b));
if (typeof bv === "string") bv = bv.toLowerCase();
if (av < bv) return this._ascending ? -1 : 1;
if (av > bv) return this._ascending ? 1 : -1;
@@ -2561,7 +2595,17 @@ class Column {
};
}
type ActivitySeverity = "Info" | "Debug" | "Warn" | "Error" | "Fatal";
type ActivitySeverity =
| "Fatal"
| "None"
| "Trace"
| "Debug"
| "Information"
| "Info"
| "Warn"
| "Warning"
| "Error"
| "Critical";
interface ActivityLogEntryDTO {
Id: number;
Name: string;
@@ -2574,3 +2618,380 @@ interface ActivityLogEntryDTO {
UserPrimaryImageTag: string;
Severity: ActivitySeverity;
}
interface PaginatedActivityLogEntriesDTO {
entries: ActivityLogEntryDTO[];
last_page: boolean;
}
class ActivityLogEntry extends TableRow implements ActivityLogEntryDTO, SearchableItem {
private _e: ActivityLogEntryDTO;
private _username: string;
private _severity: HTMLElement;
private _user: HTMLElement;
private _name: HTMLElement;
private _type: HTMLElement;
private _overview: HTMLElement;
private _time: HTMLElement;
update = (user: string | null, entry: ActivityLogEntryDTO) => {
this._e = entry;
if (user != null) this.User = user;
this.Id = entry.Id;
this.Name = entry.Name;
this.Overview = entry.Overview;
this.ShortOverview = entry.ShortOverview;
this.Type = entry.Type;
this.ItemId = entry.ItemId;
this.Date = entry.Date;
this.UserId = entry.UserId;
this.UserPrimaryImageTag = entry.UserPrimaryImageTag;
this.Severity = entry.Severity;
};
constructor(user: string, entry: ActivityLogEntryDTO) {
super();
this._row.innerHTML = `
<td class="text-center-i"><span class="jf-activity-log-severity chip ~info dark:~d_info unfocused"></span></td>
<td class="jf-activity-log-user-name-combined max-w-96 truncate"><div class="flex flex-row gap-2 items-baseline"><span class="chip ~gray dark:~d_gray jf-activity-log-user unfocused"></span><span class="jf-activity-log-name truncate"></span></div></td>
<td class="jf-activity-log-type italic"></td>
<td class="jf-activity-log-overview"></td>
<td class="jf-activity-log-time"></td>
`;
this._severity = this._row.getElementsByClassName("jf-activity-log-severity")[0] as HTMLElement;
this._user = this._row.getElementsByClassName("jf-activity-log-user")[0] as HTMLElement;
this._name = this._row.getElementsByClassName("jf-activity-log-name")[0] as HTMLElement;
this._type = this._row.getElementsByClassName("jf-activity-log-type")[0] as HTMLElement;
this._overview = this._row.getElementsByClassName("jf-activity-log-overview")[0] as HTMLElement;
this._time = this._row.getElementsByClassName("jf-activity-log-time")[0] as HTMLElement;
this.update(user, entry);
}
matchesSearch = (query: string): boolean => {
return (
("" + this.Id).includes(query) ||
this.User.includes(query) ||
this.UserId.includes(query) ||
this.Name.includes(query) ||
this.Overview.includes(query) ||
this.ShortOverview.includes(query) ||
this.Type.includes(query) ||
this.ItemId.includes(query) ||
this.UserId.includes(query) ||
this.Severity.includes(query)
);
};
get Id(): number {
return this._e.Id;
}
set Id(v: number) {
this._e.Id = v;
}
private setName() {
const space = this.Name.indexOf(" ");
let nameContent = this.Name;
let endOfUserBadge = ":";
if (space != -1 && this.User != "" && nameContent.substring(0, space) == this.User) {
endOfUserBadge = "";
nameContent = nameContent.substring(space + 1, nameContent.length);
}
if (this.User == "") this._user.classList.add("unfocused");
else this._user.classList.remove("unfocused");
this._user.textContent = this.User + endOfUserBadge;
this._name.textContent = nameContent;
this._name.title = nameContent;
}
// User is the username of the user. It is not part of an ActivityLogEntryDTO, rather the UserId is, but in most contexts the parent should know it anyway.
get User(): string {
return this._username;
}
set User(v: string) {
this._username = v;
this.setName();
}
// Name is a description of the entry. It often starts with the name of the user, so
get Name(): string {
return this._e.Name;
}
set Name(v: string) {
this._e.Name = v;
this.setName();
}
// "Overview" doesn't seem to be used, but we'll let it take precedence anyway.
private setOverview() {
this._overview.textContent = this.Overview || this.ShortOverview;
}
// Overview is something, but I haven't seen it actually used.
get Overview(): string {
return this._e.Overview;
}
set Overview(v: string) {
this._e.Overview = v;
this.setOverview();
}
// ShortOverview usually seems to be the IP address of the user in applicable entries.
get ShortOverview(): string {
return this._e.ShortOverview;
}
set ShortOverview(v: string) {
this._e.ShortOverview = v;
this.setOverview();
}
get Type(): string {
return this._e.Type;
}
set Type(v: string) {
this._e.Type = v;
this._type.textContent = v;
}
get ItemId(): string {
return this._e.ItemId;
}
set ItemId(v: string) {
this._e.ItemId = v;
}
get Date(): number {
return this._e.Date;
}
set Date(v: number) {
this._e.Date = v;
this._time.textContent = toDateString(new Date(v * 1000));
}
get UserId(): string {
return this._e.UserId;
}
set UserId(v: string) {
this._e.UserId = v;
}
get UserPrimaryImageTag(): string {
return this._e.UserPrimaryImageTag;
}
set UserPrimaryImageTag(v: string) {
this._e.UserPrimaryImageTag = v;
}
get Severity(): ActivitySeverity {
return this._e.Severity;
}
set Severity(v: ActivitySeverity) {
this._e.Severity = v;
if (v) this._severity.classList.remove("unfocused");
else this._severity.classList.add("unfocused");
["~neutral", "~positive", "~warning", "~critical", "~info", "~urge"].forEach((c) =>
this._severity.classList.remove(c),
);
switch (v) {
case "Info":
case "Information":
this._severity.textContent = window.lang.strings("info");
this._severity.classList.add("~info");
break;
case "Debug":
case "Trace":
this._severity.textContent = window.lang.strings("debug");
this._severity.classList.add("~urge");
break;
case "Warn":
case "Warning":
this._severity.textContent = window.lang.strings("warn");
this._severity.classList.add("~warning");
break;
case "Error":
this._severity.textContent = window.lang.strings("error");
this._severity.classList.add("~critical");
break;
case "Critical":
case "Fatal":
this._severity.textContent = window.lang.strings("fatal");
this._severity.classList.add("~critical");
break;
case "None":
this._severity.textContent = window.lang.strings("none");
this._severity.classList.add("~neutral");
default:
console.warn("Unknown key in activity severity:", v);
this._severity.textContent = v;
this._severity.classList.add("~neutral");
}
}
}
interface ShowDetailsEvent extends Event {
detail: {
username: string;
id: string;
};
}
class UserInfo extends PaginatedList {
private _card: HTMLElement;
get entries(): Map<string, ActivityLogEntry> {
return this._search.items as Map<string, ActivityLogEntry>;
}
private _back: HTMLButtonElement;
username: string;
jfId: string;
constructor(card: HTMLElement) {
card.classList.add("unfocused");
card.innerHTML = `
<div class="flex flex-col gap-2">
<div class="flex flex-row gap-2 justify-start">
<button type="button" title="${window.lang.strings("back")}" class="button ~neutral @low inline-flex gap-1 user-details-back" aria-label="${window.lang.strings("back")}"><i class="icon ri-arrow-left-fill"></i>${window.lang.strings("back")}</button>
</div>
<div class="flex flex-row gap-2">
</div>
<div class="card @low overflow-x-scroll">
<table class="table text-xs leading-5">
<thead>
<tr>
<th>${window.lang.strings("severity")}</th>
<th>${window.lang.strings("details")}</th>
<th>${window.lang.strings("type")}</th>
<th>${window.lang.strings("other")}</th>
<th>${window.lang.strings("date")}</th>
</tr>
</thead>
<tbody class="jf-activity-table-content"></tbody>
</table>
</div>
<div class="flex flex-row gap-2 justify-center">
<button class="button ~neutral @low jf-activity-load-more">${window.lang.strings("loadMore")}</button>
<button class="button ~neutral @low accounts-load-all jf-activity-load-all">${window.lang.strings("loadAll")}</button>
</div>
</div>
`;
super({
loader: card.getElementsByClassName("jf-activity-loader")[0] as HTMLElement,
loadMoreButtons: Array.from(
document.getElementsByClassName("jf-activity-load-more"),
) as Array<HTMLButtonElement>,
loadAllButtons: Array.from(
document.getElementsByClassName("jf-activity-load-all"),
) as Array<HTMLButtonElement>,
totalEndpoint: () => "/users/" + this.jfId + "/activities/jellyfin/count",
getPageEndpoint: () => "/users/" + this.jfId + "/activities/jellyfin",
itemsPerPage: 20,
maxItemsLoadedForSearch: 200,
disableSearch: true,
appendNewItems: (resp: paginatedDTO) => {
for (let entry of (resp as PaginatedActivityLogEntriesDTO).entries || []) {
if (this.entries.has("" + entry.Id)) {
this.entries.get("" + entry.Id).update(null, entry);
} else {
this.add(entry);
}
}
this._search.setOrdering(Array.from(this.entries.keys()), "Date", true);
},
replaceWithNewItems: (resp: paginatedDTO) => {
let entriesOnDOM = new Map<string, boolean>();
for (let id of this.entries.keys()) {
entriesOnDOM.set(id, true);
}
for (let entry of (resp as PaginatedActivityLogEntriesDTO).entries || []) {
if (entriesOnDOM.has("" + entry.Id)) {
this.entries.get("" + entry.Id).update(null, entry);
entriesOnDOM.delete("" + entry.Id);
} else {
this.add(entry);
}
}
// Delete entries w/ remaining IDs (those not in resp.entries)
// console.log("Removing", Object.keys(entriesOnDOM).length, "from DOM");
for (let id of entriesOnDOM.keys()) {
this.entries.get(id).remove();
this.entries.delete(id);
}
this._search.setOrdering(Array.from(this.entries.keys()), "Date", true);
},
});
this._card = card;
this._container = this._card.getElementsByClassName("jf-activity-table-content")[0] as HTMLElement;
this._back = this._card.getElementsByClassName("user-details-back")[0] as HTMLButtonElement;
let searchConfig: SearchConfiguration = {
queries: {},
setVisibility: null,
searchServer: null,
clearServerSearch: null,
onSearchCallback: (_0: boolean, _1: boolean) => {},
};
this.initSearch(searchConfig);
}
add = (entry: ActivityLogEntryDTO) => {
this.entries.set("" + entry.Id, new ActivityLogEntry(this.username, entry));
};
loadMore = (loadAll: boolean = false, callback?: (resp?: paginatedDTO) => void) => {
this._loadMore(loadAll, callback);
};
loadAll = (callback?: (resp?: paginatedDTO) => void) => {
this._loadAll(callback);
};
reload = (callback?: (resp: paginatedDTO) => void) => {
this._reload(callback);
};
load = (username: string, jfId: string, onLoad?: () => void, onBack?: () => void) => {
this.username = username;
this.jfId = jfId;
if (onBack) {
this._back.classList.remove("unfocused");
this._back.onclick = () => onBack();
} else {
this._back.classList.add("unfocused");
this._back.onclick = null;
}
this.reload(onLoad);
/*_get("/users/" + jfId + "/activities/jellyfin", null, (req: XMLHttpRequest) => {
if (req.readyState != 4) return;
if (req.status != 200) {
window.notifications.customError("errorLoadJFActivities", window.lang.notif("errorLoadActivities"));
return;
}
// FIXME: Lazy loading table
this._table.textContent = ``;
let entries = (req.response as PaginatedActivityLogEntriesDTO).entries;
for (let entry of entries) {
const row = new ActivityLogEntry(username, entry);
this._table.appendChild(row.asElement());
}
if (onLoad) onLoad();
});*/
};
get hidden(): boolean {
return this._card.classList.contains("unfocused");
}
set hidden(v: boolean) {
if (v) {
this.unbindPageEvents();
this._card.classList.add("unfocused");
this.username = "";
this.jfId = "";
} else {
this.bindPageEvents();
this._card.classList.remove("unfocused");
}
}
}