From b6459b665d3b6fdce295aac9c95a6e6319ee437f Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Sat, 20 Dec 2025 18:27:39 +0000 Subject: [PATCH] jf_activity: paginated list in ui added POST route for pagination to activity route, a count route, and modified Search and PaginatedList a bit to support lists without search fields (essentially just running an empty search). Visible by clicking on a user's name in the accounts tab. --- api-users.go | 74 +++++- go.mod | 2 +- go.sum | 2 + html/admin.html | 97 ++++---- html/user.html | 2 +- lang/admin/en-us.json | 8 +- lang/common/en-us.json | 7 +- models.go | 27 ++- router.go | 2 + ts/modules/accounts.ts | 519 +++++++++++++++++++++++++++++++++++++---- ts/modules/activity.ts | 10 +- ts/modules/list.ts | 74 +++--- ts/modules/row.ts | 15 ++ ts/modules/search.ts | 79 ++++--- ts/modules/settings.ts | 1 - usercache.go | 12 - 16 files changed, 738 insertions(+), 193 deletions(-) create mode 100644 ts/modules/row.ts diff --git a/api-users.go b/api-users.go index 570af70..afab1cb 100644 --- a/api-users.go +++ b/api-users.go @@ -1420,7 +1420,30 @@ func (app *appContext) ApplySettings(gc *gin.Context) { gc.JSON(code, errors) } -// @Summary Get the latest Jellyfin/Emby activities related to the given user ID. Returns as many as the server has recorded. +// @Summary Gets the number of Jellyfin/Emby activities stored by jfa-go related to the given user ID. As the total collected by jfa-go is limited, this may not include all those held by Jellyfin. +// @Produce json +// @Success 200 {object} PageCountDTO +// @Failure 400 {object} boolResponse +// @Param id path string true "id of user to fetch activities of." +// @Router /users/{id}/activities/jellyfin/count [get] +// @Security Bearer +// @tags Users +func (app *appContext) CountJFActivitesForUser(gc *gin.Context) { + userID := gc.Param("id") + if userID == "" { + respondBool(400, false, gc) + return + } + activities, err := app.jf.activity.ByUserID(userID) + if err != nil { + app.err.Printf(lm.FailedGetJFActivities, err) + respondBool(400, false, gc) + return + } + gc.JSON(200, PageCountDTO{Count: uint64(len(activities))}) +} + +// @Summary Get the latest Jellyfin/Emby activities related to the given user ID. Returns as many as the server has recorded. As the total collected by jfa-go is limited, this may not include all those held by Jellyfin. // @Produce json // @Success 200 {object} ActivityLogEntriesDTO // @Failure 400 {object} boolResponse @@ -1429,12 +1452,12 @@ func (app *appContext) ApplySettings(gc *gin.Context) { // @Security Bearer // @tags Users func (app *appContext) GetJFActivitesForUser(gc *gin.Context) { - userId := gc.Param("id") - if userId == "" { + userID := gc.Param("id") + if userID == "" { respondBool(400, false, gc) return } - activities, err := app.jf.activity.ByUserID(userId) + activities, err := app.jf.activity.ByUserID(userID) if err != nil { app.err.Printf(lm.FailedGetJFActivities, err) respondBool(400, false, gc) @@ -1450,3 +1473,46 @@ func (app *appContext) GetJFActivitesForUser(gc *gin.Context) { app.debug.Printf(lm.GotNEntries, len(activities)) gc.JSON(200, out) } + +// @Summary Get the latest Jellyfin/Emby activities related to the given user ID, paginated. As the total collected by jfa-go is limited, this may not include all those held by Jellyfin. +// @Produce json +// @Param PaginatedReqDTO body PaginatedReqDTO true "pagination parameters" +// @Success 200 {object} PaginatedActivityLogEntriesDTO +// @Failure 400 {object} boolResponse +// @Param id path string true "id of user to fetch activities of." +// @Router /users/{id}/activities/jellyfin [post] +// @Security Bearer +// @tags Users +func (app *appContext) GetPaginatedJFActivitesForUser(gc *gin.Context) { + var req PaginatedReqDTO + gc.BindJSON(&req) + userID := gc.Param("id") + if userID == "" { + respondBool(400, false, gc) + return + } + activities, err := app.jf.activity.ByUserID(userID) + if err != nil { + app.err.Printf(lm.FailedGetJFActivities, err) + respondBool(400, false, gc) + return + } + out := PaginatedActivityLogEntriesDTO{} + startIndex := req.Page * req.Limit + if startIndex >= len(activities) { + out.LastPage = true + gc.JSON(200, out) + return + } + endIndex := min(startIndex+req.Limit, len(activities)) + activities = activities[startIndex:endIndex] + + out.Entries = make([]ActivityLogEntryDTO, len(activities)) + out.LastPage = len(activities) != req.Limit + for i := range activities { + out.Entries[i].ActivityLogEntry = activities[i] + out.Entries[i].Date = activities[i].Date.Unix() + } + app.debug.Printf(lm.GotNEntries, len(activities)) + gc.JSON(200, out) +} diff --git a/go.mod b/go.mod index 9049f5e..6e98864 100644 --- a/go.mod +++ b/go.mod @@ -42,7 +42,7 @@ require ( github.com/hrfee/jfa-go/logger v0.0.0-20251123165523-7c9f91711460 github.com/hrfee/jfa-go/logmessages v0.0.0-20251123165523-7c9f91711460 github.com/hrfee/jfa-go/ombi v0.0.0-20251123165523-7c9f91711460 - github.com/hrfee/mediabrowser v0.3.35 + github.com/hrfee/mediabrowser v0.3.36 github.com/hrfee/simple-template v1.1.0 github.com/itchyny/timefmt-go v0.1.7 github.com/lithammer/shortuuid/v3 v3.0.7 diff --git a/go.sum b/go.sum index e8696d1..5ce20a4 100644 --- a/go.sum +++ b/go.sum @@ -200,6 +200,8 @@ github.com/hrfee/mediabrowser v0.3.34 h1:AKnd1V9wt+KWZmHDjj1GMkCgcgcpBKxPw5iUcYg github.com/hrfee/mediabrowser v0.3.34/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U= github.com/hrfee/mediabrowser v0.3.35 h1:xEq4cL96Di0G+S3ONBH1HHeQJU6IfUMZiaeGeuJSFS8= github.com/hrfee/mediabrowser v0.3.35/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U= +github.com/hrfee/mediabrowser v0.3.36 h1:erYWzmaz4b6wfxEfeEWQHLMI9fSpGDy1m7NxkQmIVsw= +github.com/hrfee/mediabrowser v0.3.36/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U= github.com/hrfee/simple-template v1.1.0 h1:PNQDTgc2H0s19/pWuhRh4bncuNJjPrW0fIX77YtY78M= github.com/hrfee/simple-template v1.1.0/go.mod h1:s9a5QgfqbmT7j9WCC3GD5JuEqvihBEohyr+oYZmr4bA= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= diff --git a/html/admin.html b/html/admin.html index 919266b..d14a053 100644 --- a/html/admin.html +++ b/html/admin.html @@ -806,54 +806,59 @@ {{ .strings.sendPWR }} {{ .quantityStrings.deleteUser.Singular }} -
- - - - - - {{ if .jellyfinLogin }} - - {{ end }} - - {{ if .telegramEnabled }} - - {{ end }} - {{ if .matrixEnabled }} - - {{ end }} - {{ if .discordEnabled }} - - {{ end }} - {{ if .referralsEnabled }} - - {{ end }} - - - - - -
{{ .strings.username }}{{ .strings.accessJFA }}{{ .strings.emailAddress }}TelegramMatrixDiscord{{ .strings.referrals }}{{ .strings.expiry }}{{ .strings.lastActiveTime }}
-
-
-
- {{ .strings.noResultsFound }} - {{ .strings.noResultsFoundLocally }} -
- +
+
+
+
+
+ + + + + + {{ if .jellyfinLogin }} + + {{ end }} + + {{ if .telegramEnabled }} + + {{ end }} + {{ if .matrixEnabled }} + + {{ end }} + {{ if .discordEnabled }} + + {{ end }} + {{ if .referralsEnabled }} + + {{ end }} + + + + + +
{{ .strings.username }}{{ .strings.accessJFA }}{{ .strings.emailAddress }}TelegramMatrixDiscord{{ .strings.referrals }}{{ .strings.expiry }}{{ .strings.lastActiveTime }}
+
+
+
+ {{ .strings.noResultsFound }} + {{ .strings.noResultsFoundLocally }} +
+ +
-
-
- - - +
+ + + +
diff --git a/html/user.html b/html/user.html index 0ec72e5..f4611ae 100644 --- a/html/user.html +++ b/html/user.html @@ -75,7 +75,7 @@ {{ .strings.logout }}
- {{ .strings.admin }} + {{ .strings.admin }}
diff --git a/lang/admin/en-us.json b/lang/admin/en-us.json index f38804b..1b7d027 100644 --- a/lang/admin/en-us.json +++ b/lang/admin/en-us.json @@ -224,7 +224,13 @@ "restartRequired": "Restart required", "required": "Required", "syntax": "Syntax", - "syntaxDescription": "Variables denoted as {variable}. If statements can evaluate truthfulness (e.g. {ifTruth}) or make basic comparisons (e.g. {ifCompare})" + "syntaxDescription": "Variables denoted as {variable}. If statements can evaluate truthfulness (e.g. {ifTruth}) or make basic comparisons (e.g. {ifCompare})", + "info": "Info", + "debug": "Debug", + "warn": "Warn", + "error": "Error", + "fatal": "Fatal", + "severity": "Severity" }, "notifications": { "pathCopied": "Full path copied to clipboard.", diff --git a/lang/common/en-us.json b/lang/common/en-us.json index 8497f11..3a9bde4 100644 --- a/lang/common/en-us.json +++ b/lang/common/en-us.json @@ -46,7 +46,12 @@ "inviteRemainingUses": "Remaining uses", "internal": "Internal", "external": "External", - "failed": "Failed" + "failed": "Failed", + "details": "Details", + "type": "Type", + "other": "Other", + "back": "Back", + "none": "None" }, "notifications": { "errorLoginBlank": "The username and/or password were left blank.", diff --git a/models.go b/models.go index 897229d..feade6b 100644 --- a/models.go +++ b/models.go @@ -174,15 +174,31 @@ type respUser struct { ReferralsEnabled bool `json:"referrals_enabled"` } +// ServerSearchReqDTO is a usual SortablePaginatedReqDTO with added fields for searching and filtering. +type ServerSearchReqDTO struct { + SortablePaginatedReqDTO + ServerFilterReqDTO +} + +// ServerFilterReqDTO provides search terms and queries to a search or count route. +type ServerFilterReqDTO struct { + SearchTerms []string `json:"searchTerms"` + Queries []QueryDTO `json:"queries"` +} + type PaginatedDTO struct { LastPage bool `json:"last_page"` } -type PaginatedReqDTO struct { - Limit int `json:"limit"` - Page int `json:"page"` // zero-indexed +type SortablePaginatedReqDTO struct { SortByField string `json:"sortByField"` Ascending bool `json:"ascending"` + PaginatedReqDTO +} + +type PaginatedReqDTO struct { + Limit int `json:"limit"` + Page int `json:"page"` // zero-indexed } type getUsersDTO struct { @@ -520,6 +536,11 @@ type ActivityLogEntriesDTO struct { Entries []ActivityLogEntryDTO `json:"entries"` } +type PaginatedActivityLogEntriesDTO struct { + ActivityLogEntriesDTO + PaginatedDTO +} + type ActivityLogEntryDTO struct { mediabrowser.ActivityLogEntry Date int64 `json:"Date"` diff --git a/router.go b/router.go index cb658b0..f4c187b 100644 --- a/router.go +++ b/router.go @@ -207,6 +207,8 @@ func (app *appContext) loadRoutes(router *gin.Engine) { api.POST(p+"/users/extend", app.ExtendExpiry) api.DELETE(p+"/users/:id/expiry", app.RemoveExpiry) api.GET(p+"/users/:id/activities/jellyfin", app.GetJFActivitesForUser) + api.GET(p+"/users/:id/activities/jellyfin/count", app.CountJFActivitesForUser) + api.POST(p+"/users/:id/activities/jellyfin", app.GetPaginatedJFActivitesForUser) api.POST(p+"/users/enable", app.EnableDisableUsers) api.POST(p+"/invites", app.GenerateInvite) api.GET(p+"/invites", app.GetInvites) diff --git a/ts/modules/accounts.ts b/ts/modules/accounts.ts index c216363..ce607c4 100644 --- a/ts/modules/accounts.ts +++ b/ts/modules/accounts.ts @@ -16,6 +16,7 @@ import { DiscordUser, newDiscordSearch } from "../modules/discord"; import { SearchConfiguration, QueryType, SearchableItem, SearchableItemDataAttribute } from "../modules/search"; import { HiddenInputField } from "./ui"; import { PaginatedList } from "./list"; +import { TableRow } from "./row"; declare var window: GlobalWindow; @@ -177,9 +178,8 @@ const queries = (): { [field: string]: QueryType } => { }; }; -class User implements UserDTO, SearchableItem { +class User extends TableRow implements UserDTO, SearchableItem { private _id = ""; - private _row: HTMLTableRowElement; private _check: HTMLInputElement; private _username: HTMLSpanElement; private _admin: HTMLSpanElement; @@ -692,12 +692,11 @@ class User implements UserDTO, SearchableItem { private _uncheckEvent = () => new CustomEvent("accountUncheckEvent", { detail: this.id }); constructor(user: UserDTO) { - this._row = document.createElement("tr") as HTMLTableRowElement; - this._row.classList.add("border-b", "border-dashed", "dark:border-dotted", "dark:border-stone-700"); + super(); let innerHTML = `
- +
@@ -743,6 +742,15 @@ class User implements UserDTO, SearchableItem { this._check = this._row.querySelector("input[type=checkbox].accounts-select-user") as HTMLInputElement; this._accounts_admin = this._row.querySelector("input[type=checkbox].accounts-access-jfa") as HTMLInputElement; this._username = this._row.querySelector(".accounts-username") as HTMLSpanElement; + this._username.onclick = () => + document.dispatchEvent( + new CustomEvent("accounts-show-details", { + detail: { + username: this.name, + id: this.id, + }, + }), + ); this._admin = this._row.querySelector(".accounts-admin") as HTMLSpanElement; this._disabled = this._row.querySelector(".accounts-disabled") as HTMLSpanElement; this._email = this._row.querySelector(".accounts-email-container") as HTMLInputElement; @@ -922,14 +930,11 @@ class User implements UserDTO, SearchableItem { this.referrals_enabled = user.referrals_enabled; }; - asElement = (): HTMLTableRowElement => { - return this._row; - }; remove = () => { if (this.selected) { document.dispatchEvent(this._uncheckEvent()); } - this._row.remove(); + super.remove(); }; } @@ -950,6 +955,8 @@ declare interface ExtendExpiryDTO { } export class accountsList extends PaginatedList { + private _details: UserInfo; + private _table = document.getElementById("accounts-table") as HTMLTableElement; protected _container = document.getElementById("accounts-list") as HTMLTableSectionElement; private _addUserButton = document.getElementById("accounts-add-user") as HTMLSpanElement; @@ -1000,8 +1007,8 @@ export class accountsList extends PaginatedList { private _selectAllState: SelectAllState = SelectAllState.None; // private _users: { [id: string]: user }; // private _ordering: string[] = []; - get users(): { [id: string]: User } { - return this._search.items as { [id: string]: User }; + get users(): Map { + return this._search.items as Map; } // set users(v: { [id: string]: user }) { this._search.items = v as SearchableItems; } @@ -1040,6 +1047,27 @@ export class accountsList extends PaginatedList { } }; + details(username: string, jfId: string) { + this.unbindPageEvents(); + console.debug("Loading details for ", username, jfId); + this._details.load( + username, + jfId, + () => { + this.unbindPageEvents(); + this._table.classList.add("unfocused"); + this._details.hidden = false; + this.processSelectedAccounts(); + }, + () => { + this._details.hidden = true; + this._table.classList.remove("unfocused"); + this.processSelectedAccounts(); + this.bindPageEvents(); + }, + ); + } + constructor() { super({ loader: document.getElementById("accounts-loader"), @@ -1060,8 +1088,8 @@ export class accountsList extends PaginatedList { maxItemsLoadedForSearch: 200, appendNewItems: (resp: paginatedDTO) => { for (let u of (resp as UsersDTO).users || []) { - if (u.id in this.users) { - this.users[u.id].update(u); + if (this.users.has(u.id)) { + this.users.get(u.id).update(u); } else { this.add(u); } @@ -1074,15 +1102,15 @@ export class accountsList extends PaginatedList { ); }, replaceWithNewItems: (resp: paginatedDTO) => { - let accountsOnDOM: { [id: string]: boolean } = {}; + let accountsOnDOM = new Map(); - for (let id of Object.keys(this.users)) { - accountsOnDOM[id] = true; + for (let id of this.users.keys()) { + accountsOnDOM.set(id, true); } for (let u of (resp as UsersDTO).users || []) { - if (u.id in accountsOnDOM) { - this.users[u.id].update(u); - delete accountsOnDOM[u.id]; + if (accountsOnDOM.has(u.id)) { + this.users.get(u.id).update(u); + accountsOnDOM.delete(u.id); } else { this.add(u); } @@ -1090,9 +1118,9 @@ export class accountsList extends PaginatedList { // Delete accounts w/ remaining IDs (those not in resp.users) // console.log("Removing", Object.keys(accountsOnDOM).length, "from DOM"); - for (let id in accountsOnDOM) { - this.users[id].remove(); - delete this.users[id]; + for (let id of accountsOnDOM.keys()) { + this.users.get(id).remove(); + this.users.delete(id); } this._search.setOrdering( @@ -1404,6 +1432,11 @@ export class accountsList extends PaginatedList { this.registerURLListener(); + this._details = new UserInfo(document.getElementsByClassName("accounts-details")[0] as HTMLElement); + document.addEventListener("accounts-show-details", (ev: ShowDetailsEvent) => { + this.details(ev.detail.username, ev.detail.id); + }); + // Get rid of nasty CSS window.modals.announce.onclose = () => { const preview = document.getElementById("announce-preview") as HTMLDivElement; @@ -1448,8 +1481,8 @@ export class accountsList extends PaginatedList { if (next == SelectAllState.None) { // Deselect -all- users, rather than just visible ones, to be safe. - for (let id in this.users) { - this.users[id].setSelected(false, false); + for (let id of this.users.keys()) { + this.users.get(id).setSelected(false, false); } this._selectAll.checked = false; this._selectAll.indeterminate = false; @@ -1461,7 +1494,7 @@ export class accountsList extends PaginatedList { const selectAllVisible = () => { let count = 0; for (let id of this._visible) { - this.users[id].setSelected(true, false); + this.users.get(id).setSelected(true, false); count++; } console.debug("Selected", count); @@ -1495,15 +1528,14 @@ export class accountsList extends PaginatedList { for (let id of this._search.ordering) { if (!(inRange || id == startID)) continue; inRange = true; - if (!this._container.contains(this.users[id].asElement())) continue; - this.users[id].selected = true; + if (!this._container.contains(this.users.get(id).asElement())) continue; + this.users.get(id).selected = true; if (id == endID) return; } }; add = (u: UserDTO) => { - let domAccount = new User(u); - this.users[u.id] = domAccount; + this.users.set(u.id, new User(u)); // console.log("after appending lengths:", Object.keys(this.users).length, Object.keys(this._search.items).length); }; @@ -1551,35 +1583,36 @@ export class accountsList extends PaginatedList { if (window.emailEnabled || window.telegramEnabled) { this._announceButton.parentElement.classList.remove("unfocused"); } + let anyNonExpiries = list.length == 0 ? true : false; let allNonExpiries = true; let noContactCount = 0; - let referralState = Number(this.users[list[0]].referrals_enabled); // -1 = hide, 0 = show "enable", 1 = show "disable" + let referralState = Number(this.users.get(list[0]).referrals_enabled); // -1 = hide, 0 = show "enable", 1 = show "disable" // Only show enable/disable button if all selected have the same state. - this._shouldEnable = this.users[list[0]].disabled; + this._shouldEnable = this.users.get(list[0]).disabled; let showDisableEnable = true; for (let id of list) { - if (!anyNonExpiries && !this.users[id].expiry) { + if (!anyNonExpiries && !this.users.get(id).expiry) { anyNonExpiries = true; this._expiryDropdown.classList.add("unfocused"); } - if (this.users[id].expiry) { + if (this.users.get(id).expiry) { allNonExpiries = false; } - if (showDisableEnable && this.users[id].disabled != this._shouldEnable) { + if (showDisableEnable && this.users.get(id).disabled != this._shouldEnable) { showDisableEnable = false; this._disableEnable.parentElement.classList.add("unfocused"); } if (!showDisableEnable && anyNonExpiries) { break; } - if (!this.users[id].lastNotifyMethod()) { + if (!this.users.get(id).lastNotifyMethod()) { noContactCount++; } if ( window.referralsEnabled && referralState != -1 && - Number(this.users[id].referrals_enabled) != referralState + Number(this.users.get(id).referrals_enabled) != referralState ) { referralState = -1; } @@ -1638,9 +1671,10 @@ export class accountsList extends PaginatedList { }; private _collectUsers = (): string[] => { + if (!this._details.hidden && this._details.jfId != "") return [this._details.jfId]; let list: string[] = []; for (let id of this._visible) { - if (this.users[id].selected) { + if (this.users.get(id).selected) { list.push(id); } } @@ -1993,7 +2027,7 @@ export class accountsList extends PaginatedList { let list = this._collectUsers(); let manualUser: User; for (let id of list) { - let user = this.users[id]; + let user = this.users.get(id); if (!user.lastNotifyMethod() && !user.email) { manualUser = user; break; @@ -2065,8 +2099,8 @@ export class accountsList extends PaginatedList { (() => { let innerHTML = ""; - for (let id in this.users) { - innerHTML += ``; + for (let id of this.users.keys()) { + innerHTML += ``; } this._userSelect.innerHTML = innerHTML; })(); @@ -2137,7 +2171,7 @@ export class accountsList extends PaginatedList { let list = this._collectUsers(); // Check if we're disabling or enabling - if (this.users[list[0]].referrals_enabled) { + if (this.users.get(list[0]).referrals_enabled) { _delete("/users/referral", { users: list }, (req: XMLHttpRequest) => { if (req.readyState != 4 || req.status != 200) return; window.notifications.customSuccess( @@ -2285,8 +2319,8 @@ export class accountsList extends PaginatedList { let id = users.length > 0 ? users[0] : ""; if (!id) invalid = true; else { - date = new Date(this.users[id].expiry * 1000); - if (this.users[id].expiry == 0) date = new Date(); + date = new Date(this.users.get(id).expiry * 1000); + if (this.users.get(id).expiry == 0) date = new Date(); date.setMonth(date.getMonth() + +fields[0].value); date.setDate(date.getDate() + +fields[1].value); date.setHours(date.getHours() + +fields[2].value); @@ -2429,7 +2463,7 @@ export class accountsList extends PaginatedList { this._c.searchBox.value = `id:"${userID}"`; this._search.onSearchBoxChange(); this._search.onServerSearch(); - if (userID in this.users) this.users[userID].focus(); + if (userID in this.users) this.users.get(userID).focus(); }; public static readonly _accountURLEvent = "account-url"; @@ -2545,12 +2579,12 @@ class Column { } // Sorts the user list. previouslyActive is whether this column was previously sorted by, indicating that the direction should change. - sort = (users: { [id: string]: User }): string[] => { - let userIDs = Object.keys(users); + sort = (users: Map): string[] => { + let userIDs = Array.from(users.keys()); userIDs.sort((a: string, b: string): number => { - let av: GetterReturnType = this._getter.call(users[a]); + let av: GetterReturnType = this._getter.call(users.get(a)); if (typeof av === "string") av = av.toLowerCase(); - let bv: GetterReturnType = this._getter.call(users[b]); + let bv: GetterReturnType = this._getter.call(users.get(b)); if (typeof bv === "string") bv = bv.toLowerCase(); if (av < bv) return this._ascending ? -1 : 1; if (av > bv) return this._ascending ? 1 : -1; @@ -2561,7 +2595,17 @@ class Column { }; } -type ActivitySeverity = "Info" | "Debug" | "Warn" | "Error" | "Fatal"; +type ActivitySeverity = + | "Fatal" + | "None" + | "Trace" + | "Debug" + | "Information" + | "Info" + | "Warn" + | "Warning" + | "Error" + | "Critical"; interface ActivityLogEntryDTO { Id: number; Name: string; @@ -2574,3 +2618,380 @@ interface ActivityLogEntryDTO { UserPrimaryImageTag: string; Severity: ActivitySeverity; } +interface PaginatedActivityLogEntriesDTO { + entries: ActivityLogEntryDTO[]; + last_page: boolean; +} + +class ActivityLogEntry extends TableRow implements ActivityLogEntryDTO, SearchableItem { + private _e: ActivityLogEntryDTO; + private _username: string; + private _severity: HTMLElement; + private _user: HTMLElement; + private _name: HTMLElement; + private _type: HTMLElement; + private _overview: HTMLElement; + private _time: HTMLElement; + + update = (user: string | null, entry: ActivityLogEntryDTO) => { + this._e = entry; + if (user != null) this.User = user; + this.Id = entry.Id; + this.Name = entry.Name; + this.Overview = entry.Overview; + this.ShortOverview = entry.ShortOverview; + this.Type = entry.Type; + this.ItemId = entry.ItemId; + this.Date = entry.Date; + this.UserId = entry.UserId; + this.UserPrimaryImageTag = entry.UserPrimaryImageTag; + this.Severity = entry.Severity; + }; + + constructor(user: string, entry: ActivityLogEntryDTO) { + super(); + this._row.innerHTML = ` + +
+ + + + `; + this._severity = this._row.getElementsByClassName("jf-activity-log-severity")[0] as HTMLElement; + this._user = this._row.getElementsByClassName("jf-activity-log-user")[0] as HTMLElement; + this._name = this._row.getElementsByClassName("jf-activity-log-name")[0] as HTMLElement; + this._type = this._row.getElementsByClassName("jf-activity-log-type")[0] as HTMLElement; + this._overview = this._row.getElementsByClassName("jf-activity-log-overview")[0] as HTMLElement; + this._time = this._row.getElementsByClassName("jf-activity-log-time")[0] as HTMLElement; + this.update(user, entry); + } + + matchesSearch = (query: string): boolean => { + return ( + ("" + this.Id).includes(query) || + this.User.includes(query) || + this.UserId.includes(query) || + this.Name.includes(query) || + this.Overview.includes(query) || + this.ShortOverview.includes(query) || + this.Type.includes(query) || + this.ItemId.includes(query) || + this.UserId.includes(query) || + this.Severity.includes(query) + ); + }; + + get Id(): number { + return this._e.Id; + } + set Id(v: number) { + this._e.Id = v; + } + + private setName() { + const space = this.Name.indexOf(" "); + let nameContent = this.Name; + let endOfUserBadge = ":"; + if (space != -1 && this.User != "" && nameContent.substring(0, space) == this.User) { + endOfUserBadge = ""; + nameContent = nameContent.substring(space + 1, nameContent.length); + } + if (this.User == "") this._user.classList.add("unfocused"); + else this._user.classList.remove("unfocused"); + this._user.textContent = this.User + endOfUserBadge; + this._name.textContent = nameContent; + this._name.title = nameContent; + } + + // User is the username of the user. It is not part of an ActivityLogEntryDTO, rather the UserId is, but in most contexts the parent should know it anyway. + get User(): string { + return this._username; + } + set User(v: string) { + this._username = v; + this.setName(); + } + + // Name is a description of the entry. It often starts with the name of the user, so + get Name(): string { + return this._e.Name; + } + set Name(v: string) { + this._e.Name = v; + this.setName(); + } + + // "Overview" doesn't seem to be used, but we'll let it take precedence anyway. + private setOverview() { + this._overview.textContent = this.Overview || this.ShortOverview; + } + + // Overview is something, but I haven't seen it actually used. + get Overview(): string { + return this._e.Overview; + } + + set Overview(v: string) { + this._e.Overview = v; + this.setOverview(); + } + + // ShortOverview usually seems to be the IP address of the user in applicable entries. + get ShortOverview(): string { + return this._e.ShortOverview; + } + set ShortOverview(v: string) { + this._e.ShortOverview = v; + this.setOverview(); + } + + get Type(): string { + return this._e.Type; + } + set Type(v: string) { + this._e.Type = v; + this._type.textContent = v; + } + + get ItemId(): string { + return this._e.ItemId; + } + set ItemId(v: string) { + this._e.ItemId = v; + } + + get Date(): number { + return this._e.Date; + } + set Date(v: number) { + this._e.Date = v; + this._time.textContent = toDateString(new Date(v * 1000)); + } + + get UserId(): string { + return this._e.UserId; + } + set UserId(v: string) { + this._e.UserId = v; + } + + get UserPrimaryImageTag(): string { + return this._e.UserPrimaryImageTag; + } + set UserPrimaryImageTag(v: string) { + this._e.UserPrimaryImageTag = v; + } + + get Severity(): ActivitySeverity { + return this._e.Severity; + } + set Severity(v: ActivitySeverity) { + this._e.Severity = v; + if (v) this._severity.classList.remove("unfocused"); + else this._severity.classList.add("unfocused"); + ["~neutral", "~positive", "~warning", "~critical", "~info", "~urge"].forEach((c) => + this._severity.classList.remove(c), + ); + switch (v) { + case "Info": + case "Information": + this._severity.textContent = window.lang.strings("info"); + this._severity.classList.add("~info"); + break; + case "Debug": + case "Trace": + this._severity.textContent = window.lang.strings("debug"); + this._severity.classList.add("~urge"); + break; + case "Warn": + case "Warning": + this._severity.textContent = window.lang.strings("warn"); + this._severity.classList.add("~warning"); + break; + case "Error": + this._severity.textContent = window.lang.strings("error"); + this._severity.classList.add("~critical"); + break; + case "Critical": + case "Fatal": + this._severity.textContent = window.lang.strings("fatal"); + this._severity.classList.add("~critical"); + break; + case "None": + this._severity.textContent = window.lang.strings("none"); + this._severity.classList.add("~neutral"); + default: + console.warn("Unknown key in activity severity:", v); + this._severity.textContent = v; + this._severity.classList.add("~neutral"); + } + } +} + +interface ShowDetailsEvent extends Event { + detail: { + username: string; + id: string; + }; +} + +class UserInfo extends PaginatedList { + private _card: HTMLElement; + get entries(): Map { + return this._search.items as Map; + } + private _back: HTMLButtonElement; + username: string; + jfId: string; + constructor(card: HTMLElement) { + card.classList.add("unfocused"); + card.innerHTML = ` +
+
+ +
+
+
+
+ + + + + + + + + + + +
${window.lang.strings("severity")}${window.lang.strings("details")}${window.lang.strings("type")}${window.lang.strings("other")}${window.lang.strings("date")}
+
+
+ + +
+
+ `; + super({ + loader: card.getElementsByClassName("jf-activity-loader")[0] as HTMLElement, + loadMoreButtons: Array.from( + document.getElementsByClassName("jf-activity-load-more"), + ) as Array, + loadAllButtons: Array.from( + document.getElementsByClassName("jf-activity-load-all"), + ) as Array, + totalEndpoint: () => "/users/" + this.jfId + "/activities/jellyfin/count", + getPageEndpoint: () => "/users/" + this.jfId + "/activities/jellyfin", + itemsPerPage: 20, + maxItemsLoadedForSearch: 200, + disableSearch: true, + appendNewItems: (resp: paginatedDTO) => { + for (let entry of (resp as PaginatedActivityLogEntriesDTO).entries || []) { + if (this.entries.has("" + entry.Id)) { + this.entries.get("" + entry.Id).update(null, entry); + } else { + this.add(entry); + } + } + + this._search.setOrdering(Array.from(this.entries.keys()), "Date", true); + }, + replaceWithNewItems: (resp: paginatedDTO) => { + let entriesOnDOM = new Map(); + + for (let id of this.entries.keys()) { + entriesOnDOM.set(id, true); + } + for (let entry of (resp as PaginatedActivityLogEntriesDTO).entries || []) { + if (entriesOnDOM.has("" + entry.Id)) { + this.entries.get("" + entry.Id).update(null, entry); + entriesOnDOM.delete("" + entry.Id); + } else { + this.add(entry); + } + } + + // Delete entries w/ remaining IDs (those not in resp.entries) + // console.log("Removing", Object.keys(entriesOnDOM).length, "from DOM"); + for (let id of entriesOnDOM.keys()) { + this.entries.get(id).remove(); + this.entries.delete(id); + } + + this._search.setOrdering(Array.from(this.entries.keys()), "Date", true); + }, + }); + this._card = card; + this._container = this._card.getElementsByClassName("jf-activity-table-content")[0] as HTMLElement; + this._back = this._card.getElementsByClassName("user-details-back")[0] as HTMLButtonElement; + + let searchConfig: SearchConfiguration = { + queries: {}, + setVisibility: null, + searchServer: null, + clearServerSearch: null, + onSearchCallback: (_0: boolean, _1: boolean) => {}, + }; + + this.initSearch(searchConfig); + } + + add = (entry: ActivityLogEntryDTO) => { + this.entries.set("" + entry.Id, new ActivityLogEntry(this.username, entry)); + }; + + loadMore = (loadAll: boolean = false, callback?: (resp?: paginatedDTO) => void) => { + this._loadMore(loadAll, callback); + }; + + loadAll = (callback?: (resp?: paginatedDTO) => void) => { + this._loadAll(callback); + }; + + reload = (callback?: (resp: paginatedDTO) => void) => { + this._reload(callback); + }; + + load = (username: string, jfId: string, onLoad?: () => void, onBack?: () => void) => { + this.username = username; + this.jfId = jfId; + if (onBack) { + this._back.classList.remove("unfocused"); + this._back.onclick = () => onBack(); + } else { + this._back.classList.add("unfocused"); + this._back.onclick = null; + } + this.reload(onLoad); + /*_get("/users/" + jfId + "/activities/jellyfin", null, (req: XMLHttpRequest) => { + if (req.readyState != 4) return; + if (req.status != 200) { + window.notifications.customError("errorLoadJFActivities", window.lang.notif("errorLoadActivities")); + return; + } + // FIXME: Lazy loading table + this._table.textContent = ``; + let entries = (req.response as PaginatedActivityLogEntriesDTO).entries; + for (let entry of entries) { + const row = new ActivityLogEntry(username, entry); + this._table.appendChild(row.asElement()); + } + if (onLoad) onLoad(); + });*/ + }; + + get hidden(): boolean { + return this._card.classList.contains("unfocused"); + } + set hidden(v: boolean) { + if (v) { + this.unbindPageEvents(); + this._card.classList.add("unfocused"); + this.username = ""; + this.jfId = ""; + } else { + this.bindPageEvents(); + this._card.classList.remove("unfocused"); + } + } +} diff --git a/ts/modules/activity.ts b/ts/modules/activity.ts index 1bce7d0..c0d1fbc 100644 --- a/ts/modules/activity.ts +++ b/ts/modules/activity.ts @@ -564,8 +564,8 @@ export class activityList extends PaginatedList { protected _ascending: boolean; - get activities(): { [id: string]: Activity } { - return this._search.items as { [id: string]: Activity }; + get activities(): Map { + return this._search.items as Map; } // set activities(v: { [id: string]: Activity }) { this._search.items = v as SearchableItems; } @@ -590,7 +590,7 @@ export class activityList extends PaginatedList { appendNewItems: (resp: paginatedDTO) => { let ordering: string[] = this._search.ordering; for (let act of (resp as ActivitiesDTO).activities || []) { - this.activities[act.id] = new Activity(act); + this.activities.set(act.id, new Activity(act)); ordering.push(act.id); } this._search.setOrdering(ordering, this._c.defaultSortField, this.ascending); @@ -599,9 +599,7 @@ export class activityList extends PaginatedList { // FIXME: Implement updates to existing elements, rather than just wiping each time. // Remove existing items - for (let id of Object.keys(this.activities)) { - delete this.activities[id]; - } + this.activities.clear(); // And wipe their ordering this._search.setOrdering([], this._c.defaultSortField, this.ascending); this._c.appendNewItems(resp); diff --git a/ts/modules/list.ts b/ts/modules/list.ts index 823c469..b975869 100644 --- a/ts/modules/list.ts +++ b/ts/modules/list.ts @@ -18,18 +18,20 @@ export class RecordCounter { private _loaded: number; private _shown: number; private _selected: number; - constructor(container: HTMLElement) { - this._container = container; - this._container.innerHTML = ` + constructor(container?: HTMLElement) { + if (container) { + this._container = container; + this._container.innerHTML = ` `; - this._totalRecords = this._container.getElementsByClassName("records-total")[0] as HTMLElement; - this._loadedRecords = this._container.getElementsByClassName("records-loaded")[0] as HTMLElement; - this._shownRecords = this._container.getElementsByClassName("records-shown")[0] as HTMLElement; - this._selectedRecords = this._container.getElementsByClassName("records-selected")[0] as HTMLElement; + this._totalRecords = this._container.getElementsByClassName("records-total")[0] as HTMLElement; + this._loadedRecords = this._container.getElementsByClassName("records-loaded")[0] as HTMLElement; + this._shownRecords = this._container.getElementsByClassName("records-shown")[0] as HTMLElement; + this._selectedRecords = this._container.getElementsByClassName("records-selected")[0] as HTMLElement; + } this.total = 0; this.loaded = 0; this.shown = 0; @@ -55,7 +57,7 @@ export class RecordCounter { } set total(v: number) { this._total = v; - this._totalRecords.textContent = window.lang.var("strings", "totalRecords", `${v}`); + if (this._totalRecords) this._totalRecords.textContent = window.lang.var("strings", "totalRecords", `${v}`); } get loaded(): number { @@ -63,7 +65,7 @@ export class RecordCounter { } set loaded(v: number) { this._loaded = v; - this._loadedRecords.textContent = window.lang.var("strings", "loadedRecords", `${v}`); + if (this._loadedRecords) this._loadedRecords.textContent = window.lang.var("strings", "loadedRecords", `${v}`); } get shown(): number { @@ -71,7 +73,7 @@ export class RecordCounter { } set shown(v: number) { this._shown = v; - this._shownRecords.textContent = window.lang.var("strings", "shownRecords", `${v}`); + if (this._shownRecords) this._shownRecords.textContent = window.lang.var("strings", "shownRecords", `${v}`); } get selected(): number { @@ -79,8 +81,10 @@ export class RecordCounter { } set selected(v: number) { this._selected = v; - if (v == 0) this._selectedRecords.textContent = ``; - else this._selectedRecords.textContent = window.lang.var("strings", "selectedRecords", `${v}`); + if (this._selectedRecords) { + if (v == 0) this._selectedRecords.textContent = ``; + else this._selectedRecords.textContent = window.lang.var("strings", "selectedRecords", `${v}`); + } } } @@ -88,20 +92,21 @@ export interface PaginatedListConfig { loader: HTMLElement; loadMoreButtons: Array; loadAllButtons: Array; - refreshButton: HTMLButtonElement; - filterArea: HTMLElement; - searchOptionsHeader: HTMLElement; - searchBox: HTMLInputElement; - recordCounter: HTMLElement; - totalEndpoint: string; - getPageEndpoint: string; + refreshButton?: HTMLButtonElement; + filterArea?: HTMLElement; + searchOptionsHeader?: HTMLElement; + searchBox?: HTMLInputElement; + recordCounter?: HTMLElement; + totalEndpoint: string | (() => string); + getPageEndpoint: string | (() => string); itemsPerPage: number; maxItemsLoadedForSearch: number; appendNewItems: (resp: paginatedDTO) => void; replaceWithNewItems: (resp: paginatedDTO) => void; - defaultSortField: string; - defaultSortAscending: boolean; + defaultSortField?: string; + defaultSortAscending?: boolean; pageLoadCallback?: (req: XMLHttpRequest) => void; + disableSearch?: boolean; } export abstract class PaginatedList { @@ -186,10 +191,11 @@ export abstract class PaginatedList { this.loadMore(() => removeLoader(this._keepSearchingButton, true)); }; */ // Since this.reload doesn't exist, we need an arrow function to wrap it. - this._c.refreshButton.onclick = () => this.reload(); + if (this._c.refreshButton) this._c.refreshButton.onclick = () => this.reload(); } autoSetServerSearchButtonsDisabled = () => { + if (!this._search) return; const serverSearchSortChanged = this._search.inServerSearch && (this._searchParams.sortByField != this._search.sortField || @@ -259,7 +265,7 @@ export abstract class PaginatedList { }; searchConfig.setVisibility = this.setVisibility; this._search = new Search(searchConfig); - this._search.generateFilterList(); + if (!this._c.disableSearch) this._search.generateFilterList(); this.lastPage = false; }; @@ -270,7 +276,7 @@ export abstract class PaginatedList { // else this._visible = this._search.ordering.filter(v => !elements.includes(v)); // const frag = document.createDocumentFragment() // for (let i = 0; i < this._visible.length; i++) { - // frag.appendChild(this._search.items[this._visible[i]].asElement()) + // frag.appendChild(this._search.items.get(this._visible[i]).asElement()) // } // this._container.replaceChildren(frag); // if (this._search.timeSearches) { @@ -295,7 +301,7 @@ export abstract class PaginatedList { if (!appendedItems) { // Wipe old elements and render 1 new one, so we can take the element height. - this._container.replaceChildren(this._search.items[this._visible[0]].asElement()); + this._container.replaceChildren(this._search.items.get(this._visible[0]).asElement()); } this._computeScrollInfo(); @@ -317,7 +323,7 @@ export abstract class PaginatedList { } const frag = document.createDocumentFragment(); for (let i = baseIndex; i < this._scroll.initialRenderCount; i++) { - frag.appendChild(this._search.items[this._visible[i]].asElement()); + frag.appendChild(this._search.items.get(this._visible[i]).asElement()); } this._scroll.rendered = Math.max(baseIndex, this._scroll.initialRenderCount); // appendChild over replaceChildren because there's already elements on the DOM @@ -335,7 +341,7 @@ export abstract class PaginatedList { this._scroll.screenHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0); - this._scroll.rowHeight = this._search.items[this._visible[0]].asElement().offsetHeight; + this._scroll.rowHeight = this._search.items.get(this._visible[0]).asElement().offsetHeight; }; // returns the item index to render up to for the given scroll position. @@ -364,7 +370,7 @@ export abstract class PaginatedList { } _post( - this._c.getPageEndpoint, + typeof this._c.getPageEndpoint === "string" ? this._c.getPageEndpoint : this._c.getPageEndpoint(), params, (req: XMLHttpRequest) => { if (req.readyState != 4) return; @@ -397,7 +403,9 @@ export abstract class PaginatedList { protected _reload = (callback?: (resp: paginatedDTO) => void) => { this.lastPage = false; this._counter.reset(); - this._counter.getTotal(this._c.totalEndpoint); + this._counter.getTotal( + typeof this._c.totalEndpoint === "string" ? this._c.totalEndpoint : this._c.totalEndpoint(), + ); // Reload all currently visible elements, i.e. Load a new page of size (limit*(page+1)). let limit = this._c.itemsPerPage; if (this._page != 0) { @@ -409,8 +417,10 @@ export abstract class PaginatedList { this._c.replaceWithNewItems, (_0: paginatedDTO) => { // Allow refreshes every 15s - this._c.refreshButton.disabled = true; - setTimeout(() => (this._c.refreshButton.disabled = false), 15000); + if (this._c.refreshButton) { + this._c.refreshButton.disabled = true; + setTimeout(() => (this._c.refreshButton.disabled = false), 15000); + } }, (resp: paginatedDTO) => { this._search.onSearchBoxChange(true, false, false); @@ -507,7 +517,7 @@ export abstract class PaginatedList { const realEndIdx = Math.min(endIdx, this._visible.length); const frag = document.createDocumentFragment(); for (let i = this._scroll.rendered; i < realEndIdx; i++) { - frag.appendChild(this._search.items[this._visible[i]].asElement()); + frag.appendChild(this._search.items.get(this._visible[i]).asElement()); } this._scroll.rendered = realEndIdx; this._container.appendChild(frag); diff --git a/ts/modules/row.ts b/ts/modules/row.ts new file mode 100644 index 0000000..ec82af8 --- /dev/null +++ b/ts/modules/row.ts @@ -0,0 +1,15 @@ +export abstract class TableRow { + protected _row: HTMLTableRowElement; + + remove() { + this._row.remove(); + } + asElement(): HTMLTableRowElement { + return this._row; + } + + constructor() { + this._row = document.createElement("tr"); + this._row.classList.add("border-b", "border-dashed", "dark:border-dotted", "dark:border-stone-700"); + } +} diff --git a/ts/modules/search.ts b/ts/modules/search.ts index 590040b..e09bac1 100644 --- a/ts/modules/search.ts +++ b/ts/modules/search.ts @@ -33,16 +33,16 @@ export interface QueryType { } export interface SearchConfiguration { - filterArea: HTMLElement; + filterArea?: HTMLElement; sortingByButton?: HTMLButtonElement; - searchOptionsHeader: HTMLElement; - notFoundPanel: HTMLElement; - notFoundLocallyText: HTMLElement; + searchOptionsHeader?: HTMLElement; + notFoundPanel?: HTMLElement; + notFoundLocallyText?: HTMLElement; notFoundCallback?: (notFound: boolean) => void; - filterList: HTMLElement; - clearSearchButtonSelector: string; - serverSearchButtonSelector: string; - search: HTMLInputElement; + filterList?: HTMLElement; + clearSearchButtonSelector?: string; + serverSearchButtonSelector?: string; + search?: HTMLInputElement; queries: { [field: string]: QueryType }; setVisibility: (items: string[], visible: boolean, appendedItems: boolean) => void; onSearchCallback: (newItems: boolean, loadAll: boolean, callback?: (resp: paginatedDTO) => void) => void; @@ -276,14 +276,14 @@ export interface SearchableItem extends ListItem { export const SearchableItemDataAttribute = "data-search-item"; -export type SearchableItems = { [id: string]: SearchableItem }; +export type SearchableItems = Map; export class Search { private _c: SearchConfiguration; private _sortField: string = ""; private _ascending: boolean = true; private _ordering: string[] = []; - private _items: SearchableItems = {}; + private _items: SearchableItems = new Map(); // Search queries (filters) private _queries: Query[] = []; // Plain-text search terms @@ -435,7 +435,7 @@ export class Search { for (let term of searchTerms) { let cachedResult = [...result]; for (let id of cachedResult) { - const u = this.items[id]; + const u = this.items.get(id); if (!u.matchesSearch(term)) { result.splice(result.indexOf(id), 1); } @@ -444,14 +444,14 @@ export class Search { } for (let q of queries) { - this._c.filterArea.appendChild(q.asElement()); + this._c.filterArea?.appendChild(q.asElement()); // Skip if this query has already been performed by the server. if (this.inServerSearch && !q.localOnly) continue; let cachedResult = [...result]; if (q.type == "bool") { for (let id of cachedResult) { - const u = this.items[id]; + const u = this.items.get(id); // Remove from result if not matching query if (!q.compareItem(u)) { // console.log("not matching, result is", result); @@ -460,7 +460,7 @@ export class Search { } } else if (q.type == "string") { for (let id of cachedResult) { - const u = this.items[id]; + const u = this.items.get(id); // We want to compare case-insensitively, so we get value, lower-case it then compare, // rather than doing both with compareItem. const value = q.getValueFromItem(u).toLowerCase(); @@ -470,7 +470,7 @@ export class Search { } } else if (q.type == "date") { for (let id of cachedResult) { - const u = this.items[id]; + const u = this.items.get(id); // Getter here returns a unix timestamp rather than a date, so we can't use compareItem. const unixValue = q.getValueFromItem(u); if (unixValue == 0) { @@ -491,7 +491,7 @@ export class Search { // Returns a list of identifiers (used as keys in items, values in ordering). search = (query: string): string[] => { let timer = this.timeSearches ? performance.now() : null; - this._c.filterArea.textContent = ""; + if (this._c.filterArea) this._c.filterArea.textContent = ""; const [searchTerms, queries] = this.parseTokens(Search.tokenizeSearch(query)); @@ -515,16 +515,16 @@ export class Search { showHideSearchOptionsHeader = () => { let sortingBy = false; if (this._c.sortingByButton) sortingBy = !this._c.sortingByButton.classList.contains("hidden"); - const hasFilters = this._c.filterArea.textContent != ""; + const hasFilters = this._c.filterArea ? this._c.filterArea.textContent != "" : false; if (sortingBy || hasFilters) { - this._c.searchOptionsHeader.classList.remove("hidden"); + this._c.searchOptionsHeader?.classList.remove("hidden"); } else { - this._c.searchOptionsHeader.classList.add("hidden"); + this._c.searchOptionsHeader?.classList.add("hidden"); } }; // -all- elements. - get items(): { [id: string]: SearchableItem } { + get items(): Map { return this._items; } // set items(v: { [id: string]: SearchableItem }) { @@ -585,14 +585,14 @@ export class Search { setNotFoundPanelVisibility = (visible: boolean) => { if (this._inServerSearch || !this.inSearch) { - this._c.notFoundLocallyText.classList.add("unfocused"); + this._c.notFoundLocallyText?.classList.add("unfocused"); } else if (this.inSearch) { - this._c.notFoundLocallyText.classList.remove("unfocused"); + this._c.notFoundLocallyText?.classList.remove("unfocused"); } if (visible) { - this._c.notFoundPanel.classList.remove("unfocused"); + this._c.notFoundPanel?.classList.remove("unfocused"); } else { - this._c.notFoundPanel.classList.add("unfocused"); + this._c.notFoundPanel?.classList.add("unfocused"); } }; @@ -606,6 +606,7 @@ export class Search { }; generateFilterList = () => { + if (!this._c.filterList) return; const filterListContainer = document.createElement("div"); filterListContainer.classList.add("flex", "flex-row", "flex-wrap", "gap-2"); // Generate filter buttons @@ -723,6 +724,10 @@ export class Search { constructor(c: SearchConfiguration) { this._c = c; + if (!this._c.search) { + // Make a dummy one + this._c.search = document.createElement("input") as HTMLInputElement; + } this._c.search.oninput = () => { this.inServerSearch = false; @@ -734,20 +739,22 @@ export class Search { } }); - 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(); - }); + if (this._c.clearSearchButtonSelector) { + 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; + this._serverSearchButtons = this._c.serverSearchButtonSelector + ? (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/settings.ts b/ts/modules/settings.ts index 0cfcf5d..6974451 100644 --- a/ts/modules/settings.ts +++ b/ts/modules/settings.ts @@ -14,7 +14,6 @@ import { } from "../modules/common.js"; import { Marked } from "@ts-stack/markdown"; import { stripMarkdown } from "../modules/stripmd.js"; -import { PDT } from "src/data/timezoneNames"; declare var window: GlobalWindow; diff --git a/usercache.go b/usercache.go index e3a2670..a90cf82 100644 --- a/usercache.go +++ b/usercache.go @@ -599,18 +599,6 @@ func (q *QueryDTO) UnmarshalJSON(data []byte) error { return nil } -// ServerSearchReqDTO is a usual PaginatedReqDTO with added fields for searching and filtering. -type ServerSearchReqDTO struct { - PaginatedReqDTO - ServerFilterReqDTO -} - -// ServerFilterReqDTO provides search terms and queries to a search or count route. -type ServerFilterReqDTO struct { - SearchTerms []string `json:"searchTerms"` - Queries []QueryDTO `json:"queries"` -} - // Filter reduces the passed slice of *respUsers // by searching for each term of terms[] with respUser.MatchesSearch, // and by evaluating Queries with Query.AsFilter().