jf_activity: paginated list in ui

added POST route for pagination to activity route, a count route, and
modified Search and PaginatedList a bit to support lists without search
fields (essentially just running an empty search). Visible by clicking
on a user's name in the accounts tab.
This commit is contained in:
Harvey Tindall
2025-12-20 18:27:39 +00:00
parent d72a5c91cf
commit b6459b665d
16 changed files with 738 additions and 193 deletions

View File

@@ -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)
}

2
go.mod
View File

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

2
go.sum
View File

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

View File

@@ -806,7 +806,11 @@
<span class="button ~info @low center unfocused " id="accounts-send-pwr">{{ .strings.sendPWR }}</span>
<span class="button ~critical @low center " id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span>
</div>
<div class="card @low accounts-header overflow-x-scroll">
<div id="accounts-table-details-container">
<div class="card @low accounts-details unfocused">
<div id="accounts-loader"></div>
</div>
<div class="card @low accounts-header overflow-x-scroll" id="accounts-table">
<table class="table text-base leading-5">
<thead>
<tr>
@@ -858,6 +862,7 @@
</div>
</div>
</div>
</div>
<div id="tab-activity" class="flex flex-col gap-4 unfocused">
<div class="card @low dark:~d_neutral activity overflow-visible flex flex-col gap-2">
<div id="activity-filter-dropdown" class="dropdown manual z-10 w-full" tabindex="0">

View File

@@ -75,7 +75,7 @@
<span class="button ~warning h-min" alt="{{ .strings.theme }}" id="button-theme"><i class="ri-sun-line"></i></span>
<span class="button ~critical @low unfocused" id="logout-button">{{ .strings.logout }}</span>
</div>
<a class="button ~info unfocused h-min flex flex-row gap-2" href="/" id="admin-back-button"><i class="ri-arrow-left-fill"></i>{{ .strings.admin }}</a>
<a class="button ~info unfocused h-min flex flex-row gap-1" href="/" id="admin-back-button"><i class="ri-arrow-left-fill"></i>{{ .strings.admin }}</a>
</div>
<div class="card @low dark:~d_neutral" id="card-user">
<span class="heading flex flex-row gap-4"></span>

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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 SortablePaginatedReqDTO struct {
SortByField string `json:"sortByField"`
Ascending bool `json:"ascending"`
PaginatedReqDTO
}
type PaginatedReqDTO struct {
Limit int `json:"limit"`
Page int `json:"page"` // zero-indexed
SortByField string `json:"sortByField"`
Ascending bool `json:"ascending"`
}
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"`

View File

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

View File

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

View File

@@ -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<string, Activity> {
return this._search.items as Map<string, Activity>;
}
// 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);

View File

@@ -18,7 +18,8 @@ export class RecordCounter {
private _loaded: number;
private _shown: number;
private _selected: number;
constructor(container: HTMLElement) {
constructor(container?: HTMLElement) {
if (container) {
this._container = container;
this._container.innerHTML = `
<span class="records-total"></span>
@@ -30,6 +31,7 @@ export class RecordCounter {
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,29 +81,32 @@ export class RecordCounter {
}
set selected(v: number) {
this._selected = v;
if (this._selectedRecords) {
if (v == 0) this._selectedRecords.textContent = ``;
else this._selectedRecords.textContent = window.lang.var("strings", "selectedRecords", `${v}`);
}
}
}
export interface PaginatedListConfig {
loader: HTMLElement;
loadMoreButtons: Array<HTMLButtonElement>;
loadAllButtons: Array<HTMLButtonElement>;
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
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);

15
ts/modules/row.ts Normal file
View File

@@ -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");
}
}

View File

@@ -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<string, SearchableItem>;
export class Search {
private _c: SearchConfiguration;
private _sortField: string = "";
private _ascending: boolean = true;
private _ordering: string[] = [];
private _items: SearchableItems = {};
private _items: SearchableItems = new Map<string, SearchableItem>();
// 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<string, SearchableItem> {
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,6 +739,7 @@ export class Search {
}
});
if (this._c.clearSearchButtonSelector) {
const clearSearchButtons = Array.from(
document.querySelectorAll(this._c.clearSearchButtonSelector),
) as Array<HTMLSpanElement>;
@@ -744,10 +750,11 @@ export class Search {
this.onSearchBoxChange();
});
}
}
this._serverSearchButtons = Array.from(
document.querySelectorAll(this._c.serverSearchButtonSelector),
) as Array<HTMLSpanElement>;
this._serverSearchButtons = this._c.serverSearchButtonSelector
? (Array.from(document.querySelectorAll(this._c.serverSearchButtonSelector)) as Array<HTMLSpanElement>)
: [];
for (let b of this._serverSearchButtons) {
b.addEventListener("click", () => {
this.onServerSearch();

View File

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

View File

@@ -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().