diff --git a/api-activities.go b/api-activities.go
index 5ff3b76..6d62751 100644
--- a/api-activities.go
+++ b/api-activities.go
@@ -97,7 +97,7 @@ func (app *appContext) GetActivities(gc *gin.Context) {
req := GetActivitiesDTO{}
gc.BindJSON(&req)
query := &badgerhold.Query{}
- activityTypes := make([]interface{}, len(req.Type))
+ activityTypes := make([]any, len(req.Type))
for i, v := range req.Type {
activityTypes[i] = stringToActivityType(v)
}
diff --git a/api-users.go b/api-users.go
index 38149f0..a4e6214 100644
--- a/api-users.go
+++ b/api-users.go
@@ -902,7 +902,7 @@ func (app *appContext) userSummary(jfUser mediabrowser.User) respUser {
// @tags Activity
func (app *appContext) GetUserCount(gc *gin.Context) {
resp := PageCountDTO{}
- userList, err := app.userCache.Gen(app)
+ userList, err := app.userCache.Gen(app, false)
if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
respond(500, "Couldn't get users", gc)
@@ -924,13 +924,12 @@ func (app *appContext) GetUsers(gc *gin.Context) {
// We're sending all users, so this is always true
resp.LastPage = true
var err error
- resp.UserList, err = app.userCache.Gen(app)
+ resp.UserList, err = app.userCache.Gen(app, true)
if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
respond(500, "Couldn't get users", gc)
return
}
- app.debug.Printf("sending usercache of length %d", len(resp.UserList))
gc.JSON(200, resp)
}
@@ -945,20 +944,31 @@ func (app *appContext) GetUsers(gc *gin.Context) {
func (app *appContext) SearchUsers(gc *gin.Context) {
req := getUsersReqDTO{}
gc.BindJSON(&req)
-
- // FIXME: Figure out how to search, sort and paginate []mediabrowser.User!
- // Expr!
+ if req.SortByField == "" {
+ req.SortByField = USER_DEFAULT_SORT_FIELD
+ }
var resp getUsersDTO
- // We're sending all users, so this is always true
- resp.LastPage = true
- var err error
- resp.UserList, err = app.userCache.Gen(app)
+ userList, err := app.userCache.Gen(app, false)
if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
respond(500, "Couldn't get users", gc)
return
}
+ var filtered []*respUser
+ if (req.SearchTerms != nil && len(req.SearchTerms) != 0) || (req.Queries != nil && len(req.Queries) != 0) {
+ filtered = app.userCache.Filter(userList, req.SearchTerms, req.Queries)
+ } else {
+ filtered = userList
+ }
+ app.userCache.Sort(filtered, req.SortByField, req.Ascending)
+
+ startIndex := (req.Page * req.Limit)
+ if startIndex < len(filtered) {
+ endIndex := min(startIndex+req.Limit, len(filtered))
+ resp.UserList = filtered[startIndex:endIndex]
+ }
+ resp.LastPage = len(resp.UserList) != req.Limit
gc.JSON(200, resp)
}
diff --git a/html/admin.html b/html/admin.html
index 72953a0..2a9a8fc 100644
--- a/html/admin.html
+++ b/html/admin.html
@@ -722,9 +722,14 @@
{{ .strings.accounts }}
-
+
+
+
+ {{ .strings.searchAllRecords }}
+
+
@@ -803,14 +808,23 @@
+
{{ .strings.noResultsFound }}
-
+
{{ .strings.keepSearchingDescription }}
+
+
+
+
+
+
+
+
@@ -825,10 +839,14 @@
-
+
-
+
+
+ {{ .strings.searchAllRecords }}
+
+
diff --git a/lang/admin/en-us.json b/lang/admin/en-us.json
index eed6603..ff3dec1 100644
--- a/lang/admin/en-us.json
+++ b/lang/admin/en-us.json
@@ -136,6 +136,7 @@
"filters": "Filters",
"clickToRemoveFilter": "Click to remove this filter.",
"clearSearch": "Clear search",
+ "searchAllRecords": "Search all records (on server)",
"actions": "Actions",
"searchOptions": "Search Options",
"matchText": "Match Text",
@@ -190,6 +191,7 @@
"totalRecords": "{n} Total Records",
"loadedRecords": "{n} Loaded",
"shownRecords": "{n} Shown",
+ "selectedRecords": "{n} Selected",
"backups": "Backups",
"backupsDescription": "Backups of the database can be made, restored, or downloaded from here.",
"backupsFormatNote": "Only backup files with the standard name format will be shown here. To use any other, upload the backup manually.",
diff --git a/models.go b/models.go
index 360a2e2..7e6b31c 100644
--- a/models.go
+++ b/models.go
@@ -168,14 +168,20 @@ type PaginatedDTO struct {
LastPage bool `json:"last_page"`
}
+type PaginatedReqDTO struct {
+ Limit int `json:"limit"`
+ Page int `json:"page"` // zero-indexed
+ SortByField string `json:"sortByField"`
+ Ascending bool `json:"ascending"`
+}
+
type getUsersReqDTO struct {
- Limit int `json:"limit"`
- Page int `json:"page"` // zero-indexed
+ ServerSearchReqDTO
}
type getUsersDTO struct {
- UserList []respUser `json:"users"`
- LastPage bool `json:"last_page"`
+ UserList []*respUser `json:"users"`
+ LastPage bool `json:"last_page"`
}
type ombiUser struct {
@@ -440,10 +446,9 @@ type ActivityDTO struct {
}
type GetActivitiesDTO struct {
- Type []string `json:"type"` // Types of activity to get. Leave blank for all.
- Limit int `json:"limit"`
- Page int `json:"page"` // zero-indexed
- Ascending bool `json:"ascending"`
+ // "SortByField" ignores, it's always time.
+ PaginatedReqDTO
+ Type []string `json:"type"` // Types of activity to get. Leave blank for all.
}
type GetActivitiesRespDTO struct {
diff --git a/ts/admin.ts b/ts/admin.ts
index 6ee213b..45aea1a 100644
--- a/ts/admin.ts
+++ b/ts/admin.ts
@@ -148,12 +148,16 @@ const tabs: { id: string, url: string, reloader: () => void }[] = [
// Don't keep loading the same item on every tab refresh
isAccountURL = false;
}
+ window.onscroll = accounts.detectScroll;
}),
},
{
id: "activity",
url: "activity",
- reloader: activity.reload
+ reloader: () => {
+ activity.reload()
+ window.onscroll = activity.detectScroll;
+ },
},
{
id: "settings",
diff --git a/ts/modules/accounts.ts b/ts/modules/accounts.ts
index 2bc902d..a95b14f 100644
--- a/ts/modules/accounts.ts
+++ b/ts/modules/accounts.ts
@@ -3,9 +3,9 @@ import { templateEmail } from "../modules/settings.js";
import { Marked } from "@ts-stack/markdown";
import { stripMarkdown } from "../modules/stripmd.js";
import { DiscordUser, newDiscordSearch } from "../modules/discord.js";
-import { Search, SearchConfiguration, QueryType, SearchableItem } from "../modules/search.js";
+import { Search, SearchConfiguration, QueryType, SearchableItem, SearchableItems } from "../modules/search.js";
import { HiddenInputField } from "./ui.js";
-import { RecordCounter } from "./activity.js";
+import { PaginatedList } from "./list.js";
declare var window: GlobalWindow;
@@ -44,6 +44,117 @@ interface announcementTemplate {
}
var addDiscord: (passData: string) => void;
+
+const queries = (): { [field: string]: QueryType } => { return {
+ "id": {
+ // We don't use a translation here to circumvent the name substitution feature.
+ name: "Jellyfin/Emby ID",
+ getter: "id",
+ bool: false,
+ string: true,
+ date: false
+ },
+ "label": {
+ name: window.lang.strings("label"),
+ getter: "label",
+ bool: true,
+ string: true,
+ date: false
+ },
+ "username": {
+ name: window.lang.strings("username"),
+ getter: "name",
+ bool: false,
+ string: true,
+ date: false
+ },
+ "name": {
+ name: window.lang.strings("username"),
+ getter: "name",
+ bool: false,
+ string: true,
+ date: false,
+ show: false
+ },
+ "admin": {
+ name: window.lang.strings("admin"),
+ getter: "admin",
+ bool: true,
+ string: false,
+ date: false
+ },
+ "disabled": {
+ name: window.lang.strings("disabled"),
+ getter: "disabled",
+ bool: true,
+ string: false,
+ date: false
+ },
+ "access-jfa": {
+ name: window.lang.strings("accessJFA"),
+ getter: "accounts_admin",
+ bool: true,
+ string: false,
+ date: false,
+ dependsOnElement: ".accounts-header-access-jfa"
+ },
+ "email": {
+ name: window.lang.strings("emailAddress"),
+ getter: "email",
+ bool: true,
+ string: true,
+ date: false,
+ dependsOnElement: ".accounts-header-email"
+ },
+ "telegram": {
+ name: "Telegram",
+ getter: "telegram",
+ bool: true,
+ string: true,
+ date: false,
+ dependsOnElement: ".accounts-header-telegram"
+ },
+ "matrix": {
+ name: "Matrix",
+ getter: "matrix",
+ bool: true,
+ string: true,
+ date: false,
+ dependsOnElement: ".accounts-header-matrix"
+ },
+ "discord": {
+ name: "Discord",
+ getter: "discord",
+ bool: true,
+ string: true,
+ date: false,
+ dependsOnElement: ".accounts-header-discord"
+ },
+ "expiry": {
+ name: window.lang.strings("expiry"),
+ getter: "expiry",
+ bool: true,
+ string: false,
+ date: true,
+ dependsOnElement: ".accounts-header-expiry"
+ },
+ "last-active": {
+ name: window.lang.strings("lastActiveTime"),
+ getter: "last_active",
+ bool: true,
+ string: false,
+ date: true
+ },
+ "referrals-enabled": {
+ name: window.lang.strings("referrals"),
+ getter: "referrals_enabled",
+ bool: true,
+ string: false,
+ date: false,
+ dependsOnElement: ".accounts-header-referrals"
+ }
+}};
+
class user implements User, SearchableItem {
private _id = "";
@@ -112,7 +223,7 @@ class user implements User, SearchableItem {
get name(): string { return this._username.textContent; }
set name(value: string) { this._username.textContent = value; }
- get admin(): boolean { return this._admin.classList.contains("chip"); }
+ get admin(): boolean { return !(this._admin.classList.contains("hidden")); }
set admin(state: boolean) {
if (state) {
this._admin.classList.remove("hidden")
@@ -472,7 +583,6 @@ class user implements User, SearchableItem {
}
matchesSearch = (query: string): boolean => {
- console.log(this.name, "matches", query, ":", this.name.includes(query));
return (
this.id.includes(query) ||
this.name.toLowerCase().includes(query) ||
@@ -536,7 +646,6 @@ class user implements User, SearchableItem {
|
`;
this._row.innerHTML = innerHTML;
- const emailEditor = ``;
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;
@@ -709,7 +818,9 @@ interface UsersDTO extends paginatedDTO {
users: User[];
}
-export class accountsList {
+interface UsersReqDTO extends PaginatedReqDTO {};
+
+export class accountsList extends PaginatedList {
private _table = document.getElementById("accounts-list") as HTMLTableSectionElement;
private _addUserButton = document.getElementById("accounts-add-user") as HTMLSpanElement;
@@ -747,8 +858,6 @@ export class accountsList {
private _referralsProfileSelect = document.getElementById("enable-referrals-user-profiles") as HTMLSelectElement;
private _referralsInviteSelect = document.getElementById("enable-referrals-user-invites") as HTMLSelectElement;
private _referralsExpiry = document.getElementById("enable-referrals-user-expiry") as HTMLInputElement;
- private _searchBox = document.getElementById("accounts-search") as HTMLInputElement;
- private _search: Search;
private _applyHomescreen = document.getElementById("modify-user-homescreen") as HTMLInputElement;
private _applyConfiguration = document.getElementById("modify-user-configuration") as HTMLInputElement;
@@ -756,9 +865,11 @@ export class accountsList {
private _applyJellyseerr = document.getElementById("modify-user-jellyseerr") as HTMLInputElement;
private _selectAll = document.getElementById("accounts-select-all") as HTMLInputElement;
- private _users: { [id: string]: user };
- private _ordering: string[] = [];
- private _checkCount: number = 0;
+ // private _users: { [id: string]: user };
+ // private _ordering: string[] = [];
+ get users(): { [id: string]: user } { return this._search.items as { [id: string]: user }; }
+ // set users(v: { [id: string]: user }) { this._search.items = v as SearchableItems; }
+
// Whether the enable/disable button should enable or not.
private _shouldEnable = false;
@@ -770,18 +881,13 @@ export class accountsList {
// Columns for sorting.
private _columns: { [className: string]: Column } = {};
- private _activeSortColumn: string;
-
- private _sortingByButton = document.getElementById("accounts-sort-by-field") as HTMLButtonElement;
- private _filterArea = document.getElementById("accounts-filter-area");
- private _searchOptionsHeader = document.getElementById("accounts-search-options-header");
-
- private _counter: RecordCounter;
// Whether the "Extend expiry" is extending or setting an expiry.
private _settingExpiry = false;
- private _count = 30;
+ private _sortingByButton = document.getElementById("accounts-sort-by-field") as HTMLButtonElement;
+
+ private _maxDayHourMinuteOptions = 30;
private _populateNumbers = () => {
const fieldIDs = ["months", "days", "hours", "minutes"];
const prefixes = ["extend-expiry-"];
@@ -789,7 +895,7 @@ export class accountsList {
for (let j = 0; j < prefixes.length; j++) {
const field = document.getElementById(prefixes[j] + fieldIDs[i]);
field.textContent = '';
- for (let n = 0; n <= this._count; n++) {
+ for (let n = 0; n <= this._maxDayHourMinuteOptions; n++) {
const opt = document.createElement("option") as HTMLOptionElement;
opt.textContent = ""+n;
opt.value = ""+n;
@@ -798,164 +904,381 @@ export class accountsList {
}
}
}
+
+ constructor() {
+ super({
+ loader: document.getElementById("accounts-loader"),
+ loadMoreButton: document.getElementById("accounts-load-more") as HTMLButtonElement,
+ loadAllButton: document.getElementById("accounts-load-all") as HTMLButtonElement,
+ refreshButton: document.getElementById("accounts-refresh") as HTMLButtonElement,
+ keepSearchingDescription: document.getElementById("accounts-keep-searching-description"),
+ keepSearchingButton: document.getElementById("accounts-keep-searching"),
+ filterArea: document.getElementById("accounts-filter-area"),
+ searchOptionsHeader: document.getElementById("accounts-search-options-header"),
+ searchBox: document.getElementById("accounts-search") as HTMLInputElement,
+ notFoundPanel: document.getElementById("accounts-not-found"),
+ recordCounter: document.getElementById("accounts-record-counter"),
+ totalEndpoint: "/users/count",
+ getPageEndpoint: "/users",
+ limit: 4,
+ newElementsFromPage: (resp: paginatedDTO) => {
+ for (let u of ((resp as UsersDTO).users || [])) {
+ if (u.id in this.users) {
+ this.users[u.id].update(u);
+ } else {
+ this.add(u);
+ }
+ }
- showHideSearchOptionsHeader = () => {
- const sortingBy = !(this._sortingByButton.parentElement.classList.contains("hidden"));
- const hasFilters = this._filterArea.textContent != "";
- console.log("sortingBy", sortingBy, "hasFilters", hasFilters);
- if (sortingBy || hasFilters) {
- this._searchOptionsHeader.classList.remove("hidden");
+ this._search.setOrdering(
+ this._columns[this._search.sortField].sort(this.users),
+ this._search.sortField,
+ this._search.ascending
+ );
+ },
+ updateExistingElementsFromPage: (resp: paginatedDTO) => {
+ let accountsOnDOM: { [id: string]: boolean } = {};
+
+ for (let id of Object.keys(this.users)) { accountsOnDOM[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];
+ } else {
+ this.add(u);
+ }
+ }
+
+ // 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];
+ }
+
+ this._search.setOrdering(
+ this._columns[this._search.sortField].sort(this.users),
+ this._search.sortField,
+ this._search.ascending
+ );
+ },
+ defaultSortField: "name",
+ pageLoadCallback: (req: XMLHttpRequest) => {
+ if (req.readyState != 4) return;
+ // FIXME: Error message
+ if (req.status != 200) return;
+ }
+ });
+ this._populateNumbers();
+
+ let searchConfig: SearchConfiguration = {
+ filterArea: this._c.filterArea,
+ sortingByButton: this._sortingByButton,
+ searchOptionsHeader: this._c.searchOptionsHeader,
+ notFoundPanel: this._c.notFoundPanel,
+ filterList: document.getElementById("accounts-filter-list"),
+ search: this._c.searchBox,
+ queries: queries(),
+ setVisibility: this.setVisibility,
+ clearSearchButtonSelector: ".accounts-search-clear",
+ serverSearchButtonSelector: ".accounts-search-server",
+ onSearchCallback: (_0: number, _1: boolean, _2: boolean) => {
+ this._checkCheckCount();
+ },
+ searchServer: null,
+ clearServerSearch: null,
+ };
+
+ this.initSearch(searchConfig);
+
+ this._selectAll.checked = false;
+ this._selectAll.onchange = () => {
+ this.selectAll = this._selectAll.checked;
+ };
+ document.addEventListener("accounts-reload", () => this.reload());
+ document.addEventListener("accountCheckEvent", () => { this._counter.selected++; this._checkCheckCount(); });
+ document.addEventListener("accountUncheckEvent", () => { this._counter.selected--; this._checkCheckCount(); });
+ this._addUserButton.onclick = () => {
+ this._populateAddUserProfiles();
+ window.modals.addUser.toggle();
+ };
+ this._addUserForm.addEventListener("submit", this._addUser);
+
+ this._deleteNotify.onchange = () => {
+ if (this._deleteNotify.checked) {
+ this._deleteReason.classList.remove("unfocused");
+ } else {
+ this._deleteReason.classList.add("unfocused");
+ }
+ };
+ this._modifySettings.onclick = this.modifyUsers;
+ this._modifySettings.classList.add("unfocused");
+
+ if (window.ombiEnabled)
+ this._applyOmbi.parentElement.classList.remove("unfocused");
+ else
+ this._applyOmbi.parentElement.classList.add("unfocused");
+ if (window.jellyseerrEnabled)
+ this._applyJellyseerr.parentElement.classList.remove("unfocused");
+ else
+ this._applyJellyseerr.parentElement.classList.add("unfocused");
+
+ const checkSource = () => {
+ const profileSpan = this._modifySettingsProfile.nextElementSibling as HTMLSpanElement;
+ const userSpan = this._modifySettingsUser.nextElementSibling as HTMLSpanElement;
+ if (this._modifySettingsProfile.checked) {
+ this._userSelect.parentElement.classList.add("unfocused");
+ this._profileSelect.parentElement.classList.remove("unfocused")
+ profileSpan.classList.add("@high");
+ profileSpan.classList.remove("@low");
+ userSpan.classList.remove("@high");
+ userSpan.classList.add("@low");
+ this._applyOmbi.parentElement.classList.remove("unfocused");
+ this._applyJellyseerr.parentElement.classList.remove("unfocused");
+ } else {
+ this._userSelect.parentElement.classList.remove("unfocused");
+ this._profileSelect.parentElement.classList.add("unfocused");
+ userSpan.classList.add("@high");
+ userSpan.classList.remove("@low");
+ profileSpan.classList.remove("@high");
+ profileSpan.classList.add("@low");
+ this._applyOmbi.parentElement.classList.add("unfocused");
+ this._applyJellyseerr.parentElement.classList.add("unfocused");
+ }
+ };
+ this._modifySettingsProfile.onchange = checkSource;
+ this._modifySettingsUser.onchange = checkSource;
+
+ if (window.referralsEnabled) {
+ const profileSpan = this._enableReferralsProfile.nextElementSibling as HTMLSpanElement;
+ const inviteSpan = this._enableReferralsInvite.nextElementSibling as HTMLSpanElement;
+ const checkReferralSource = () => {
+ console.log("States:", this._enableReferralsProfile.checked, this._enableReferralsInvite.checked);
+ if (this._enableReferralsProfile.checked) {
+ this._referralsInviteSelect.parentElement.classList.add("unfocused");
+ this._referralsProfileSelect.parentElement.classList.remove("unfocused")
+ profileSpan.classList.add("@high");
+ profileSpan.classList.remove("@low");
+ inviteSpan.classList.remove("@high");
+ inviteSpan.classList.add("@low");
+ } else {
+ this._referralsInviteSelect.parentElement.classList.remove("unfocused");
+ this._referralsProfileSelect.parentElement.classList.add("unfocused");
+ inviteSpan.classList.add("@high");
+ inviteSpan.classList.remove("@low");
+ profileSpan.classList.remove("@high");
+ profileSpan.classList.add("@low");
+ }
+ };
+ profileSpan.onclick = () => {
+ this._enableReferralsProfile.checked = true;
+ this._enableReferralsInvite.checked = false;
+ checkReferralSource();
+ };
+ inviteSpan.onclick = () => {;
+ this._enableReferralsInvite.checked = true;
+ this._enableReferralsProfile.checked = false;
+ checkReferralSource();
+ };
+ this._enableReferrals.onclick = () => {
+ this.enableReferrals();
+ profileSpan.onclick(null);
+ };
+ }
+
+ this._deleteUser.onclick = this.deleteUsers;
+ this._deleteUser.classList.add("unfocused");
+
+ this._announceButton.onclick = this.announce;
+ this._announceButton.parentElement.classList.add("unfocused");
+
+ this._extendExpiry.onclick = () => { this.extendExpiry(); };
+ this._removeExpiry.onclick = () => { this.removeExpiry(); };
+ this._expiryDropdown.classList.add("unfocused");
+ this._extendExpiryDate.classList.add("unfocused");
+
+ this._extendExpiryTextInput.onkeyup = () => {
+ this._extendExpiryTextInput.parentElement.parentElement.classList.remove("opacity-60");
+ this._extendExpiryFieldInputs.classList.add("opacity-60");
+ this._usingExtendExpiryTextInput = true;
+ this._displayExpiryDate();
+ }
+
+ this._extendExpiryTextInput.onclick = () => {
+ this._extendExpiryTextInput.parentElement.parentElement.classList.remove("opacity-60");
+ this._extendExpiryFieldInputs.classList.add("opacity-60");
+ this._usingExtendExpiryTextInput = true;
+ this._displayExpiryDate();
+ };
+
+ this._extendExpiryFieldInputs.onclick = () => {
+ this._extendExpiryFieldInputs.classList.remove("opacity-60");
+ this._extendExpiryTextInput.parentElement.parentElement.classList.add("opacity-60");
+ this._usingExtendExpiryTextInput = false;
+ this._displayExpiryDate();
+ };
+
+ for (let field of ["months", "days", "hours", "minutes"]) {
+ (document.getElementById("extend-expiry-"+field) as HTMLSelectElement).onchange = () => {
+ this._extendExpiryFieldInputs.classList.remove("opacity-60");
+ this._extendExpiryTextInput.parentElement.parentElement.classList.add("opacity-60");
+ this._usingExtendExpiryTextInput = false;
+ this._displayExpiryDate();
+ };
+ }
+
+ this._disableEnable.onclick = this.enableDisableUsers;
+ this._disableEnable.parentElement.classList.add("unfocused");
+
+ this._enableExpiry.onclick = () => { this.extendExpiry(true); };
+ this._enableExpiryNotify.onchange = () => {
+ if (this._enableExpiryNotify.checked) {
+ this._enableExpiryReason.classList.remove("unfocused");
+ } else {
+ this._enableExpiryReason.classList.add("unfocused");
+ }
+ };
+
+ if (!window.usernameEnabled) {
+ this._addUserName.classList.add("unfocused");
+ this._addUserName = this._addUserEmail;
+ }
+
+ if (!window.linkResetEnabled) {
+ this._sendPWR.classList.add("unfocused");
} else {
- this._searchOptionsHeader.classList.add("hidden");
+ this._sendPWR.onclick = this.sendPWR;
}
+ /*if (!window.emailEnabled) {
+ this._deleteNotify.parentElement.classList.add("unfocused");
+ this._deleteNotify.checked = false;
+ }*/
+
+ this._announceTextarea.onkeyup = this.loadPreview;
+ addDiscord = newDiscordSearch(window.lang.strings("linkDiscord"), window.lang.strings("searchDiscordUser"), window.lang.strings("add"), (user: DiscordUser, id: string) => {
+ _post("/users/discord", {jf_id: id, discord_id: user.id}, (req: XMLHttpRequest) => {
+ if (req.readyState == 4) {
+ document.dispatchEvent(new CustomEvent("accounts-reload"));
+ if (req.status != 200) {
+ window.notifications.customError("errorConnectDiscord", window.lang.notif("errorFailureCheckLogs"));
+ return
+ }
+ window.notifications.customSuccess("discordConnected", window.lang.notif("accountConnected"));
+ window.modals.discord.close()
+ }
+ });
+ });
+
+ this._announceSaveButton.onclick = this.saveAnnouncement;
+ const announceVarUsername = document.getElementById("announce-variables-username") as HTMLSpanElement;
+ announceVarUsername.onclick = () => {
+ insertText(this._announceTextarea, announceVarUsername.children[0].textContent);
+ this.loadPreview();
+ };
+
+ const headerNames: string[] = ["username", "access-jfa", "email", "telegram", "matrix", "discord", "expiry", "last-active", "referrals"];
+ const headerGetters: string[] = ["name", "accounts_admin", "email", "telegram", "matrix", "discord", "expiry", "last_active", "referrals_enabled"];
+ for (let i = 0; i < headerNames.length; i++) {
+ const header: HTMLTableHeaderCellElement = document.querySelector(".accounts-header-" + headerNames[i]) as HTMLTableHeaderCellElement;
+ if (header !== null) {
+ this._columns[headerGetters[i]] = new Column(header, headerGetters[i], Object.getOwnPropertyDescriptor(user.prototype, headerGetters[i]).get);
+ }
+ }
+
+ // Start off sorting by username (this._c.defaultSortField)
+ const defaultSort = () => {
+ document.dispatchEvent(new CustomEvent("header-click", { detail: this._c.defaultSortField }));
+ this._columns[this._c.defaultSortField].ascending = true;
+ this._columns[this._c.defaultSortField].hideIcon();
+ this._sortingByButton.parentElement.classList.add("hidden");
+ this._search.showHideSearchOptionsHeader();
+ };
+
+ this._sortingByButton.parentElement.addEventListener("click", defaultSort);
+
+ document.addEventListener("header-click", (event: CustomEvent) => {
+ this._search.setOrdering(
+ this._columns[event.detail].sort(this.users),
+ event.detail,
+ this._columns[event.detail].ascending
+ );
+ this._sortingByButton.innerHTML = this._columns[event.detail].buttonContent;
+ this._sortingByButton.parentElement.classList.remove("hidden");
+ // console.log("ordering by", event.detail, ": ", this._ordering);
+ if (this._search.inSearch) {
+ this._search.onSearchBoxChange();
+ } else {
+ this.setVisibility(this._search.ordering, true);
+ this._c.notFoundPanel.classList.add("unfocused");
+ }
+ this._search.showHideSearchOptionsHeader();
+ });
+
+ defaultSort();
+ this._search.showHideSearchOptionsHeader();
+
+ this._search.generateFilterList();
+
+ this.registerURLListener();
}
- private _queries: { [field: string]: QueryType } = {
- "id": {
- // We don't use a translation here to circumvent the name substitution feature.
- name: "Jellyfin/Emby ID",
- getter: "id",
- bool: false,
- string: true,
- date: false
- },
- "label": {
- name: window.lang.strings("label"),
- getter: "label",
- bool: true,
- string: true,
- date: false
- },
- "username": {
- name: window.lang.strings("username"),
- getter: "name",
- bool: false,
- string: true,
- date: false
- },
- "name": {
- name: window.lang.strings("username"),
- getter: "name",
- bool: false,
- string: true,
- date: false,
- show: false
- },
- "admin": {
- name: window.lang.strings("admin"),
- getter: "admin",
- bool: true,
- string: false,
- date: false
- },
- "disabled": {
- name: window.lang.strings("disabled"),
- getter: "disabled",
- bool: true,
- string: false,
- date: false
- },
- "access-jfa": {
- name: window.lang.strings("accessJFA"),
- getter: "accounts_admin",
- bool: true,
- string: false,
- date: false,
- dependsOnElement: ".accounts-header-access-jfa"
- },
- "email": {
- name: window.lang.strings("emailAddress"),
- getter: "email",
- bool: true,
- string: true,
- date: false,
- dependsOnElement: ".accounts-header-email"
- },
- "telegram": {
- name: "Telegram",
- getter: "telegram",
- bool: true,
- string: true,
- date: false,
- dependsOnElement: ".accounts-header-telegram"
- },
- "matrix": {
- name: "Matrix",
- getter: "matrix",
- bool: true,
- string: true,
- date: false,
- dependsOnElement: ".accounts-header-matrix"
- },
- "discord": {
- name: "Discord",
- getter: "discord",
- bool: true,
- string: true,
- date: false,
- dependsOnElement: ".accounts-header-discord"
- },
- "expiry": {
- name: window.lang.strings("expiry"),
- getter: "expiry",
- bool: true,
- string: false,
- date: true,
- dependsOnElement: ".accounts-header-expiry"
- },
- "last-active": {
- name: window.lang.strings("lastActiveTime"),
- getter: "last_active",
- bool: true,
- string: false,
- date: true
- },
- "referrals-enabled": {
- name: window.lang.strings("referrals"),
- getter: "referrals_enabled",
- bool: true,
- string: false,
- date: false,
- dependsOnElement: ".accounts-header-referrals"
- }
+ reload = (callback?: () => void) => {
+ this._reload(
+ (req: XMLHttpRequest) => {
+ if (req.readyState != 4) return;
+ if (req.status != 200) return;
+ if (callback) callback();
+ }
+ );
+ this.loadTemplates();
}
- private _notFoundPanel: HTMLElement = document.getElementById("accounts-not-found");
+ loadMore = (callback?: () => void, loadAll: boolean = false) => {
+ this._loadMore(
+ loadAll,
+ (req: XMLHttpRequest) => {
+ if (req.readyState != 4) return;
+ if (req.status != 200) return;
+ if (callback) callback();
+ }
+ );
+ };
get selectAll(): boolean { return this._selectAll.checked; }
set selectAll(state: boolean) {
let count = 0;
- for (let id in this._users) {
- if (this._table.contains(this._users[id].asElement())) { // Only select visible elements
- this._users[id].selected = state;
+ for (let id in this.users) {
+ if (this._table.contains(this.users[id].asElement())) { // Only select visible elements
+ this.users[id].selected = state;
count++;
}
}
this._selectAll.checked = state;
this._selectAll.indeterminate = false;
- state ? this._checkCount = count : 0;
+ state ? this._counter.selected = count : 0;
}
selectAllBetweenIDs = (startID: string, endID: string) => {
let inRange = false;
- for (let id of this._ordering) {
+ for (let id of this._search.ordering) {
if (!(inRange || id == startID)) continue;
inRange = true;
- if (!(this._table.contains(this._users[id].asElement()))) continue;
- this._users[id].selected = true;
+ if (!(this._table.contains(this.users[id].asElement()))) continue;
+ this.users[id].selected = true;
if (id == endID) return;
}
}
add = (u: User) => {
let domAccount = new user(u);
- this._users[u.id] = domAccount;
+ this.users[u.id] = domAccount;
+ // console.log("after appending lengths:", Object.keys(this.users).length, Object.keys(this._search.items).length);
}
private _checkCheckCount = () => {
const list = this._collectUsers();
- this._checkCount = list.length;
- if (this._checkCount == 0) {
+ this._counter.selected = list.length;
+ if (this._counter.selected == 0) {
this._selectAll.indeterminate = false;
this._selectAll.checked = false;
this._modifySettings.classList.add("unfocused");
@@ -971,12 +1294,12 @@ export class accountsList {
this._sendPWR.classList.add("unfocused");
} else {
let visibleCount = 0;
- for (let id in this._users) {
- if (this._table.contains(this._users[id].asElement())) {
+ for (let id in this.users) {
+ if (this._table.contains(this.users[id].asElement())) {
visibleCount++;
}
}
- if (this._checkCount == visibleCount) {
+ if (this._counter.selected == visibleCount) {
this._selectAll.checked = true;
this._selectAll.indeterminate = false;
} else {
@@ -995,27 +1318,27 @@ export class accountsList {
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[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[list[0]].disabled
let showDisableEnable = true;
for (let id of list) {
- if (!anyNonExpiries && !this._users[id].expiry) {
+ if (!anyNonExpiries && !this.users[id].expiry) {
anyNonExpiries = true;
this._expiryDropdown.classList.add("unfocused");
}
- if (this._users[id].expiry) {
+ if (this.users[id].expiry) {
allNonExpiries = false;
}
- if (showDisableEnable && this._users[id].disabled != this._shouldEnable) {
+ if (showDisableEnable && this.users[id].disabled != this._shouldEnable) {
showDisableEnable = false;
this._disableEnable.parentElement.classList.add("unfocused");
}
if (!showDisableEnable && anyNonExpiries) { break; }
- if (!this._users[id].lastNotifyMethod()) {
+ if (!this.users[id].lastNotifyMethod()) {
noContactCount++;
}
- if (window.referralsEnabled && referralState != -1 && Number(this._users[id].referrals_enabled) != referralState) {
+ if (window.referralsEnabled && referralState != -1 && Number(this.users[id].referrals_enabled) != referralState) {
referralState = -1;
}
}
@@ -1074,8 +1397,8 @@ export class accountsList {
private _collectUsers = (): string[] => {
let list: string[] = [];
- for (let id in this._users) {
- if (this._table.contains(this._users[id].asElement()) && this._users[id].selected) { list.push(id); }
+ for (let id in this.users) {
+ if (this._table.contains(this.users[id].asElement()) && this.users[id].selected) { list.push(id); }
}
return list;
}
@@ -1382,7 +1705,7 @@ export class accountsList {
let list = this._collectUsers();
let manualUser: user;
for (let id of list) {
- let user = this._users[id];
+ let user = this.users[id];
if (!user.lastNotifyMethod() && !user.email) {
manualUser = user;
break;
@@ -1448,8 +1771,8 @@ export class accountsList {
(() => {
let innerHTML = "";
- for (let id in this._users) {
- innerHTML += ``;
+ for (let id in this.users) {
+ innerHTML += ``;
}
this._userSelect.innerHTML = innerHTML;
})();
@@ -1513,7 +1836,7 @@ export class accountsList {
let list = this._collectUsers();
// Check if we're disabling or enabling
- if (this._users[list[0]].referrals_enabled) {
+ if (this.users[list[0]].referrals_enabled) {
_delete("/users/referral", {"users": list}, (req: XMLHttpRequest) => {
if (req.readyState != 4 || req.status != 200) return;
window.notifications.customSuccess("disabledReferralsSuccess", window.lang.quantity("appliedSettings", list.length));
@@ -1632,8 +1955,8 @@ export class accountsList {
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[id].expiry*1000);
+ if (this.users[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));
@@ -1740,12 +2063,13 @@ export class accountsList {
setVisibility = (users: string[], visible: boolean) => {
+ console.log(`setting ${users.length} users as visible: ${visible}`);
this._table.textContent = "";
- for (let id of this._ordering) {
+ for (let id of this._search.ordering) {
if (visible && users.indexOf(id) != -1) {
- this._table.appendChild(this._users[id].asElement());
+ this._table.appendChild(this.users[id].asElement());
} else if (!visible && users.indexOf(id) == -1) {
- this._table.appendChild(this._users[id].asElement());
+ this._table.appendChild(this.users[id].asElement());
}
}
}
@@ -1761,9 +2085,9 @@ export class accountsList {
focusAccount = (userID: string) => {
console.log("focusing user", userID);
- this._searchBox.value = `id:"${userID}"`;
+ this._c.searchBox.value = `id:"${userID}"`;
this._search.onSearchBoxChange();
- if (userID in this._users) this._users[userID].focus();
+ if (userID in this.users) this.users[userID].focus();
}
public static readonly _accountURLEvent = "account-url";
@@ -1771,7 +2095,6 @@ export class accountsList {
this.focusAccount(event.detail);
});
- // FIXME: Use Query Param! so it doesn't get cleared by pages.ts.
isAccountURL = () => {
const urlParams = new URLSearchParams(window.location.search);
const userID = urlParams.get("user");
@@ -1784,314 +2107,6 @@ export class accountsList {
this.focusAccount(userID);
}
- constructor() {
- this._populateNumbers();
-
- this._counter = new RecordCounter(document.getElementById("accounts-record-counter"));
-
- this._users = {};
- this._selectAll.checked = false;
- this._selectAll.onchange = () => {
- this.selectAll = this._selectAll.checked;
- };
- document.addEventListener("accounts-reload", () => this.reload());
- document.addEventListener("accountCheckEvent", () => { this._checkCount++; this._checkCheckCount(); });
- document.addEventListener("accountUncheckEvent", () => { this._checkCount--; this._checkCheckCount(); });
- this._addUserButton.onclick = () => {
- this._populateAddUserProfiles();
- window.modals.addUser.toggle();
- };
- this._addUserForm.addEventListener("submit", this._addUser);
-
- this._deleteNotify.onchange = () => {
- if (this._deleteNotify.checked) {
- this._deleteReason.classList.remove("unfocused");
- } else {
- this._deleteReason.classList.add("unfocused");
- }
- };
- this._modifySettings.onclick = this.modifyUsers;
- this._modifySettings.classList.add("unfocused");
-
- if (window.ombiEnabled)
- this._applyOmbi.parentElement.classList.remove("unfocused");
- else
- this._applyOmbi.parentElement.classList.add("unfocused");
- if (window.jellyseerrEnabled)
- this._applyJellyseerr.parentElement.classList.remove("unfocused");
- else
- this._applyJellyseerr.parentElement.classList.add("unfocused");
-
- const checkSource = () => {
- const profileSpan = this._modifySettingsProfile.nextElementSibling as HTMLSpanElement;
- const userSpan = this._modifySettingsUser.nextElementSibling as HTMLSpanElement;
- if (this._modifySettingsProfile.checked) {
- this._userSelect.parentElement.classList.add("unfocused");
- this._profileSelect.parentElement.classList.remove("unfocused")
- profileSpan.classList.add("@high");
- profileSpan.classList.remove("@low");
- userSpan.classList.remove("@high");
- userSpan.classList.add("@low");
- this._applyOmbi.parentElement.classList.remove("unfocused");
- this._applyJellyseerr.parentElement.classList.remove("unfocused");
- } else {
- this._userSelect.parentElement.classList.remove("unfocused");
- this._profileSelect.parentElement.classList.add("unfocused");
- userSpan.classList.add("@high");
- userSpan.classList.remove("@low");
- profileSpan.classList.remove("@high");
- profileSpan.classList.add("@low");
- this._applyOmbi.parentElement.classList.add("unfocused");
- this._applyJellyseerr.parentElement.classList.add("unfocused");
- }
- };
- this._modifySettingsProfile.onchange = checkSource;
- this._modifySettingsUser.onchange = checkSource;
-
- if (window.referralsEnabled) {
- const profileSpan = this._enableReferralsProfile.nextElementSibling as HTMLSpanElement;
- const inviteSpan = this._enableReferralsInvite.nextElementSibling as HTMLSpanElement;
- const checkReferralSource = () => {
- console.log("States:", this._enableReferralsProfile.checked, this._enableReferralsInvite.checked);
- if (this._enableReferralsProfile.checked) {
- this._referralsInviteSelect.parentElement.classList.add("unfocused");
- this._referralsProfileSelect.parentElement.classList.remove("unfocused")
- profileSpan.classList.add("@high");
- profileSpan.classList.remove("@low");
- inviteSpan.classList.remove("@high");
- inviteSpan.classList.add("@low");
- } else {
- this._referralsInviteSelect.parentElement.classList.remove("unfocused");
- this._referralsProfileSelect.parentElement.classList.add("unfocused");
- inviteSpan.classList.add("@high");
- inviteSpan.classList.remove("@low");
- profileSpan.classList.remove("@high");
- profileSpan.classList.add("@low");
- }
- };
- profileSpan.onclick = () => {
- this._enableReferralsProfile.checked = true;
- this._enableReferralsInvite.checked = false;
- checkReferralSource();
- };
- inviteSpan.onclick = () => {;
- this._enableReferralsInvite.checked = true;
- this._enableReferralsProfile.checked = false;
- checkReferralSource();
- };
- this._enableReferrals.onclick = () => {
- this.enableReferrals();
- profileSpan.onclick(null);
- };
- }
-
- this._deleteUser.onclick = this.deleteUsers;
- this._deleteUser.classList.add("unfocused");
-
- this._announceButton.onclick = this.announce;
- this._announceButton.parentElement.classList.add("unfocused");
-
- this._extendExpiry.onclick = () => { this.extendExpiry(); };
- this._removeExpiry.onclick = () => { this.removeExpiry(); };
- this._expiryDropdown.classList.add("unfocused");
- this._extendExpiryDate.classList.add("unfocused");
-
- this._extendExpiryTextInput.onkeyup = () => {
- this._extendExpiryTextInput.parentElement.parentElement.classList.remove("opacity-60");
- this._extendExpiryFieldInputs.classList.add("opacity-60");
- this._usingExtendExpiryTextInput = true;
- this._displayExpiryDate();
- }
-
- this._extendExpiryTextInput.onclick = () => {
- this._extendExpiryTextInput.parentElement.parentElement.classList.remove("opacity-60");
- this._extendExpiryFieldInputs.classList.add("opacity-60");
- this._usingExtendExpiryTextInput = true;
- this._displayExpiryDate();
- };
-
- this._extendExpiryFieldInputs.onclick = () => {
- this._extendExpiryFieldInputs.classList.remove("opacity-60");
- this._extendExpiryTextInput.parentElement.parentElement.classList.add("opacity-60");
- this._usingExtendExpiryTextInput = false;
- this._displayExpiryDate();
- };
-
- for (let field of ["months", "days", "hours", "minutes"]) {
- (document.getElementById("extend-expiry-"+field) as HTMLSelectElement).onchange = () => {
- this._extendExpiryFieldInputs.classList.remove("opacity-60");
- this._extendExpiryTextInput.parentElement.parentElement.classList.add("opacity-60");
- this._usingExtendExpiryTextInput = false;
- this._displayExpiryDate();
- };
- }
-
- this._disableEnable.onclick = this.enableDisableUsers;
- this._disableEnable.parentElement.classList.add("unfocused");
-
- this._enableExpiry.onclick = () => { this.extendExpiry(true); };
- this._enableExpiryNotify.onchange = () => {
- if (this._enableExpiryNotify.checked) {
- this._enableExpiryReason.classList.remove("unfocused");
- } else {
- this._enableExpiryReason.classList.add("unfocused");
- }
- };
-
- if (!window.usernameEnabled) {
- this._addUserName.classList.add("unfocused");
- this._addUserName = this._addUserEmail;
- }
-
- if (!window.linkResetEnabled) {
- this._sendPWR.classList.add("unfocused");
- } else {
- this._sendPWR.onclick = this.sendPWR;
- }
- /*if (!window.emailEnabled) {
- this._deleteNotify.parentElement.classList.add("unfocused");
- this._deleteNotify.checked = false;
- }*/
-
- let conf: SearchConfiguration = {
- filterArea: this._filterArea,
- sortingByButton: this._sortingByButton,
- searchOptionsHeader: this._searchOptionsHeader,
- notFoundPanel: this._notFoundPanel,
- filterList: document.getElementById("accounts-filter-list"),
- search: this._searchBox,
- queries: this._queries,
- setVisibility: this.setVisibility,
- clearSearchButtonSelector: ".accounts-search-clear",
- onSearchCallback: (_0: number, _1: boolean, _2: boolean) => {
- this._checkCheckCount();
- }
- };
- this._search = new Search(conf);
- this._search.items = this._users;
-
-
- this._announceTextarea.onkeyup = this.loadPreview;
- addDiscord = newDiscordSearch(window.lang.strings("linkDiscord"), window.lang.strings("searchDiscordUser"), window.lang.strings("add"), (user: DiscordUser, id: string) => {
- _post("/users/discord", {jf_id: id, discord_id: user.id}, (req: XMLHttpRequest) => {
- if (req.readyState == 4) {
- document.dispatchEvent(new CustomEvent("accounts-reload"));
- if (req.status != 200) {
- window.notifications.customError("errorConnectDiscord", window.lang.notif("errorFailureCheckLogs"));
- return
- }
- window.notifications.customSuccess("discordConnected", window.lang.notif("accountConnected"));
- window.modals.discord.close()
- }
- });
- });
-
- this._announceSaveButton.onclick = this.saveAnnouncement;
- const announceVarUsername = document.getElementById("announce-variables-username") as HTMLSpanElement;
- announceVarUsername.onclick = () => {
- insertText(this._announceTextarea, announceVarUsername.children[0].textContent);
- this.loadPreview();
- };
-
- const headerNames: string[] = ["username", "access-jfa", "email", "telegram", "matrix", "discord", "expiry", "last-active", "referrals"];
- const headerGetters: string[] = ["name", "accounts_admin", "email", "telegram", "matrix", "discord", "expiry", "last_active", "referrals_enabled"];
- for (let i = 0; i < headerNames.length; i++) {
- const header: HTMLTableHeaderCellElement = document.querySelector(".accounts-header-" + headerNames[i]) as HTMLTableHeaderCellElement;
- if (header !== null) {
- this._columns[header.className] = new Column(header, Object.getOwnPropertyDescriptor(user.prototype, headerGetters[i]).get);
- }
- }
-
- // Start off sorting by Name
- const defaultSort = () => {
- this._activeSortColumn = document.getElementsByClassName("accounts-header-" + headerNames[0])[0].className;
- document.dispatchEvent(new CustomEvent("header-click", { detail: this._activeSortColumn }));
- this._columns[this._activeSortColumn].ascending = true;
- this._columns[this._activeSortColumn].hideIcon();
- this._sortingByButton.parentElement.classList.add("hidden");
- this.showHideSearchOptionsHeader();
- };
-
- this._sortingByButton.parentElement.addEventListener("click", defaultSort);
-
- document.addEventListener("header-click", (event: CustomEvent) => {
- this._ordering = this._columns[event.detail].sort(this._users);
- this._search.ordering = this._ordering;
- this._activeSortColumn = event.detail;
- this._sortingByButton.innerHTML = this._columns[event.detail].buttonContent;
- this._sortingByButton.parentElement.classList.remove("hidden");
- // console.log("ordering by", event.detail, ": ", this._ordering);
- if (!(this._search.inSearch)) {
- this.setVisibility(this._ordering, true);
- this._notFoundPanel.classList.add("unfocused");
- } else {
- const results = this._search.search(this._searchBox.value);
- this.setVisibility(results, true);
- if (results.length == 0) {
- this._notFoundPanel.classList.remove("unfocused");
- } else {
- this._notFoundPanel.classList.add("unfocused");
- }
- }
- this.showHideSearchOptionsHeader();
- });
-
- defaultSort();
- this.showHideSearchOptionsHeader();
-
- this._search.generateFilterList();
-
- this.registerURLListener();
- }
-
- reload = (callback?: () => void) => {
- this._counter.reset()
- this._counter.getTotal("/users/count");
-
- _get("/users", null, (req: XMLHttpRequest) => {
- if (req.readyState == 4 && req.status == 200) {
- let resp = req.response as UsersDTO;
- // same method as inviteList.reload()
- let accountsOnDOM: { [id: string]: boolean } = {};
- for (let id in this._users) { accountsOnDOM[id] = true; }
- for (let u of resp.users) {
- if (u.id in this._users) {
- this._users[u.id].update(u);
- delete accountsOnDOM[u.id];
- } else {
- this.add(u);
- }
- }
- for (let id in accountsOnDOM) {
- this._users[id].remove();
- delete this._users[id];
- }
- // console.log("reload, so sorting by", this._activeSortColumn);
- this._ordering = this._columns[this._activeSortColumn].sort(this._users);
- this._search.ordering = this._ordering;
-
- this._counter.loaded = this._ordering.length;
-
- if (this._search.inSearch) {
- const results = this._search.search(this._searchBox.value);
- if (results.length == 0) {
- this._notFoundPanel.classList.remove("unfocused");
- } else {
- this._notFoundPanel.classList.add("unfocused");
- }
- this.setVisibility(results, true);
- } else {
- this._counter.shown = this._counter.loaded;
- this.setVisibility(this._ordering, true);
- this._notFoundPanel.classList.add("unfocused");
- }
- this._checkCheckCount();
-
- if (callback) callback();
- }
- });
- this.loadTemplates();
- }
}
export const accountURLEvent = (id: string) => { return new CustomEvent(accountsList._accountURLEvent, {"detail": id}) };
@@ -2104,13 +2119,15 @@ type Getter = () => GetterReturnType;
// Listen for broadcast event from others, check its not us by comparing the header className in the message, then hide the arrow icon
class Column {
private _header: HTMLTableHeaderCellElement;
+ private _name: string;
private _headerContent: string;
private _getter: Getter;
private _ascending: boolean;
private _active: boolean;
- constructor(header: HTMLTableHeaderCellElement, getter: Getter) {
+ constructor(header: HTMLTableHeaderCellElement, name: string, getter: Getter) {
this._header = header;
+ this._name = name;
this._headerContent = this._header.textContent;
this._getter = getter;
this._ascending = true;
@@ -2127,10 +2144,10 @@ class Column {
this._active = true;
this._header.setAttribute("aria-sort", this._headerContent);
this.updateHeader();
- document.dispatchEvent(new CustomEvent("header-click", { detail: this._header.className }));
+ document.dispatchEvent(new CustomEvent("header-click", { detail: this._name }));
});
document.addEventListener("header-click", (event: CustomEvent) => {
- if (event.detail != this._header.className) {
+ if (event.detail != this._name) {
this._active = false;
this._header.removeAttribute("aria-sort");
this.hideIcon();
@@ -2160,15 +2177,17 @@ class Column {
if (!this._active) return;
this.updateHeader();
this._header.setAttribute("aria-sort", this._headerContent);
- document.dispatchEvent(new CustomEvent("header-click", { detail: this._header.className }));
+ document.dispatchEvent(new CustomEvent("header-click", { detail: this._name }));
}
// 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);
userIDs.sort((a: string, b: string): number => {
- const av: GetterReturnType = this._getter.call(users[a]);
- const bv: GetterReturnType = this._getter.call(users[b]);
+ let av: GetterReturnType = this._getter.call(users[a]);
+ if (typeof av === "string") av = av.toLowerCase();
+ let bv: GetterReturnType = this._getter.call(users[b]);
+ if (typeof bv === "string") bv = bv.toLowerCase();
if (av < bv) return this._ascending ? -1 : 1;
if (av > bv) return this._ascending ? 1 : -1;
return 0;
diff --git a/ts/modules/activity.ts b/ts/modules/activity.ts
index 59ed865..5780962 100644
--- a/ts/modules/activity.ts
+++ b/ts/modules/activity.ts
@@ -1,7 +1,8 @@
-import { _get, _post, _delete, toDateString, addLoader, removeLoader } from "../modules/common.js";
-import { Search, SearchConfiguration, QueryType, SearchableItem } from "../modules/search.js";
+import { _get, _post, _delete, toDateString } from "../modules/common.js";
+import { SearchConfiguration, QueryType, SearchableItem, SearchableItems } from "../modules/search.js";
import { accountURLEvent } from "../modules/accounts.js";
import { inviteURLEvent } from "../modules/invites.js";
+import { PaginatedList } from "./list.js";
declare var window: GlobalWindow;
@@ -32,6 +33,123 @@ var activityTypeMoods = {
"deleteInvite": -1
};
+// window.lang doesn't exist at page load, so I made this a function that's invoked by activityList.
+const queries = (): { [field: string]: QueryType } => { return {
+ "id": {
+ name: window.lang.strings("activityID"),
+ getter: "id",
+ bool: false,
+ string: true,
+ date: false
+ },
+ "title": {
+ name: window.lang.strings("title"),
+ getter: "title",
+ bool: false,
+ string: true,
+ date: false
+ },
+ "user": {
+ name: window.lang.strings("usersMentioned"),
+ getter: "mentionedUsers",
+ bool: false,
+ string: true,
+ date: false
+ },
+ "actor": {
+ name: window.lang.strings("actor"),
+ description: window.lang.strings("actorDescription"),
+ getter: "actor",
+ bool: false,
+ string: true,
+ date: false
+ },
+ "referrer": {
+ name: window.lang.strings("referrer"),
+ getter: "referrer",
+ bool: true,
+ string: true,
+ date: false
+ },
+ "date": {
+ name: window.lang.strings("date"),
+ getter: "date",
+ bool: false,
+ string: false,
+ date: true
+ },
+ "account-creation": {
+ name: window.lang.strings("accountCreationFilter"),
+ getter: "accountCreation",
+ bool: true,
+ string: false,
+ date: false
+ },
+ "account-deletion": {
+ name: window.lang.strings("accountDeletionFilter"),
+ getter: "accountDeletion",
+ bool: true,
+ string: false,
+ date: false
+ },
+ "account-disabled": {
+ name: window.lang.strings("accountDisabledFilter"),
+ getter: "accountDisabled",
+ bool: true,
+ string: false,
+ date: false
+ },
+ "account-enabled": {
+ name: window.lang.strings("accountEnabledFilter"),
+ getter: "accountEnabled",
+ bool: true,
+ string: false,
+ date: false
+ },
+ "contact-linked": {
+ name: window.lang.strings("contactLinkedFilter"),
+ getter: "contactLinked",
+ bool: true,
+ string: false,
+ date: false
+ },
+ "contact-unlinked": {
+ name: window.lang.strings("contactUnlinkedFilter"),
+ getter: "contactUnlinked",
+ bool: true,
+ string: false,
+ date: false
+ },
+ "password-change": {
+ name: window.lang.strings("passwordChangeFilter"),
+ getter: "passwordChange",
+ bool: true,
+ string: false,
+ date: false
+ },
+ "password-reset": {
+ name: window.lang.strings("passwordResetFilter"),
+ getter: "passwordReset",
+ bool: true,
+ string: false,
+ date: false
+ },
+ "invite-created": {
+ name: window.lang.strings("inviteCreatedFilter"),
+ getter: "inviteCreated",
+ bool: true,
+ string: false,
+ date: false
+ },
+ "invite-deleted": {
+ name: window.lang.strings("inviteDeletedFilter"),
+ getter: "inviteDeleted",
+ bool: true,
+ string: false,
+ date: false
+ }
+}};
+
// var moodColours = ["~warning", "~neutral", "~urge"];
export var activityReload = new CustomEvent("activity-reload");
@@ -346,369 +464,130 @@ export class Activity implements activity, SearchableItem {
asElement = () => { return this._card; };
}
-export class RecordCounter {
- private _container: HTMLElement;
- private _totalRecords: HTMLElement;
- private _loadedRecords: HTMLElement;
- private _shownRecords: HTMLElement;
- private _total: number;
- private _loaded: number;
- private _shown: number;
- constructor(container: HTMLElement) {
- this._container = container;
- this._container.innerHTML = `
-
-
-
- `;
- this._totalRecords = document.getElementsByClassName("records-total")[0] as HTMLElement;
- this._loadedRecords = document.getElementsByClassName("records-loaded")[0] as HTMLElement;
- this._shownRecords = document.getElementsByClassName("records-shown")[0] as HTMLElement;
- this.total = 0;
- this.loaded = 0;
- this.shown = 0;
- }
-
- reset() {
- this.total = 0;
- this.loaded = 0;
- this.shown = 0;
- }
-
- // Sets the total using a PageCountDTO-returning API endpoint.
- getTotal(endpoint: string) {
- _get(endpoint, null, (req: XMLHttpRequest) => {
- if (req.readyState != 4 || req.status != 200) return;
- this.total = req.response["count"] as number;
- });
- }
-
- get total(): number { return this._total; }
- set total(v: number) {
- this._total = v;
- this._totalRecords.textContent = window.lang.var("strings", "totalRecords", `${v}`);
- }
-
- get loaded(): number { return this._loaded; }
- set loaded(v: number) {
- this._loaded = v;
- this._loadedRecords.textContent = window.lang.var("strings", "loadedRecords", `${v}`);
- }
-
- get shown(): number { return this._shown; }
- set shown(v: number) {
- this._shown = v;
- this._shownRecords.textContent = window.lang.var("strings", "shownRecords", `${v}`);
- }
-}
+interface ActivitiesReqDTO extends PaginatedReqDTO {
+ type: string[];
+};
interface ActivitiesDTO extends paginatedDTO {
activities: activity[];
}
-export class activityList {
- private _activityList: HTMLElement;
- private _activities: { [id: string]: Activity } = {};
- private _ordering: string[] = [];
- private _filterArea = document.getElementById("activity-filter-area");
- private _searchOptionsHeader = document.getElementById("activity-search-options-header");
- private _sortingByButton = document.getElementById("activity-sort-by-field") as HTMLButtonElement;
- private _notFoundPanel = document.getElementById("activity-not-found");
- private _searchBox = document.getElementById("activity-search") as HTMLInputElement;
- private _sortDirection = document.getElementById("activity-sort-direction") as HTMLButtonElement;
- private _loader = document.getElementById("activity-loader");
- private _loadMoreButton = document.getElementById("activity-load-more") as HTMLButtonElement;
- private _loadAllButton = document.getElementById("activity-load-all") as HTMLButtonElement;
- private _refreshButton = document.getElementById("activity-refresh") as HTMLButtonElement;
- private _keepSearchingDescription = document.getElementById("activity-keep-searching-description");
- private _keepSearchingButton = document.getElementById("activity-keep-searching");
+export class activityList extends PaginatedList {
+ protected _activityList: HTMLElement;
+ // protected _sortingByButton = document.getElementById("activity-sort-by-field") as HTMLButtonElement;
+ protected _sortDirection = document.getElementById("activity-sort-direction") as HTMLButtonElement;
- private _counter: RecordCounter;
+ protected _ascending: boolean;
+
+ get activities(): { [id: string]: Activity } { return this._search.items as { [id: string]: Activity }; }
+ // set activities(v: { [id: string]: Activity }) { this._search.items = v as SearchableItems; }
+
+ constructor() {
+ super({
+ loader: document.getElementById("activity-loader"),
+ loadMoreButton: document.getElementById("activity-load-more") as HTMLButtonElement,
+ loadAllButton: document.getElementById("activity-load-all") as HTMLButtonElement,
+ refreshButton: document.getElementById("activity-refresh") as HTMLButtonElement,
+ keepSearchingDescription: document.getElementById("activity-keep-searching-description"),
+ keepSearchingButton: document.getElementById("activity-keep-searching"),
+ filterArea: document.getElementById("activity-filter-area"),
+ searchOptionsHeader: document.getElementById("activity-search-options-header"),
+ searchBox: document.getElementById("activity-search") as HTMLInputElement,
+ notFoundPanel: document.getElementById("activity-not-found"),
+ recordCounter: document.getElementById("activity-record-counter"),
+ totalEndpoint: "/activity/count",
+ getPageEndpoint: "/activity",
+ limit: 10,
+ newElementsFromPage: (resp: paginatedDTO) => {
+ let ordering: string[] = this._search.ordering;
+ for (let act of ((resp as ActivitiesDTO).activities || [])) {
+ this.activities[act.id] = new Activity(act);
+ ordering.push(act.id);
+ }
+ },
+ updateExistingElementsFromPage: (resp: paginatedDTO) => {
+ // FIXME: Implement updates to existing elements!
+ for (let id of Object.keys(this.activities)) {
+ delete this.activities[id];
+ }
+ this._search.setOrdering([], this._c.defaultSortField, this.ascending);
+ this._c.newElementsFromPage(resp);
+ },
+ defaultSortField: "time",
+ pageLoadCallback: (req: XMLHttpRequest) => {
+ if (req.readyState != 4) return;
+ if (req.status != 200) {
+ window.notifications.customError("loadActivitiesError", window.lang.notif("errorLoadActivities"));
+ return;
+ }
+ }
+ });
- private _search: Search;
- private _ascending: boolean;
- private _hasLoaded: boolean;
- private _lastLoad: number;
- private _page: number = 0;
- private _lastPage: boolean;
+ this._activityList = document.getElementById("activity-card-list")
+ document.addEventListener("activity-reload", this.reload);
+ let searchConfig: SearchConfiguration = {
+ filterArea: this._c.filterArea,
+ // Exclude this: We only sort by date, and don't want to show a redundant header indicating so.
+ // sortingByButton: this._sortingByButton,
+ searchOptionsHeader: this._c.searchOptionsHeader,
+ notFoundPanel: this._c.notFoundPanel,
+ search: this._c.searchBox,
+ clearSearchButtonSelector: ".activity-search-clear",
+ serverSearchButtonSelector: ".activity-search-server",
+ queries: queries(),
+ setVisibility: this.setVisibility,
+ filterList: document.getElementById("activity-filter-list"),
+ // notFoundCallback: this._notFoundCallback,
+ onSearchCallback: null,
+ searchServer: null,
+ clearServerSearch: null,
+ }
+
+ this.initSearch(searchConfig);
+
+ this.ascending = false;
+ this._sortDirection.addEventListener("click", () => this.ascending = !this.ascending);
+ }
setVisibility = (activities: string[], visible: boolean) => {
this._activityList.textContent = ``;
- for (let id of this._ordering) {
+ for (let id of this._search.ordering) {
if (visible && activities.indexOf(id) != -1) {
- this._activityList.appendChild(this._activities[id].asElement());
+ this._activityList.appendChild(this.activities[id].asElement());
} else if (!visible && activities.indexOf(id) == -1) {
- this._activityList.appendChild(this._activities[id].asElement());
+ this._activityList.appendChild(this.activities[id].asElement());
}
}
}
reload = () => {
- this._lastLoad = Date.now();
- this._lastPage = false;
- this._loadMoreButton.textContent = window.lang.strings("loadMore");
- this._loadMoreButton.disabled = false;
- this._loadAllButton.classList.remove("unfocused");
- this._loadAllButton.disabled = false;
-
- this._counter.reset();
- this._counter.getTotal("/activity/count");
-
- // this._page = 0;
- let limit = 10;
- if (this._page != 0) {
- limit *= this._page+1;
- };
-
- let send = {
- "type": [],
- "limit": limit,
- "page": 0,
- "ascending": this.ascending
- }
-
-
- _post("/activity", send, (req: XMLHttpRequest) => {
- if (req.readyState != 4) return;
- if (req.status != 200) {
- window.notifications.customError("loadActivitiesError", window.lang.notif("errorLoadActivities"));
- return;
- }
-
- this._hasLoaded = true;
- // Allow refreshes every 15s
- this._refreshButton.disabled = true;
- setTimeout(() => this._refreshButton.disabled = false, 15000);
-
- let resp = req.response as ActivitiesDTO;
- // FIXME: Don't destroy everything each reload!
- this._activities = {};
- this._ordering = [];
-
- for (let act of resp.activities) {
- this._activities[act.id] = new Activity(act);
- this._ordering.push(act.id);
- }
- this._search.items = this._activities;
- this._search.ordering = this._ordering;
-
- this._counter.loaded = this._ordering.length;
-
- if (this._search.inSearch) {
- this._search.onSearchBoxChange(true);
- this._loadAllButton.classList.remove("unfocused");
- } else {
- this._counter.shown = this._counter.loaded;
- this.setVisibility(this._ordering, true);
- this._loadAllButton.classList.add("unfocused");
- this._notFoundPanel.classList.add("unfocused");
- }
- }, true);
+ this._reload();
}
loadMore = (callback?: () => void, loadAll: boolean = false) => {
- this._lastLoad = Date.now();
- this._loadMoreButton.disabled = true;
- // this._loadAllButton.disabled = true;
- const timeout = setTimeout(() => {
- this._loadMoreButton.disabled = false;
- // this._loadAllButton.disabled = false;
- }, 1000);
- this._page += 1;
-
- let send = {
- "type": [],
- "limit": 10,
- "page": this._page,
- "ascending": this._ascending
- };
-
- // this._activityList.classList.add("unfocused");
- // addLoader(this._loader, false, true);
-
- _post("/activity", send, (req: XMLHttpRequest) => {
- if (req.readyState != 4) return;
- if (req.status != 200) {
- window.notifications.customError("loadActivitiesError", window.lang.notif("errorLoadActivities"));
- return;
+ this._loadMore(
+ loadAll,
+ (req: XMLHttpRequest) => {
+ if (req.readyState != 4) return;
+ if (req.status != 200) return;
+ if (callback) callback();
}
-
- let resp = req.response as ActivitiesDTO;
-
- this._lastPage = resp.last_page;
- if (this._lastPage) {
- clearTimeout(timeout);
- this._loadMoreButton.disabled = true;
- removeLoader(this._loadAllButton);
- this._loadAllButton.classList.add("unfocused");
- this._loadMoreButton.textContent = window.lang.strings("noMoreResults");
- }
-
- for (let act of resp.activities) {
- this._activities[act.id] = new Activity(act);
- this._ordering.push(act.id);
- }
- // this._search.items = this._activities;
- // this._search.ordering = this._ordering;
-
- this._counter.loaded = this._ordering.length;
-
- if (this._search.inSearch || loadAll) {
- if (this._lastPage) {
- loadAll = false;
- }
- this._search.onSearchBoxChange(true, loadAll);
- } else {
- this.setVisibility(this._ordering, true);
- this._notFoundPanel.classList.add("unfocused");
- }
-
- if (callback) callback();
- // removeLoader(this._loader);
- // this._activityList.classList.remove("unfocused");
- }, true);
+ );
}
- private _queries: { [field: string]: QueryType } = {
- "id": {
- name: window.lang.strings("activityID"),
- getter: "id",
- bool: false,
- string: true,
- date: false
- },
- "title": {
- name: window.lang.strings("title"),
- getter: "title",
- bool: false,
- string: true,
- date: false
- },
- "user": {
- name: window.lang.strings("usersMentioned"),
- getter: "mentionedUsers",
- bool: false,
- string: true,
- date: false
- },
- "actor": {
- name: window.lang.strings("actor"),
- description: window.lang.strings("actorDescription"),
- getter: "actor",
- bool: false,
- string: true,
- date: false
- },
- "referrer": {
- name: window.lang.strings("referrer"),
- getter: "referrer",
- bool: true,
- string: true,
- date: false
- },
- "date": {
- name: window.lang.strings("date"),
- getter: "date",
- bool: false,
- string: false,
- date: true
- },
- "account-creation": {
- name: window.lang.strings("accountCreationFilter"),
- getter: "accountCreation",
- bool: true,
- string: false,
- date: false
- },
- "account-deletion": {
- name: window.lang.strings("accountDeletionFilter"),
- getter: "accountDeletion",
- bool: true,
- string: false,
- date: false
- },
- "account-disabled": {
- name: window.lang.strings("accountDisabledFilter"),
- getter: "accountDisabled",
- bool: true,
- string: false,
- date: false
- },
- "account-enabled": {
- name: window.lang.strings("accountEnabledFilter"),
- getter: "accountEnabled",
- bool: true,
- string: false,
- date: false
- },
- "contact-linked": {
- name: window.lang.strings("contactLinkedFilter"),
- getter: "contactLinked",
- bool: true,
- string: false,
- date: false
- },
- "contact-unlinked": {
- name: window.lang.strings("contactUnlinkedFilter"),
- getter: "contactUnlinked",
- bool: true,
- string: false,
- date: false
- },
- "password-change": {
- name: window.lang.strings("passwordChangeFilter"),
- getter: "passwordChange",
- bool: true,
- string: false,
- date: false
- },
- "password-reset": {
- name: window.lang.strings("passwordResetFilter"),
- getter: "passwordReset",
- bool: true,
- string: false,
- date: false
- },
- "invite-created": {
- name: window.lang.strings("inviteCreatedFilter"),
- getter: "inviteCreated",
- bool: true,
- string: false,
- date: false
- },
- "invite-deleted": {
- name: window.lang.strings("inviteDeletedFilter"),
- getter: "inviteDeleted",
- bool: true,
- string: false,
- date: false
- }
- };
-
get ascending(): boolean { return this._ascending; }
set ascending(v: boolean) {
this._ascending = v;
this._sortDirection.innerHTML = `${window.lang.strings("sortDirection")} `;
+ // FIXME?: We don't actually re-sort the list here, instead just use setOrdering to apply this.ascending before a reload.
+ this._search.setOrdering(this._search.ordering, this._c.defaultSortField, this.ascending);
if (this._hasLoaded) {
this.reload();
}
}
- detectScroll = () => {
- if (!this._hasLoaded) return;
- // console.log(window.innerHeight + document.documentElement.scrollTop, document.scrollingElement.scrollHeight);
- if (Math.abs(window.innerHeight + document.documentElement.scrollTop - document.scrollingElement.scrollHeight) < 50) {
- // window.notifications.customSuccess("scroll", "Reached bottom.");
- // Wait .5s between loads
- if (this._lastLoad + 500 > Date.now()) return;
- this.loadMore();
- }
- }
-
- private _prevResultCount = 0;
-
- private _notFoundCallback = (notFound: boolean) => {
+ /*private _notFoundCallback = (notFound: boolean) => {
if (notFound) this._loadMoreButton.classList.add("unfocused");
else this._loadMoreButton.classList.remove("unfocused");
@@ -719,55 +598,6 @@ export class activityList {
this._keepSearchingButton.classList.add("unfocused");
this._keepSearchingDescription.classList.add("unfocused");
}
- };
+ };*/
- constructor() {
- this._activityList = document.getElementById("activity-card-list");
- document.addEventListener("activity-reload", this.reload);
-
- this._counter = new RecordCounter(document.getElementById("activity-record-counter"));
-
- let conf: SearchConfiguration = {
- filterArea: this._filterArea,
- sortingByButton: this._sortingByButton,
- searchOptionsHeader: this._searchOptionsHeader,
- notFoundPanel: this._notFoundPanel,
- search: this._searchBox,
- clearSearchButtonSelector: ".activity-search-clear",
- queries: this._queries,
- setVisibility: this.setVisibility,
- filterList: document.getElementById("activity-filter-list"),
- // notFoundCallback: this._notFoundCallback,
- onSearchCallback: (visibleCount: number, newItems: boolean, loadAll: boolean) => {
- this._counter.shown = visibleCount;
-
- if (this._search.inSearch && !this._lastPage) this._loadAllButton.classList.remove("unfocused");
- else this._loadAllButton.classList.add("unfocused");
-
- if (visibleCount < 10 || loadAll) {
- if (!newItems || this._prevResultCount != visibleCount || (visibleCount == 0 && !this._lastPage) || loadAll) this.loadMore(() => {}, loadAll);
- }
- this._prevResultCount = visibleCount;
- }
- }
- this._search = new Search(conf);
- this._search.generateFilterList();
-
- this._hasLoaded = false;
- this.ascending = false;
- this._sortDirection.addEventListener("click", () => this.ascending = !this.ascending);
-
- this._loadMoreButton.onclick = () => this.loadMore();
- this._loadAllButton.onclick = () => {
- addLoader(this._loadAllButton, true);
- this.loadMore(() => {}, true);
- };
- /* this._keepSearchingButton.onclick = () => {
- addLoader(this._keepSearchingButton, true);
- this.loadMore(() => removeLoader(this._keepSearchingButton, true));
- }; */
- this._refreshButton.onclick = this.reload;
-
- window.onscroll = this.detectScroll;
- }
}
diff --git a/ts/modules/list.ts b/ts/modules/list.ts
new file mode 100644
index 0000000..0978317
--- /dev/null
+++ b/ts/modules/list.ts
@@ -0,0 +1,328 @@
+import { _get, _post, addLoader, removeLoader } from "./common";
+import { Search, SearchConfiguration, SearchableItems } from "./search";
+
+declare var window: GlobalWindow;
+
+export class RecordCounter {
+ private _container: HTMLElement;
+ private _totalRecords: HTMLElement;
+ private _loadedRecords: HTMLElement;
+ private _shownRecords: HTMLElement;
+ private _selectedRecords: HTMLElement;
+ private _total: number;
+ private _loaded: number;
+ private _shown: number;
+ private _selected: number;
+ constructor(container: HTMLElement) {
+ this._container = container;
+ this._container.innerHTML = `
+
+
+
+
+ `;
+ this._totalRecords = document.getElementsByClassName("records-total")[0] as HTMLElement;
+ this._loadedRecords = document.getElementsByClassName("records-loaded")[0] as HTMLElement;
+ this._shownRecords = document.getElementsByClassName("records-shown")[0] as HTMLElement;
+ this._selectedRecords = document.getElementsByClassName("records-selected")[0] as HTMLElement;
+ this.total = 0;
+ this.loaded = 0;
+ this.shown = 0;
+ }
+
+ reset() {
+ this.total = 0;
+ this.loaded = 0;
+ this.shown = 0;
+ this.selected = 0;
+ }
+
+ // Sets the total using a PageCountDTO-returning API endpoint.
+ getTotal(endpoint: string) {
+ _get(endpoint, null, (req: XMLHttpRequest) => {
+ if (req.readyState != 4 || req.status != 200) return;
+ this.total = req.response["count"] as number;
+ });
+ }
+
+ get total(): number { return this._total; }
+ set total(v: number) {
+ this._total = v;
+ this._totalRecords.textContent = window.lang.var("strings", "totalRecords", `${v}`);
+ }
+
+ get loaded(): number { return this._loaded; }
+ set loaded(v: number) {
+ this._loaded = v;
+ this._loadedRecords.textContent = window.lang.var("strings", "loadedRecords", `${v}`);
+ }
+
+ get shown(): number { return this._shown; }
+ set shown(v: number) {
+ this._shown = v;
+ this._shownRecords.textContent = window.lang.var("strings", "shownRecords", `${v}`);
+ }
+
+ get selected(): number { return this._selected; }
+ set selected(v: number) {
+ this._selected = v;
+ if (v == 0) this._selectedRecords.textContent = ``;
+ else this._selectedRecords.textContent = window.lang.var("strings", "selectedRecords", `${v}`);
+ }
+}
+
+export interface PaginatedListConfig {
+ loader: HTMLElement;
+ loadMoreButton: HTMLButtonElement;
+ loadAllButton: HTMLButtonElement;
+ refreshButton: HTMLButtonElement;
+ keepSearchingDescription: HTMLElement;
+ keepSearchingButton: HTMLElement;
+ notFoundPanel: HTMLElement;
+ filterArea: HTMLElement;
+ searchOptionsHeader: HTMLElement;
+ searchBox: HTMLInputElement;
+ recordCounter: HTMLElement;
+ totalEndpoint: string;
+ getPageEndpoint: string;
+ limit: number;
+ newElementsFromPage: (resp: paginatedDTO) => void;
+ updateExistingElementsFromPage: (resp: paginatedDTO) => void;
+ defaultSortField: string;
+ pageLoadCallback?: (req: XMLHttpRequest) => void;
+}
+
+export abstract class PaginatedList {
+ protected _c: PaginatedListConfig;
+
+ protected _search: Search;
+
+ protected _counter: RecordCounter;
+
+ protected _hasLoaded: boolean;
+ protected _lastLoad: number;
+ protected _page: number = 0;
+ protected _lastPage: boolean;
+ get lastPage(): boolean { return this._lastPage };
+ set lastPage(v: boolean) {
+ this._lastPage = v;
+ if (v) {
+ this._c.loadAllButton.classList.add("unfocused");
+ this._c.loadMoreButton.textContent = window.lang.strings("noMoreResults");
+ this._c.loadMoreButton.disabled = true;
+ } else {
+ this._c.loadMoreButton.textContent = window.lang.strings("loadMore");
+ this._c.loadMoreButton.disabled = false;
+ if (this._search.inSearch) {
+ this._c.loadAllButton.classList.remove("unfocused");
+ }
+ }
+ }
+
+ protected _previousPageSize = 0;
+
+ // Stores a PaginatedReqDTO-implementing thing.
+ // A standard PaginatedReqDTO will be overridden entirely,
+ // but a ServerSearchDTO will keep it's fields.
+ protected _searchParams: PaginatedReqDTO;
+ defaultParams = (): PaginatedReqDTO => {
+ return {
+ limit: 0,
+ page: 0,
+ sortByField: "",
+ ascending: false
+ };
+ }
+
+ constructor(c: PaginatedListConfig) {
+ this._c = c;
+ this._counter = new RecordCounter(this._c.recordCounter);
+ this._hasLoaded = false;
+
+ this._c.loadMoreButton.onclick = () => this.loadMore(null, false);
+ this._c.loadAllButton.onclick = () => {
+ addLoader(this._c.loadAllButton, true);
+ this.loadMore(null, true);
+ };
+ /* this._keepSearchingButton.onclick = () => {
+ addLoader(this._keepSearchingButton, true);
+ this.loadMore(() => removeLoader(this._keepSearchingButton, true));
+ }; */
+ // Since this.reload doesn't exist, we need an arrow function to wrap it.
+ // FIXME: Make sure it works though!
+ this._c.refreshButton.onclick = () => this.reload();
+ }
+
+ initSearch = (searchConfig: SearchConfiguration) => {
+ const previousCallback = searchConfig.onSearchCallback;
+ searchConfig.onSearchCallback = (visibleCount: number, newItems: boolean, loadAll: boolean) => {
+ this._counter.shown = visibleCount;
+
+ // if (this._search.inSearch && !this.lastPage) this._c.loadAllButton.classList.remove("unfocused");
+ // else this._c.loadAllButton.classList.add("unfocused");
+
+ // FIXME: Figure out why this makes sense and make it clearer.
+ if ((visibleCount < this._c.limit && !this.lastPage) || loadAll) {
+ if (!newItems ||
+ this._previousPageSize != visibleCount ||
+ (visibleCount == 0 && !this.lastPage) ||
+ loadAll
+ ) {
+ this.loadMore(() => {}, loadAll);
+ }
+ }
+ this._previousPageSize = visibleCount;
+ if (previousCallback) previousCallback(visibleCount, newItems, loadAll);
+ };
+ const previousServerSearch = searchConfig.searchServer;
+ searchConfig.searchServer = (params: PaginatedReqDTO, newSearch: boolean) => {
+ this._searchParams = params;
+ if (newSearch) this.reload();
+ else this.loadMore(null, false);
+
+ if (previousServerSearch) previousServerSearch(params, newSearch);
+ };
+ searchConfig.clearServerSearch = () => {
+ this._page = 0;
+ this.reload();
+ }
+ this._search = new Search(searchConfig);
+ this._search.generateFilterList();
+ this.lastPage = false;
+ };
+
+ // Sets the elements with "name"s in "elements" as visible or not.
+ public abstract setVisibility: (elements: string[], visible: boolean) => void;
+
+ // Removes all elements, and reloads the first page.
+ public abstract reload: () => void;
+ protected _reload = (
+ callback?: (req: XMLHttpRequest) => void
+ ) => {
+ this._lastLoad = Date.now();
+ this.lastPage = false;
+
+ this._counter.reset();
+ this._counter.getTotal(this._c.totalEndpoint);
+
+ // Reload all currently visible elements, i.e. Load a new page of size (limit*(page+1)).
+ let limit = this._c.limit;
+ if (this._page != 0) {
+ limit *= this._page+1;
+ }
+
+ let params = this._search.inServerSearch ? this._searchParams : this.defaultParams();
+ params.limit = limit;
+ params.page = 0;
+ if (params.sortByField == "") {
+ params.sortByField = this._c.defaultSortField;
+ params.ascending = true;
+ }
+
+ _post(this._c.getPageEndpoint, params, (req: XMLHttpRequest) => {
+ if (req.readyState != 4) return;
+ if (req.status != 200) {
+ if (this._c.pageLoadCallback) this._c.pageLoadCallback(req);
+ if (callback) callback(req);
+ return;
+ }
+
+ this._hasLoaded = true;
+ // Allow refreshes every 15s
+ this._c.refreshButton.disabled = true;
+ setTimeout(() => this._c.refreshButton.disabled = false, 15000);
+
+ let resp = req.response as paginatedDTO;
+
+ this.lastPage = resp.last_page;
+
+ this._c.updateExistingElementsFromPage(resp);
+
+ this._counter.loaded = this._search.ordering.length;
+
+ this._search.onSearchBoxChange(true);
+ if (this._search.inSearch) {
+ // this._c.loadAllButton.classList.remove("unfocused");
+ } else {
+ this._counter.shown = this._counter.loaded;
+ this.setVisibility(this._search.ordering, true);
+ this._c.loadAllButton.classList.add("unfocused");
+ this._c.notFoundPanel.classList.add("unfocused");
+ }
+ if (this._c.pageLoadCallback) this._c.pageLoadCallback(req);
+ if (callback) callback(req);
+ }, true);
+ }
+
+ // Loads the next page. If "loadAll", all pages will be loaded until the last is reached.
+ public abstract loadMore: (callback: () => void, loadAll: boolean) => void;
+ protected _loadMore = (
+ loadAll: boolean = false,
+ callback?: (req: XMLHttpRequest) => void
+ ) => {
+ this._lastLoad = Date.now();
+ this._c.loadMoreButton.disabled = true;
+ const timeout = setTimeout(() => {
+ this._c.loadMoreButton.disabled = false;
+ }, 1000);
+ this._page += 1;
+
+ let params = this._search.inServerSearch ? this._searchParams : this.defaultParams();
+ params.limit = this._c.limit;
+ params.page = this._page;
+ if (params.sortByField == "") {
+ params.sortByField = this._c.defaultSortField;
+ params.ascending = true;
+ }
+
+ _post(this._c.getPageEndpoint, params, (req: XMLHttpRequest) => {
+ if (req.readyState != 4) return;
+ if (req.status != 200) {
+ if (this._c.pageLoadCallback) this._c.pageLoadCallback(req);
+ if (callback) callback(req);
+ return;
+ }
+
+ let resp = req.response as paginatedDTO;
+
+ // Check before setting this.lastPage so we have a chance to cancel the timeout.
+ if (resp.last_page) {
+ clearTimeout(timeout);
+ removeLoader(this._c.loadAllButton);
+ }
+
+ this.lastPage = resp.last_page;
+
+ this._c.newElementsFromPage(resp);
+
+ this._counter.loaded = this._search.ordering.length;
+
+ if (this._search.inSearch || loadAll) {
+ if (this.lastPage) {
+ loadAll = false;
+ }
+ this._search.onSearchBoxChange(true, loadAll);
+ } else {
+ this.setVisibility(this._search.ordering, true);
+ this._c.notFoundPanel.classList.add("unfocused");
+ }
+ if (this._c.pageLoadCallback) this._c.pageLoadCallback(req);
+ if (callback) callback(req);
+ }, true)
+ }
+
+ // Should be assigned to window.onscroll whenever the list is in view.
+ detectScroll = () => {
+ if (!this._hasLoaded || this.lastPage) return;
+ // console.log(window.innerHeight + document.documentElement.scrollTop, document.scrollingElement.scrollHeight);
+ if (Math.abs(window.innerHeight + document.documentElement.scrollTop - document.scrollingElement.scrollHeight) < 50) {
+ // window.notifications.customSuccess("scroll", "Reached bottom.");
+ // Wait .5s between loads
+ if (this._lastLoad + 500 > Date.now()) return;
+ this.loadMore(null, false);
+ }
+ }
+
+}
+
+
diff --git a/ts/modules/search.ts b/ts/modules/search.ts
index a0a446a..e93b653 100644
--- a/ts/modules/search.ts
+++ b/ts/modules/search.ts
@@ -32,29 +32,49 @@ export interface QueryType {
export interface SearchConfiguration {
filterArea: HTMLElement;
- sortingByButton: HTMLButtonElement;
+ sortingByButton?: HTMLButtonElement;
searchOptionsHeader: HTMLElement;
notFoundPanel: HTMLElement;
notFoundCallback?: (notFound: boolean) => void;
filterList: HTMLElement;
clearSearchButtonSelector: string;
+ serverSearchButtonSelector: string;
search: HTMLInputElement;
queries: { [field: string]: QueryType };
setVisibility: (items: string[], visible: boolean) => void;
onSearchCallback: (visibleCount: number, newItems: boolean, loadAll: boolean) => void;
+ searchServer: (params: PaginatedReqDTO, newSearch: boolean) => void;
+ clearServerSearch: () => void;
loadMore?: () => void;
}
+export interface ServerSearchReqDTO extends PaginatedReqDTO {
+ searchTerms: string[];
+ queries: QueryDTO[];
+}
+
+// FIXME: Generate ServerSearchReqDTO using Query.asDTO methods in serverSearch()!
+
+export interface QueryDTO {
+ class: "bool" | "string" | "date";
+ // QueryType.getter
+ field: string;
+ operator: QueryOperator;
+ value: boolean | string | DateAttempt;
+};
+
export abstract class Query {
protected _subject: QueryType;
protected _operator: QueryOperator;
protected _card: HTMLElement;
- constructor(subject: QueryType, operator: QueryOperator) {
+ constructor(subject: QueryType | null, operator: QueryOperator) {
this._subject = subject;
this._operator = operator;
- this._card = document.createElement("span");
- this._card.ariaLabel = window.lang.strings("clickToRemoveFilter");
+ if (subject != null) {
+ this._card = document.createElement("span");
+ this._card.ariaLabel = window.lang.strings("clickToRemoveFilter");
+ }
}
set onclick(v: () => void) {
@@ -62,8 +82,16 @@ export abstract class Query {
}
asElement(): HTMLElement { return this._card; }
-}
+
+ public abstract compare(subjectValue: any): boolean;
+ asDTO(): QueryDTO {
+ let out = {} as QueryDTO;
+ out.field = this._subject.getter;
+ out.operator = this._operator;
+ return out;
+ }
+}
export class BoolQuery extends Query {
protected _value: boolean;
@@ -96,13 +124,20 @@ export class BoolQuery extends Query {
public compare(subjectBool: boolean): boolean {
return ((subjectBool && this._value) || (!subjectBool && !this._value))
}
+
+ asDTO(): QueryDTO {
+ let out = super.asDTO();
+ out.class = "bool";
+ out.value = this._value;
+ return out;
+ }
}
export class StringQuery extends Query {
protected _value: string;
constructor(subject: QueryType, value: string) {
super(subject, QueryOperator.Equal);
- this._value = value;
+ this._value = value.toLowerCase();
this._card.classList.add("button", "~neutral", "@low", "center", "mx-2", "h-full");
this._card.innerHTML = `
${subject.name}: "${this._value}"
@@ -110,6 +145,17 @@ export class StringQuery extends Query {
}
get value(): string { return this._value; }
+
+ public compare(subjectString: string): boolean {
+ return subjectString.toLowerCase().includes(this._value);
+ }
+
+ asDTO(): QueryDTO {
+ let out = super.asDTO();
+ out.class = "string";
+ out.value = this._value;
+ return out;
+ }
}
export interface DateAttempt {
@@ -151,7 +197,6 @@ export class DateQuery extends Query {
constructor(subject: QueryType, operator: QueryOperator, value: ParsedDate) {
super(subject, operator);
this._value = value;
- console.log("op:", operator, "date:", value);
this._card.classList.add("button", "~neutral", "@low", "center", "m-2", "h-full");
let dateText = QueryOperatorToDateText(operator);
this._card.innerHTML = `
@@ -204,30 +249,58 @@ export class DateQuery extends Query {
}
return subjectDate > temp;
}
+
+ asDTO(): QueryDTO {
+ let out = super.asDTO();
+ out.class = "date";
+ out.value = this._value.attempt;
+ return out;
+ }
}
-
-// FIXME: Continue taking stuff from search function, making XQuery classes!
-
-
-
export interface SearchableItem {
matchesSearch: (query: string) => boolean;
}
+export type SearchableItems = { [id: string]: SearchableItem };
+
export class Search {
private _c: SearchConfiguration;
+ private _sortField: string = "";
+ private _ascending: boolean = true;
private _ordering: string[] = [];
- private _items: { [id: string]: SearchableItem };
- inSearch: boolean;
+ private _items: SearchableItems = {};
+ // Search queries (filters)
+ private _queries: Query[] = [];
+ // Plain-text search terms
+ private _searchTerms: string[] = [];
+ inSearch: boolean = false;
+ private _inServerSearch: boolean = false;
+ get inServerSearch(): boolean { return this._inServerSearch; }
+ set inServerSearch(v: boolean) {
+ this._inServerSearch = v;
+ if (!v) {
+ this._c.clearServerSearch();
+ }
+ }
+ private _serverSearchButtons: HTMLElement[];
+
+ // Returns a list of identifiers (keys in items, values in ordering).
search = (query: String): string[] => {
this._c.filterArea.textContent = "";
query = query.toLowerCase();
let result: string[] = [...this._ordering];
+ // If we're in a server search already, the results are already correct.
+ if (this.inServerSearch) return result;
+
+
let words: string[] = [];
+ let queries = [];
+ let searchTerms = [];
+
let quoteSymbol = ``;
let queryStart = -1;
@@ -258,7 +331,6 @@ export class Search {
}
}
words.push(query.substring(queryStart, end).replace(/['"]/g, ""));
- console.log("pushed", words);
queryStart = -1;
}
}
@@ -266,28 +338,31 @@ export class Search {
query = "";
for (let word of words) {
+ // 1. Normal search text, no filters or anything
if (!word.includes(":")) {
+ searchTerms.push(word);
let cachedResult = [...result];
for (let id of cachedResult) {
- const u = this._items[id];
+ const u = this.items[id];
if (!u.matchesSearch(word)) {
result.splice(result.indexOf(id), 1);
}
}
continue;
}
+ // 2. A filter query of some sort.
const split = [word.substring(0, word.indexOf(":")), word.substring(word.indexOf(":")+1)];
if (!(split[0] in this._c.queries)) continue;
const queryFormat = this._c.queries[split[0]];
- let formattedQuery = []
+ let q: Query | null = null;
if (queryFormat.bool) {
let [boolState, isBool] = BoolQuery.paramsFromString(split[1]);
if (isBool) {
- let q = new BoolQuery(queryFormat, boolState);
+ q = new BoolQuery(queryFormat, boolState);
q.onclick = () => {
for (let quote of [`"`, `'`, ``]) {
this._c.search.value = this._c.search.value.replace(split[0] + ":" + quote + split[1] + quote, "");
@@ -297,24 +372,21 @@ export class Search {
this._c.filterArea.appendChild(q.asElement());
- // console.log("is bool, state", boolState);
// So removing elements doesn't affect us
let cachedResult = [...result];
for (let id of cachedResult) {
- const u = this._items[id];
+ const u = this.items[id];
const value = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u);
- // console.log("got", queryFormat.getter + ":", value);
// Remove from result if not matching query
if (!q.compare(value)) {
// console.log("not matching, result is", result);
result.splice(result.indexOf(id), 1);
+ } else {
}
}
- continue
}
- }
- if (queryFormat.string) {
- const q = new StringQuery(queryFormat, split[1]);
+ } else if (queryFormat.string) {
+ q = new StringQuery(queryFormat, split[1]);
q.onclick = () => {
for (let quote of [`"`, `'`, ``]) {
@@ -328,18 +400,16 @@ export class Search {
let cachedResult = [...result];
for (let id of cachedResult) {
- const u = this._items[id];
+ const u = this.items[id];
const value = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u).toLowerCase();
- if (!(value.includes(split[1]))) {
+ if (!q.compare(value)) {
result.splice(result.indexOf(id), 1);
}
}
- continue;
- }
- if (queryFormat.date) {
+ } else if (queryFormat.date) {
let [parsedDate, op, isDate] = DateQuery.paramsFromString(split[1]);
if (!isDate) continue;
- const q = new DateQuery(queryFormat, op, parsedDate);
+ q = new DateQuery(queryFormat, op, parsedDate);
q.onclick = () => {
for (let quote of [`"`, `'`, ``]) {
@@ -354,7 +424,7 @@ export class Search {
let cachedResult = [...result];
for (let id of cachedResult) {
- const u = this._items[id];
+ const u = this.items[id];
const unixValue = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u);
if (unixValue == 0) {
result.splice(result.indexOf(id), 1);
@@ -362,20 +432,24 @@ export class Search {
}
let value = new Date(unixValue*1000);
- let match = q.compare(value);
- if (!match) {
+ if (!q.compare(value)) {
result.splice(result.indexOf(id), 1);
}
}
}
+
+ if (q != null) queries.push(q);
}
+
+ this._queries = queries;
+ this._searchTerms = searchTerms;
return result;
}
showHideSearchOptionsHeader = () => {
- const sortingBy = !(this._c.sortingByButton.parentElement.classList.contains("hidden"));
+ let sortingBy = false;
+ if (this._c.sortingByButton) sortingBy = !(this._c.sortingByButton.parentElement.classList.contains("hidden"));
const hasFilters = this._c.filterArea.textContent != "";
- console.log("sortingBy", sortingBy, "hasFilters", hasFilters);
if (sortingBy || hasFilters) {
this._c.searchOptionsHeader.classList.remove("hidden");
} else {
@@ -383,14 +457,24 @@ export class Search {
}
}
-
+ // -all- elements.
get items(): { [id: string]: SearchableItem } { return this._items; }
- set items(v: { [id: string]: SearchableItem }) {
- this._items = v;
+ // set items(v: { [id: string]: SearchableItem }) {
+ // this._items = v;
+ // }
+
+ // The order of -all- elements (even those hidden), by their identifier.
+ get ordering(): string[] { return this._ordering; }
+ // Specifically dis-allow setting ordering itself, so that setOrdering is used instead (for the field and ascending params).
+ // set ordering(v: string[]) { this._ordering = v; }
+ setOrdering = (v: string[], field: string, ascending: boolean) => {
+ this._ordering = v;
+ this._sortField = field;
+ this._ascending = ascending;
}
- get ordering(): string[] { return this._ordering; }
- set ordering(v: string[]) { this._ordering = v; }
+ get sortField(): string { return this._sortField; }
+ get ascending(): boolean { return this._ascending; }
onSearchBoxChange = (newItems: boolean = false, loadAll: boolean = false) => {
const query = this._c.search.value;
@@ -402,11 +486,23 @@ export class Search {
const results = this.search(query);
this._c.setVisibility(results, true);
this._c.onSearchCallback(results.length, newItems, loadAll);
+ if (this.inServerSearch) {
+ this._serverSearchButtons.forEach((v: HTMLElement) => {
+ v.classList.add("@low");
+ v.classList.remove("@high");
+ });
+ } else {
+ this._serverSearchButtons.forEach((v: HTMLElement) => {
+ v.classList.add("@high");
+ v.classList.remove("@low");
+ });
+ }
this.showHideSearchOptionsHeader();
if (results.length == 0) {
this._c.notFoundPanel.classList.remove("unfocused");
} else {
this._c.notFoundPanel.classList.add("unfocused");
+
}
if (this._c.notFoundCallback) this._c.notFoundCallback(results.length == 0);
}
@@ -420,9 +516,8 @@ export class Search {
this._c.search.setSelectionRange(newPos, newPos);
this._c.search.oninput(null as any);
};
-
-
+ // FIXME: Make XQuery classes less specifically for in-progress searches, and include this code for making info button things.
generateFilterList = () => {
// Generate filter buttons
for (let queryName of Object.keys(this._c.queries)) {
@@ -497,17 +592,55 @@ export class Search {
}
}
+ onServerSearch = () => {
+ const newServerSearch = !this.inServerSearch;
+ this.inServerSearch = true;
+ this._c.searchServer(this.serverSearchParams(this._searchTerms, this._queries), newServerSearch);
+ }
+
+ serverSearchParams = (searchTerms: string[], queries: Query[]): PaginatedReqDTO => {
+ let req: ServerSearchReqDTO = {
+ searchTerms: searchTerms,
+ queries: queries.map((q: Query) => q.asDTO()),
+ limit: -1,
+ page: 0,
+ sortByField: this.sortField,
+ ascending: this.ascending
+ };
+ return req;
+ }
+
constructor(c: SearchConfiguration) {
+ // FIXME: Remove!
+ if (c.search.id.includes("accounts")) {
+ (window as any).s = this;
+ }
this._c = c;
- this._c.search.oninput = () => this.onSearchBoxChange();
+ this._c.search.oninput = () => {
+ this.inServerSearch = false;
+ this.onSearchBoxChange();
+ }
+ this._c.search.addEventListener("keyup", (ev: KeyboardEvent) => {
+ if (ev.key == "Enter") {
+ this.onServerSearch();
+ }
+ });
const clearSearchButtons = Array.from(document.querySelectorAll(this._c.clearSearchButtonSelector)) as Array;
for (let b of clearSearchButtons) {
b.addEventListener("click", () => {
this._c.search.value = "";
+ this.inServerSearch = false;
this.onSearchBoxChange();
});
}
+
+ this._serverSearchButtons = Array.from(document.querySelectorAll(this._c.serverSearchButtonSelector)) as Array;
+ for (let b of this._serverSearchButtons) {
+ b.addEventListener("click", () => {
+ this.onServerSearch();
+ });
+ }
}
}
diff --git a/ts/modules/tabs.ts b/ts/modules/tabs.ts
index 3f7d78b..fc700cf 100644
--- a/ts/modules/tabs.ts
+++ b/ts/modules/tabs.ts
@@ -1,7 +1,5 @@
import { PageManager, Page } from "../modules/pages.js";
-declare var window: GlobalWindow;
-
export interface Tab {
page: Page;
tabEl: HTMLDivElement;
diff --git a/ts/typings/d.ts b/ts/typings/d.ts
index a4f7f80..8447fbd 100644
--- a/ts/typings/d.ts
+++ b/ts/typings/d.ts
@@ -150,14 +150,16 @@ interface inviteList {
loadInviteURL: () => void;
}
-// Finally added to typescript, dont need this anymore.
-// declare interface SubmitEvent extends Event {
-// submitter: HTMLInputElement;
-// }
-
interface paginatedDTO {
last_page: boolean;
}
+interface PaginatedReqDTO {
+ limit: number;
+ page: number;
+ sortByField: string;
+ ascending: boolean;
+};
+
declare var config: Object;
declare var modifiedConfig: Object;
diff --git a/usercache.go b/usercache.go
index cec988f..9b2e050 100644
--- a/usercache.go
+++ b/usercache.go
@@ -2,56 +2,63 @@ package main
import (
"cmp"
+ "fmt"
+ "slices"
+ "strings"
"sync"
"time"
)
const (
// FIXME: Follow mediabrowser, or make tuneable, or both
- WEB_USER_CACHE_SYNC = 30 * time.Second
+ WEB_USER_CACHE_SYNC = 30 * time.Second
+ USER_DEFAULT_SORT_FIELD = "name"
)
type UserCache struct {
Cache []respUser
+ Ref []*respUser
+ Sorted bool
LastSync time.Time
Lock sync.Mutex
}
-func (c *UserCache) Gen(app *appContext) ([]respUser, error) {
+func (c *UserCache) gen(app *appContext) error {
// FIXME: I don't like this.
if !time.Now().After(c.LastSync.Add(WEB_USER_CACHE_SYNC)) {
- return c.Cache, nil
+ return nil
}
c.Lock.Lock()
users, err := app.jf.GetUsers(false)
if err != nil {
- return nil, err
+ return err
}
c.Cache = make([]respUser, len(users))
for i, jfUser := range users {
c.Cache[i] = app.userSummary(jfUser)
}
+ c.Ref = make([]*respUser, len(c.Cache))
+ for i := range c.Cache {
+ c.Ref[i] = &(c.Cache[i])
+ }
+ c.Sorted = false
c.LastSync = time.Now()
c.Lock.Unlock()
- return c.Cache, nil
+ return nil
}
-type Less func(a, b *respUser) bool
-type SortableUserList struct {
- Cache []respUser
- lessFunc Less
-}
-
-func (sc *SortableUserList) Len() int {
- return len(sc.Cache)
-}
-
-func (sc *SortableUserList) Swap(i, j int) {
- sc.Cache[i], sc.Cache[j] = sc.Cache[j], sc.Cache[i]
-}
-
-func (sc *SortableUserList) Less(i, j int) bool {
- return sc.lessFunc(&sc.Cache[i], &sc.Cache[j])
+func (c *UserCache) Gen(app *appContext, sorted bool) ([]*respUser, error) {
+ if err := c.gen(app); err != nil {
+ return nil, err
+ }
+ if sorted && !c.Sorted {
+ c.Lock.Lock()
+ // FIXME: Check we want ascending!
+ c.Sort(c.Ref, USER_DEFAULT_SORT_FIELD, true)
+ c.Sorted = true
+ c.Lock.Unlock()
+ }
+ return c.Ref, nil
}
// instead of making a Less for bools, just convert them to integers
@@ -66,93 +73,303 @@ func bool2int(b bool) int {
return i
}
+// Returns -1 if respUser < value, 0 if equal, 1 is greater than
+type Sorter func(a, b *respUser) int
+
// Allow sorting by respUser's struct fields (well, it's JSON-representation's fields)
-// Ugly I know, but at least cmp.Less exists.
-// Done with vim macros, thank god they exist
-func SortUsersBy(u []respUser, field string) SortableUserList {
- s := SortableUserList{Cache: u}
+func SortUsersBy(field string) Sorter {
+ switch field {
+ case "id":
+ return func(a, b *respUser) int {
+ return cmp.Compare(strings.ToLower(a.ID), strings.ToLower(b.ID))
+ }
+ case "name":
+ return func(a, b *respUser) int {
+ return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
+ }
+ case "email":
+ return func(a, b *respUser) int {
+ return cmp.Compare(strings.ToLower(a.Email), strings.ToLower(b.Email))
+ }
+ case "notify_email":
+ return func(a, b *respUser) int {
+ return cmp.Compare(bool2int(a.NotifyThroughEmail), bool2int(b.NotifyThroughEmail))
+ }
+ case "last_active":
+ return func(a, b *respUser) int {
+ return cmp.Compare(a.LastActive, b.LastActive)
+ }
+ case "admin":
+ return func(a, b *respUser) int {
+ return cmp.Compare(bool2int(a.Admin), bool2int(b.Admin))
+ }
+ case "expiry":
+ return func(a, b *respUser) int {
+ return cmp.Compare(a.Expiry, b.Expiry)
+ }
+ case "disabled":
+ return func(a, b *respUser) int {
+ return cmp.Compare(bool2int(a.Disabled), bool2int(b.Disabled))
+ }
+ case "telegram":
+ return func(a, b *respUser) int {
+ return cmp.Compare(strings.ToLower(a.Telegram), strings.ToLower(b.Telegram))
+ }
+ case "notify_telegram":
+ return func(a, b *respUser) int {
+ return cmp.Compare(bool2int(a.NotifyThroughTelegram), bool2int(b.NotifyThroughTelegram))
+ }
+ case "discord":
+ return func(a, b *respUser) int {
+ return cmp.Compare(strings.ToLower(a.Discord), strings.ToLower(b.Discord))
+ }
+ case "discord_id":
+ return func(a, b *respUser) int {
+ return cmp.Compare(strings.ToLower(a.DiscordID), strings.ToLower(b.DiscordID))
+ }
+ case "notify_discord":
+ return func(a, b *respUser) int {
+ return cmp.Compare(bool2int(a.NotifyThroughDiscord), bool2int(b.NotifyThroughDiscord))
+ }
+ case "matrix":
+ return func(a, b *respUser) int {
+ return cmp.Compare(strings.ToLower(a.Matrix), strings.ToLower(b.Matrix))
+ }
+ case "notify_matrix":
+ return func(a, b *respUser) int {
+ return cmp.Compare(bool2int(a.NotifyThroughMatrix), bool2int(b.NotifyThroughMatrix))
+ }
+ case "label":
+ return func(a, b *respUser) int {
+ return cmp.Compare(strings.ToLower(a.Label), strings.ToLower(b.Label))
+ }
+ case "accounts_admin":
+ return func(a, b *respUser) int {
+ return cmp.Compare(bool2int(a.AccountsAdmin), bool2int(b.AccountsAdmin))
+ }
+ case "referrals_enabled":
+ return func(a, b *respUser) int {
+ return cmp.Compare(bool2int(a.ReferralsEnabled), bool2int(b.ReferralsEnabled))
+ }
+ }
+ panic(fmt.Errorf("got invalid field %s", field))
+ return nil
+}
+
+type Filter func(*respUser) bool
+
+type CompareResult int
+
+const (
+ Lesser CompareResult = -1
+ Equal CompareResult = 0
+ Greater CompareResult = 1
+)
+
+// One day i'll figure out Go generics
+/*type FilterValue interface {
+ bool | string | DateAttempt
+}*/
+
+type DateAttempt struct {
+ Year *int `json:"year,omitempty"`
+ Month *int `json:"month,omitempty"`
+ Day *int `json:"day,omitempty"`
+ Hour *int `json:"hour,omitempty"`
+ Minute *int `json:"minute,omitempty"`
+}
+
+// Compares a Unix timestamp.
+// We want to compare only the fields given in DateAttempt,
+// so we copy subjectDate and apply on those fields from this._value.
+func (d DateAttempt) Compare(subject int64) int {
+ subjectTime := time.Unix(subject, 0)
+ yy, mo, dd := subjectTime.Date()
+ hh, mm, _ := subjectTime.Clock()
+ if d.Year != nil {
+ yy = *d.Year
+ }
+ if d.Month != nil {
+ // Month in Javascript is zero-based, so we need to increment it
+ mo = time.Month((*d.Month) + 1)
+ }
+ if d.Day != nil {
+ dd = *d.Day
+ }
+ if d.Hour != nil {
+ hh = *d.Hour
+ }
+ if d.Minute != nil {
+ mm = *d.Minute
+ }
+ return subjectTime.Compare(time.Date(yy, mo, dd, hh, mm, 0, 0, nil))
+}
+
+// FIXME: Consider using QueryDTO.Class rather than assuming type from name? Probably not worthwhile though.
+func FilterUsersBy(field string, op QueryOperator, value any) Filter {
+ operator := Equal
+ switch op {
+ case LesserOperator:
+ operator = Lesser
+ case EqualOperator:
+ operator = Equal
+ case GreaterOperator:
+ operator = Greater
+ }
switch field {
case "id":
- s.lessFunc = func(a, b *respUser) bool {
- return cmp.Less(a.ID, b.ID)
+ return func(a *respUser) bool {
+ return cmp.Compare(strings.ToLower(a.ID), strings.ToLower(value.(string))) == int(operator)
}
-
case "name":
- s.lessFunc = func(a, b *respUser) bool {
- return cmp.Less(a.Name, b.Name)
+ return func(a *respUser) bool {
+ return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(value.(string))) == int(operator)
}
case "email":
- s.lessFunc = func(a, b *respUser) bool {
- return cmp.Less(a.Email, b.Email)
+ return func(a *respUser) bool {
+ return cmp.Compare(strings.ToLower(a.Email), strings.ToLower(value.(string))) == int(operator)
}
case "notify_email":
- s.lessFunc = func(a, b *respUser) bool {
- return cmp.Less(bool2int(a.NotifyThroughEmail), bool2int(b.NotifyThroughEmail))
+ return func(a *respUser) bool {
+ return cmp.Compare(bool2int(a.NotifyThroughEmail), bool2int(value.(bool))) == int(operator)
}
case "last_active":
- s.lessFunc = func(a, b *respUser) bool {
- return cmp.Less(a.LastActive, b.LastActive)
+ return func(a *respUser) bool {
+ return value.(DateAttempt).Compare(a.LastActive) == int(operator)
}
case "admin":
- s.lessFunc = func(a, b *respUser) bool {
- return cmp.Less(bool2int(a.Admin), bool2int(b.Admin))
+ return func(a *respUser) bool {
+ return cmp.Compare(bool2int(a.Admin), bool2int(value.(bool))) == int(operator)
}
case "expiry":
- s.lessFunc = func(a, b *respUser) bool {
- return cmp.Less(a.Expiry, b.Expiry)
+ return func(a *respUser) bool {
+ return value.(DateAttempt).Compare(a.Expiry) == int(operator)
}
case "disabled":
- s.lessFunc = func(a, b *respUser) bool {
- return cmp.Less(bool2int(a.Disabled), bool2int(b.Disabled))
+ return func(a *respUser) bool {
+ return cmp.Compare(bool2int(a.Disabled), bool2int(value.(bool))) == int(operator)
}
case "telegram":
- s.lessFunc = func(a, b *respUser) bool {
- return cmp.Less(a.Telegram, b.Telegram)
+ return func(a *respUser) bool {
+ return cmp.Compare(strings.ToLower(a.Telegram), strings.ToLower(value.(string))) == int(operator)
}
case "notify_telegram":
- s.lessFunc = func(a, b *respUser) bool {
- return cmp.Less(bool2int(a.NotifyThroughTelegram), bool2int(b.NotifyThroughTelegram))
+ return func(a *respUser) bool {
+ return cmp.Compare(bool2int(a.NotifyThroughTelegram), bool2int(value.(bool))) == int(operator)
}
case "discord":
- s.lessFunc = func(a, b *respUser) bool {
- return cmp.Less(a.Discord, b.Discord)
+ return func(a *respUser) bool {
+ return cmp.Compare(strings.ToLower(a.Discord), strings.ToLower(value.(string))) == int(operator)
}
case "discord_id":
- s.lessFunc = func(a, b *respUser) bool {
- return cmp.Less(a.DiscordID, b.DiscordID)
+ return func(a *respUser) bool {
+ return cmp.Compare(strings.ToLower(a.DiscordID), strings.ToLower(value.(string))) == int(operator)
}
case "notify_discord":
- s.lessFunc = func(a, b *respUser) bool {
- return cmp.Less(bool2int(a.NotifyThroughDiscord), bool2int(b.NotifyThroughDiscord))
+ return func(a *respUser) bool {
+ return cmp.Compare(bool2int(a.NotifyThroughDiscord), bool2int(value.(bool))) == int(operator)
}
case "matrix":
- s.lessFunc = func(a, b *respUser) bool {
- return cmp.Less(a.Matrix, b.Matrix)
+ return func(a *respUser) bool {
+ return cmp.Compare(strings.ToLower(a.Matrix), strings.ToLower(value.(string))) == int(operator)
}
case "notify_matrix":
- s.lessFunc = func(a, b *respUser) bool {
- return cmp.Less(bool2int(a.NotifyThroughMatrix), bool2int(b.NotifyThroughMatrix))
+ return func(a *respUser) bool {
+ return cmp.Compare(bool2int(a.NotifyThroughMatrix), bool2int(value.(bool))) == int(operator)
}
case "label":
- s.lessFunc = func(a, b *respUser) bool {
- return cmp.Less(a.Label, b.Label)
+ return func(a *respUser) bool {
+ return cmp.Compare(strings.ToLower(a.Label), strings.ToLower(value.(string))) == int(operator)
}
case "accounts_admin":
- s.lessFunc = func(a, b *respUser) bool {
- return cmp.Less(bool2int(a.AccountsAdmin), bool2int(b.AccountsAdmin))
+ return func(a *respUser) bool {
+ return cmp.Compare(bool2int(a.AccountsAdmin), bool2int(value.(bool))) == int(operator)
}
case "referrals_enabled":
- s.lessFunc = func(a, b *respUser) bool {
- return cmp.Less(bool2int(a.ReferralsEnabled), bool2int(b.ReferralsEnabled))
+ return func(a *respUser) bool {
+ return cmp.Compare(bool2int(a.ReferralsEnabled), bool2int(value.(bool))) == int(operator)
}
}
- return s
+ panic(fmt.Errorf("got invalid field %s", field))
+ return nil
}
-type Filter func(yield func(*respUser) bool)
-
-type FilterableList struct {
- Cache []respUser
- filterFunc Filter
+func (ru *respUser) MatchesSearch(term string) bool {
+ return (strings.Contains(ru.ID, term) ||
+ strings.Contains(strings.ToLower(ru.Name), term) ||
+ strings.Contains(strings.ToLower(ru.Label), term) ||
+ strings.Contains(strings.ToLower(ru.Email), term) ||
+ strings.Contains(strings.ToLower(ru.Discord), term) ||
+ strings.Contains(strings.ToLower(ru.Matrix), term) ||
+ strings.Contains(strings.ToLower(ru.Telegram), term))
+}
+
+type QueryClass string
+
+const (
+ BoolQuery QueryClass = "bool"
+ StringQuery QueryClass = "string"
+ DateQuery QueryClass = "date"
+)
+
+type QueryOperator string
+
+const (
+ LesserOperator QueryOperator = "<"
+ EqualOperator QueryOperator = "="
+ GreaterOperator QueryOperator = ">"
+)
+
+type QueryDTO struct {
+ Class QueryClass `json:"class"`
+ Field string `json:"field"`
+ Operator QueryOperator `json:"operator"`
+ // string | bool | DateAttempt
+ Value any `json:"value"`
+}
+
+type ServerSearchReqDTO struct {
+ PaginatedReqDTO
+ SearchTerms []string `json:"searchTerms"`
+ Queries []QueryDTO `json:"queries"`
+}
+
+// Filter by AND-ing all search terms and queries.
+func (c *UserCache) Filter(users []*respUser, terms []string, queries []QueryDTO) []*respUser {
+ filters := make([]Filter, len(queries))
+ for i, q := range queries {
+ filters[i] = FilterUsersBy(q.Field, q.Operator, q.Value)
+ }
+ // FIXME: Properly consider pre-allocation size
+ out := make([]*respUser, 0, len(users)/4)
+ for i := range users {
+ match := true
+ for _, term := range terms {
+ if !users[i].MatchesSearch(term) {
+ match = false
+ break
+ }
+ }
+ if !match {
+ continue
+ }
+ for _, filter := range filters {
+ if filter == nil || !filter(users[i]) {
+ match = false
+ break
+ }
+ }
+ if match {
+ out = append(out, users[i])
+ }
+ }
+ return out
+}
+
+func (c *UserCache) Sort(users []*respUser, field string, ascending bool) {
+ slices.SortFunc(users, SortUsersBy(field))
+ if !ascending {
+ slices.Reverse(users)
+ }
}