mirror of
https://github.com/hrfee/jfa-go.git
synced 2026-01-18 16:47:42 +01:00
accounts: pagination, server-side search
Pagination fully factored out, and both Activities and Accounts now use a PaginatedList superclass. Server-side search is done by pressing enter after typing a search, or by pressing the search button. Works on accounts, soon will on activities if it doesn't already.
This commit is contained in:
@@ -97,7 +97,7 @@ func (app *appContext) GetActivities(gc *gin.Context) {
|
||||
req := GetActivitiesDTO{}
|
||||
gc.BindJSON(&req)
|
||||
query := &badgerhold.Query{}
|
||||
activityTypes := make([]interface{}, len(req.Type))
|
||||
activityTypes := make([]any, len(req.Type))
|
||||
for i, v := range req.Type {
|
||||
activityTypes[i] = stringToActivityType(v)
|
||||
}
|
||||
|
||||
30
api-users.go
30
api-users.go
@@ -902,7 +902,7 @@ func (app *appContext) userSummary(jfUser mediabrowser.User) respUser {
|
||||
// @tags Activity
|
||||
func (app *appContext) GetUserCount(gc *gin.Context) {
|
||||
resp := PageCountDTO{}
|
||||
userList, err := app.userCache.Gen(app)
|
||||
userList, err := app.userCache.Gen(app, false)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
|
||||
respond(500, "Couldn't get users", gc)
|
||||
@@ -924,13 +924,12 @@ func (app *appContext) GetUsers(gc *gin.Context) {
|
||||
// We're sending all users, so this is always true
|
||||
resp.LastPage = true
|
||||
var err error
|
||||
resp.UserList, err = app.userCache.Gen(app)
|
||||
resp.UserList, err = app.userCache.Gen(app, true)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
|
||||
respond(500, "Couldn't get users", gc)
|
||||
return
|
||||
}
|
||||
app.debug.Printf("sending usercache of length %d", len(resp.UserList))
|
||||
gc.JSON(200, resp)
|
||||
}
|
||||
|
||||
@@ -945,20 +944,31 @@ func (app *appContext) GetUsers(gc *gin.Context) {
|
||||
func (app *appContext) SearchUsers(gc *gin.Context) {
|
||||
req := getUsersReqDTO{}
|
||||
gc.BindJSON(&req)
|
||||
|
||||
// FIXME: Figure out how to search, sort and paginate []mediabrowser.User!
|
||||
// Expr!
|
||||
if req.SortByField == "" {
|
||||
req.SortByField = USER_DEFAULT_SORT_FIELD
|
||||
}
|
||||
|
||||
var resp getUsersDTO
|
||||
// We're sending all users, so this is always true
|
||||
resp.LastPage = true
|
||||
var err error
|
||||
resp.UserList, err = app.userCache.Gen(app)
|
||||
userList, err := app.userCache.Gen(app, false)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
|
||||
respond(500, "Couldn't get users", gc)
|
||||
return
|
||||
}
|
||||
var filtered []*respUser
|
||||
if (req.SearchTerms != nil && len(req.SearchTerms) != 0) || (req.Queries != nil && len(req.Queries) != 0) {
|
||||
filtered = app.userCache.Filter(userList, req.SearchTerms, req.Queries)
|
||||
} else {
|
||||
filtered = userList
|
||||
}
|
||||
app.userCache.Sort(filtered, req.SortByField, req.Ascending)
|
||||
|
||||
startIndex := (req.Page * req.Limit)
|
||||
if startIndex < len(filtered) {
|
||||
endIndex := min(startIndex+req.Limit, len(filtered))
|
||||
resp.UserList = filtered[startIndex:endIndex]
|
||||
}
|
||||
resp.LastPage = len(resp.UserList) != req.Limit
|
||||
gc.JSON(200, resp)
|
||||
}
|
||||
|
||||
|
||||
@@ -722,9 +722,14 @@
|
||||
<span class="text-3xl font-bold mr-4">{{ .strings.accounts }}</span>
|
||||
<span class="dropdown-manual-toggle"><button class="h-full button ~neutral @low center" id="accounts-filter-button" tabindex="0">{{ .strings.filters }}</button></span>
|
||||
</div>
|
||||
<div class="flex flex-row align-middle w-full">
|
||||
<div class="flex flex-row align-middle w-full gap-2">
|
||||
<input type="search" class="field ~neutral @low input search mr-2" id="accounts-search" placeholder="{{ .strings.search }}">
|
||||
<span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none accounts-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
|
||||
<div class="tooltip left">
|
||||
<span class="button ~info @low center h-full accounts-search-server" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}"><i class="ri-search-line"></i></span>
|
||||
<span class="content sm">{{ .strings.searchAllRecords }}</span>
|
||||
</div>
|
||||
<button class="button ~info @low" id="accounts-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropdown-display max-w-full">
|
||||
@@ -803,14 +808,23 @@
|
||||
</thead>
|
||||
<tbody id="accounts-list"></tbody>
|
||||
</table>
|
||||
<div id="accounts-loader"></div>
|
||||
<div class="unfocused h-[100%] my-3" id="accounts-not-found">
|
||||
<div class="flex flex-col h-[100%] justify-center items-center">
|
||||
<span class="text-2xl font-medium italic mb-3">{{ .strings.noResultsFound }}</span>
|
||||
<button class="button ~neutral @low accounts-search-clear">
|
||||
<span class="mr-2">{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
|
||||
</button>
|
||||
<span class="text-xl font-medium italic mb-3 unfocused" id="accounts-keep-searching-description">{{ .strings.keepSearchingDescription }}</span>
|
||||
<div class="flex flex-row">
|
||||
<button class="button ~neutral @low accounts-search-clear">
|
||||
<span class="mr-2">{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
|
||||
</button>
|
||||
<button class="button ~neutral @low unfocused" id="accounts-keep-searching">{{ .strings.keepSearching }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<button class="button m-2 ~neutral @low" id="accounts-load-more">{{ .strings.loadMore }}</button>
|
||||
<button class="button m-2 ~neutral @low" id="accounts-load-all">{{ .strings.loadAll }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -825,10 +839,14 @@
|
||||
<button class="button ~neutral @low ml-2" id="activity-sort-direction">{{ .strings.sortDirection }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row align-middle w-full">
|
||||
<div class="flex flex-row align-middle w-full gap-2">
|
||||
<input type="search" class="field ~neutral @low input search mr-2" id="activity-search" placeholder="{{ .strings.search }}">
|
||||
<span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none activity-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
|
||||
<button class="button ~info @low ml-2" id="activity-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
|
||||
<div class="tooltip left">
|
||||
<span class="button ~info @low center h-full activity-search-server" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}"><i class="ri-search-line"></i></span>
|
||||
<span class="content sm">{{ .strings.searchAllRecords }}</span>
|
||||
</div>
|
||||
<button class="button ~info @low" id="activity-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropdown-display max-w-full">
|
||||
|
||||
@@ -136,6 +136,7 @@
|
||||
"filters": "Filters",
|
||||
"clickToRemoveFilter": "Click to remove this filter.",
|
||||
"clearSearch": "Clear search",
|
||||
"searchAllRecords": "Search all records (on server)",
|
||||
"actions": "Actions",
|
||||
"searchOptions": "Search Options",
|
||||
"matchText": "Match Text",
|
||||
@@ -190,6 +191,7 @@
|
||||
"totalRecords": "{n} Total Records",
|
||||
"loadedRecords": "{n} Loaded",
|
||||
"shownRecords": "{n} Shown",
|
||||
"selectedRecords": "{n} Selected",
|
||||
"backups": "Backups",
|
||||
"backupsDescription": "Backups of the database can be made, restored, or downloaded from here.",
|
||||
"backupsFormatNote": "Only backup files with the standard name format will be shown here. To use any other, upload the backup manually.",
|
||||
|
||||
21
models.go
21
models.go
@@ -168,14 +168,20 @@ type PaginatedDTO struct {
|
||||
LastPage bool `json:"last_page"`
|
||||
}
|
||||
|
||||
type PaginatedReqDTO struct {
|
||||
Limit int `json:"limit"`
|
||||
Page int `json:"page"` // zero-indexed
|
||||
SortByField string `json:"sortByField"`
|
||||
Ascending bool `json:"ascending"`
|
||||
}
|
||||
|
||||
type getUsersReqDTO struct {
|
||||
Limit int `json:"limit"`
|
||||
Page int `json:"page"` // zero-indexed
|
||||
ServerSearchReqDTO
|
||||
}
|
||||
|
||||
type getUsersDTO struct {
|
||||
UserList []respUser `json:"users"`
|
||||
LastPage bool `json:"last_page"`
|
||||
UserList []*respUser `json:"users"`
|
||||
LastPage bool `json:"last_page"`
|
||||
}
|
||||
|
||||
type ombiUser struct {
|
||||
@@ -440,10 +446,9 @@ type ActivityDTO struct {
|
||||
}
|
||||
|
||||
type GetActivitiesDTO struct {
|
||||
Type []string `json:"type"` // Types of activity to get. Leave blank for all.
|
||||
Limit int `json:"limit"`
|
||||
Page int `json:"page"` // zero-indexed
|
||||
Ascending bool `json:"ascending"`
|
||||
// "SortByField" ignores, it's always time.
|
||||
PaginatedReqDTO
|
||||
Type []string `json:"type"` // Types of activity to get. Leave blank for all.
|
||||
}
|
||||
|
||||
type GetActivitiesRespDTO struct {
|
||||
|
||||
@@ -148,12 +148,16 @@ const tabs: { id: string, url: string, reloader: () => void }[] = [
|
||||
// Don't keep loading the same item on every tab refresh
|
||||
isAccountURL = false;
|
||||
}
|
||||
window.onscroll = accounts.detectScroll;
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "activity",
|
||||
url: "activity",
|
||||
reloader: activity.reload
|
||||
reloader: () => {
|
||||
activity.reload()
|
||||
window.onscroll = activity.detectScroll;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
||||
import { _get, _post, _delete, toDateString, addLoader, removeLoader } from "../modules/common.js";
|
||||
import { Search, SearchConfiguration, QueryType, SearchableItem } from "../modules/search.js";
|
||||
import { _get, _post, _delete, toDateString } from "../modules/common.js";
|
||||
import { SearchConfiguration, QueryType, SearchableItem, SearchableItems } from "../modules/search.js";
|
||||
import { accountURLEvent } from "../modules/accounts.js";
|
||||
import { inviteURLEvent } from "../modules/invites.js";
|
||||
import { PaginatedList } from "./list.js";
|
||||
|
||||
declare var window: GlobalWindow;
|
||||
|
||||
@@ -32,6 +33,123 @@ var activityTypeMoods = {
|
||||
"deleteInvite": -1
|
||||
};
|
||||
|
||||
// window.lang doesn't exist at page load, so I made this a function that's invoked by activityList.
|
||||
const queries = (): { [field: string]: QueryType } => { return {
|
||||
"id": {
|
||||
name: window.lang.strings("activityID"),
|
||||
getter: "id",
|
||||
bool: false,
|
||||
string: true,
|
||||
date: false
|
||||
},
|
||||
"title": {
|
||||
name: window.lang.strings("title"),
|
||||
getter: "title",
|
||||
bool: false,
|
||||
string: true,
|
||||
date: false
|
||||
},
|
||||
"user": {
|
||||
name: window.lang.strings("usersMentioned"),
|
||||
getter: "mentionedUsers",
|
||||
bool: false,
|
||||
string: true,
|
||||
date: false
|
||||
},
|
||||
"actor": {
|
||||
name: window.lang.strings("actor"),
|
||||
description: window.lang.strings("actorDescription"),
|
||||
getter: "actor",
|
||||
bool: false,
|
||||
string: true,
|
||||
date: false
|
||||
},
|
||||
"referrer": {
|
||||
name: window.lang.strings("referrer"),
|
||||
getter: "referrer",
|
||||
bool: true,
|
||||
string: true,
|
||||
date: false
|
||||
},
|
||||
"date": {
|
||||
name: window.lang.strings("date"),
|
||||
getter: "date",
|
||||
bool: false,
|
||||
string: false,
|
||||
date: true
|
||||
},
|
||||
"account-creation": {
|
||||
name: window.lang.strings("accountCreationFilter"),
|
||||
getter: "accountCreation",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"account-deletion": {
|
||||
name: window.lang.strings("accountDeletionFilter"),
|
||||
getter: "accountDeletion",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"account-disabled": {
|
||||
name: window.lang.strings("accountDisabledFilter"),
|
||||
getter: "accountDisabled",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"account-enabled": {
|
||||
name: window.lang.strings("accountEnabledFilter"),
|
||||
getter: "accountEnabled",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"contact-linked": {
|
||||
name: window.lang.strings("contactLinkedFilter"),
|
||||
getter: "contactLinked",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"contact-unlinked": {
|
||||
name: window.lang.strings("contactUnlinkedFilter"),
|
||||
getter: "contactUnlinked",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"password-change": {
|
||||
name: window.lang.strings("passwordChangeFilter"),
|
||||
getter: "passwordChange",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"password-reset": {
|
||||
name: window.lang.strings("passwordResetFilter"),
|
||||
getter: "passwordReset",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"invite-created": {
|
||||
name: window.lang.strings("inviteCreatedFilter"),
|
||||
getter: "inviteCreated",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"invite-deleted": {
|
||||
name: window.lang.strings("inviteDeletedFilter"),
|
||||
getter: "inviteDeleted",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
}
|
||||
}};
|
||||
|
||||
// var moodColours = ["~warning", "~neutral", "~urge"];
|
||||
|
||||
export var activityReload = new CustomEvent("activity-reload");
|
||||
@@ -346,369 +464,130 @@ export class Activity implements activity, SearchableItem {
|
||||
asElement = () => { return this._card; };
|
||||
}
|
||||
|
||||
export class RecordCounter {
|
||||
private _container: HTMLElement;
|
||||
private _totalRecords: HTMLElement;
|
||||
private _loadedRecords: HTMLElement;
|
||||
private _shownRecords: HTMLElement;
|
||||
private _total: number;
|
||||
private _loaded: number;
|
||||
private _shown: number;
|
||||
constructor(container: HTMLElement) {
|
||||
this._container = container;
|
||||
this._container.innerHTML = `
|
||||
<span class="records-total"></span>
|
||||
<span class="records-loaded"></span>
|
||||
<span class="records-shown"></span>
|
||||
`;
|
||||
this._totalRecords = document.getElementsByClassName("records-total")[0] as HTMLElement;
|
||||
this._loadedRecords = document.getElementsByClassName("records-loaded")[0] as HTMLElement;
|
||||
this._shownRecords = document.getElementsByClassName("records-shown")[0] as HTMLElement;
|
||||
this.total = 0;
|
||||
this.loaded = 0;
|
||||
this.shown = 0;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.total = 0;
|
||||
this.loaded = 0;
|
||||
this.shown = 0;
|
||||
}
|
||||
|
||||
// Sets the total using a PageCountDTO-returning API endpoint.
|
||||
getTotal(endpoint: string) {
|
||||
_get(endpoint, null, (req: XMLHttpRequest) => {
|
||||
if (req.readyState != 4 || req.status != 200) return;
|
||||
this.total = req.response["count"] as number;
|
||||
});
|
||||
}
|
||||
|
||||
get total(): number { return this._total; }
|
||||
set total(v: number) {
|
||||
this._total = v;
|
||||
this._totalRecords.textContent = window.lang.var("strings", "totalRecords", `${v}`);
|
||||
}
|
||||
|
||||
get loaded(): number { return this._loaded; }
|
||||
set loaded(v: number) {
|
||||
this._loaded = v;
|
||||
this._loadedRecords.textContent = window.lang.var("strings", "loadedRecords", `${v}`);
|
||||
}
|
||||
|
||||
get shown(): number { return this._shown; }
|
||||
set shown(v: number) {
|
||||
this._shown = v;
|
||||
this._shownRecords.textContent = window.lang.var("strings", "shownRecords", `${v}`);
|
||||
}
|
||||
}
|
||||
interface ActivitiesReqDTO extends PaginatedReqDTO {
|
||||
type: string[];
|
||||
};
|
||||
|
||||
interface ActivitiesDTO extends paginatedDTO {
|
||||
activities: activity[];
|
||||
}
|
||||
|
||||
export class activityList {
|
||||
private _activityList: HTMLElement;
|
||||
private _activities: { [id: string]: Activity } = {};
|
||||
private _ordering: string[] = [];
|
||||
private _filterArea = document.getElementById("activity-filter-area");
|
||||
private _searchOptionsHeader = document.getElementById("activity-search-options-header");
|
||||
private _sortingByButton = document.getElementById("activity-sort-by-field") as HTMLButtonElement;
|
||||
private _notFoundPanel = document.getElementById("activity-not-found");
|
||||
private _searchBox = document.getElementById("activity-search") as HTMLInputElement;
|
||||
private _sortDirection = document.getElementById("activity-sort-direction") as HTMLButtonElement;
|
||||
private _loader = document.getElementById("activity-loader");
|
||||
private _loadMoreButton = document.getElementById("activity-load-more") as HTMLButtonElement;
|
||||
private _loadAllButton = document.getElementById("activity-load-all") as HTMLButtonElement;
|
||||
private _refreshButton = document.getElementById("activity-refresh") as HTMLButtonElement;
|
||||
private _keepSearchingDescription = document.getElementById("activity-keep-searching-description");
|
||||
private _keepSearchingButton = document.getElementById("activity-keep-searching");
|
||||
export class activityList extends PaginatedList {
|
||||
protected _activityList: HTMLElement;
|
||||
// protected _sortingByButton = document.getElementById("activity-sort-by-field") as HTMLButtonElement;
|
||||
protected _sortDirection = document.getElementById("activity-sort-direction") as HTMLButtonElement;
|
||||
|
||||
private _counter: RecordCounter;
|
||||
protected _ascending: boolean;
|
||||
|
||||
get activities(): { [id: string]: Activity } { return this._search.items as { [id: string]: Activity }; }
|
||||
// set activities(v: { [id: string]: Activity }) { this._search.items = v as SearchableItems; }
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
loader: document.getElementById("activity-loader"),
|
||||
loadMoreButton: document.getElementById("activity-load-more") as HTMLButtonElement,
|
||||
loadAllButton: document.getElementById("activity-load-all") as HTMLButtonElement,
|
||||
refreshButton: document.getElementById("activity-refresh") as HTMLButtonElement,
|
||||
keepSearchingDescription: document.getElementById("activity-keep-searching-description"),
|
||||
keepSearchingButton: document.getElementById("activity-keep-searching"),
|
||||
filterArea: document.getElementById("activity-filter-area"),
|
||||
searchOptionsHeader: document.getElementById("activity-search-options-header"),
|
||||
searchBox: document.getElementById("activity-search") as HTMLInputElement,
|
||||
notFoundPanel: document.getElementById("activity-not-found"),
|
||||
recordCounter: document.getElementById("activity-record-counter"),
|
||||
totalEndpoint: "/activity/count",
|
||||
getPageEndpoint: "/activity",
|
||||
limit: 10,
|
||||
newElementsFromPage: (resp: paginatedDTO) => {
|
||||
let ordering: string[] = this._search.ordering;
|
||||
for (let act of ((resp as ActivitiesDTO).activities || [])) {
|
||||
this.activities[act.id] = new Activity(act);
|
||||
ordering.push(act.id);
|
||||
}
|
||||
},
|
||||
updateExistingElementsFromPage: (resp: paginatedDTO) => {
|
||||
// FIXME: Implement updates to existing elements!
|
||||
for (let id of Object.keys(this.activities)) {
|
||||
delete this.activities[id];
|
||||
}
|
||||
this._search.setOrdering([], this._c.defaultSortField, this.ascending);
|
||||
this._c.newElementsFromPage(resp);
|
||||
},
|
||||
defaultSortField: "time",
|
||||
pageLoadCallback: (req: XMLHttpRequest) => {
|
||||
if (req.readyState != 4) return;
|
||||
if (req.status != 200) {
|
||||
window.notifications.customError("loadActivitiesError", window.lang.notif("errorLoadActivities"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
private _search: Search;
|
||||
private _ascending: boolean;
|
||||
private _hasLoaded: boolean;
|
||||
private _lastLoad: number;
|
||||
private _page: number = 0;
|
||||
private _lastPage: boolean;
|
||||
this._activityList = document.getElementById("activity-card-list")
|
||||
document.addEventListener("activity-reload", this.reload);
|
||||
|
||||
let searchConfig: SearchConfiguration = {
|
||||
filterArea: this._c.filterArea,
|
||||
// Exclude this: We only sort by date, and don't want to show a redundant header indicating so.
|
||||
// sortingByButton: this._sortingByButton,
|
||||
searchOptionsHeader: this._c.searchOptionsHeader,
|
||||
notFoundPanel: this._c.notFoundPanel,
|
||||
search: this._c.searchBox,
|
||||
clearSearchButtonSelector: ".activity-search-clear",
|
||||
serverSearchButtonSelector: ".activity-search-server",
|
||||
queries: queries(),
|
||||
setVisibility: this.setVisibility,
|
||||
filterList: document.getElementById("activity-filter-list"),
|
||||
// notFoundCallback: this._notFoundCallback,
|
||||
onSearchCallback: null,
|
||||
searchServer: null,
|
||||
clearServerSearch: null,
|
||||
}
|
||||
|
||||
this.initSearch(searchConfig);
|
||||
|
||||
this.ascending = false;
|
||||
this._sortDirection.addEventListener("click", () => this.ascending = !this.ascending);
|
||||
}
|
||||
|
||||
setVisibility = (activities: string[], visible: boolean) => {
|
||||
this._activityList.textContent = ``;
|
||||
for (let id of this._ordering) {
|
||||
for (let id of this._search.ordering) {
|
||||
if (visible && activities.indexOf(id) != -1) {
|
||||
this._activityList.appendChild(this._activities[id].asElement());
|
||||
this._activityList.appendChild(this.activities[id].asElement());
|
||||
} else if (!visible && activities.indexOf(id) == -1) {
|
||||
this._activityList.appendChild(this._activities[id].asElement());
|
||||
this._activityList.appendChild(this.activities[id].asElement());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reload = () => {
|
||||
this._lastLoad = Date.now();
|
||||
this._lastPage = false;
|
||||
this._loadMoreButton.textContent = window.lang.strings("loadMore");
|
||||
this._loadMoreButton.disabled = false;
|
||||
this._loadAllButton.classList.remove("unfocused");
|
||||
this._loadAllButton.disabled = false;
|
||||
|
||||
this._counter.reset();
|
||||
this._counter.getTotal("/activity/count");
|
||||
|
||||
// this._page = 0;
|
||||
let limit = 10;
|
||||
if (this._page != 0) {
|
||||
limit *= this._page+1;
|
||||
};
|
||||
|
||||
let send = {
|
||||
"type": [],
|
||||
"limit": limit,
|
||||
"page": 0,
|
||||
"ascending": this.ascending
|
||||
}
|
||||
|
||||
|
||||
_post("/activity", send, (req: XMLHttpRequest) => {
|
||||
if (req.readyState != 4) return;
|
||||
if (req.status != 200) {
|
||||
window.notifications.customError("loadActivitiesError", window.lang.notif("errorLoadActivities"));
|
||||
return;
|
||||
}
|
||||
|
||||
this._hasLoaded = true;
|
||||
// Allow refreshes every 15s
|
||||
this._refreshButton.disabled = true;
|
||||
setTimeout(() => this._refreshButton.disabled = false, 15000);
|
||||
|
||||
let resp = req.response as ActivitiesDTO;
|
||||
// FIXME: Don't destroy everything each reload!
|
||||
this._activities = {};
|
||||
this._ordering = [];
|
||||
|
||||
for (let act of resp.activities) {
|
||||
this._activities[act.id] = new Activity(act);
|
||||
this._ordering.push(act.id);
|
||||
}
|
||||
this._search.items = this._activities;
|
||||
this._search.ordering = this._ordering;
|
||||
|
||||
this._counter.loaded = this._ordering.length;
|
||||
|
||||
if (this._search.inSearch) {
|
||||
this._search.onSearchBoxChange(true);
|
||||
this._loadAllButton.classList.remove("unfocused");
|
||||
} else {
|
||||
this._counter.shown = this._counter.loaded;
|
||||
this.setVisibility(this._ordering, true);
|
||||
this._loadAllButton.classList.add("unfocused");
|
||||
this._notFoundPanel.classList.add("unfocused");
|
||||
}
|
||||
}, true);
|
||||
this._reload();
|
||||
}
|
||||
|
||||
loadMore = (callback?: () => void, loadAll: boolean = false) => {
|
||||
this._lastLoad = Date.now();
|
||||
this._loadMoreButton.disabled = true;
|
||||
// this._loadAllButton.disabled = true;
|
||||
const timeout = setTimeout(() => {
|
||||
this._loadMoreButton.disabled = false;
|
||||
// this._loadAllButton.disabled = false;
|
||||
}, 1000);
|
||||
this._page += 1;
|
||||
|
||||
let send = {
|
||||
"type": [],
|
||||
"limit": 10,
|
||||
"page": this._page,
|
||||
"ascending": this._ascending
|
||||
};
|
||||
|
||||
// this._activityList.classList.add("unfocused");
|
||||
// addLoader(this._loader, false, true);
|
||||
|
||||
_post("/activity", send, (req: XMLHttpRequest) => {
|
||||
if (req.readyState != 4) return;
|
||||
if (req.status != 200) {
|
||||
window.notifications.customError("loadActivitiesError", window.lang.notif("errorLoadActivities"));
|
||||
return;
|
||||
this._loadMore(
|
||||
loadAll,
|
||||
(req: XMLHttpRequest) => {
|
||||
if (req.readyState != 4) return;
|
||||
if (req.status != 200) return;
|
||||
if (callback) callback();
|
||||
}
|
||||
|
||||
let resp = req.response as ActivitiesDTO;
|
||||
|
||||
this._lastPage = resp.last_page;
|
||||
if (this._lastPage) {
|
||||
clearTimeout(timeout);
|
||||
this._loadMoreButton.disabled = true;
|
||||
removeLoader(this._loadAllButton);
|
||||
this._loadAllButton.classList.add("unfocused");
|
||||
this._loadMoreButton.textContent = window.lang.strings("noMoreResults");
|
||||
}
|
||||
|
||||
for (let act of resp.activities) {
|
||||
this._activities[act.id] = new Activity(act);
|
||||
this._ordering.push(act.id);
|
||||
}
|
||||
// this._search.items = this._activities;
|
||||
// this._search.ordering = this._ordering;
|
||||
|
||||
this._counter.loaded = this._ordering.length;
|
||||
|
||||
if (this._search.inSearch || loadAll) {
|
||||
if (this._lastPage) {
|
||||
loadAll = false;
|
||||
}
|
||||
this._search.onSearchBoxChange(true, loadAll);
|
||||
} else {
|
||||
this.setVisibility(this._ordering, true);
|
||||
this._notFoundPanel.classList.add("unfocused");
|
||||
}
|
||||
|
||||
if (callback) callback();
|
||||
// removeLoader(this._loader);
|
||||
// this._activityList.classList.remove("unfocused");
|
||||
}, true);
|
||||
);
|
||||
}
|
||||
|
||||
private _queries: { [field: string]: QueryType } = {
|
||||
"id": {
|
||||
name: window.lang.strings("activityID"),
|
||||
getter: "id",
|
||||
bool: false,
|
||||
string: true,
|
||||
date: false
|
||||
},
|
||||
"title": {
|
||||
name: window.lang.strings("title"),
|
||||
getter: "title",
|
||||
bool: false,
|
||||
string: true,
|
||||
date: false
|
||||
},
|
||||
"user": {
|
||||
name: window.lang.strings("usersMentioned"),
|
||||
getter: "mentionedUsers",
|
||||
bool: false,
|
||||
string: true,
|
||||
date: false
|
||||
},
|
||||
"actor": {
|
||||
name: window.lang.strings("actor"),
|
||||
description: window.lang.strings("actorDescription"),
|
||||
getter: "actor",
|
||||
bool: false,
|
||||
string: true,
|
||||
date: false
|
||||
},
|
||||
"referrer": {
|
||||
name: window.lang.strings("referrer"),
|
||||
getter: "referrer",
|
||||
bool: true,
|
||||
string: true,
|
||||
date: false
|
||||
},
|
||||
"date": {
|
||||
name: window.lang.strings("date"),
|
||||
getter: "date",
|
||||
bool: false,
|
||||
string: false,
|
||||
date: true
|
||||
},
|
||||
"account-creation": {
|
||||
name: window.lang.strings("accountCreationFilter"),
|
||||
getter: "accountCreation",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"account-deletion": {
|
||||
name: window.lang.strings("accountDeletionFilter"),
|
||||
getter: "accountDeletion",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"account-disabled": {
|
||||
name: window.lang.strings("accountDisabledFilter"),
|
||||
getter: "accountDisabled",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"account-enabled": {
|
||||
name: window.lang.strings("accountEnabledFilter"),
|
||||
getter: "accountEnabled",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"contact-linked": {
|
||||
name: window.lang.strings("contactLinkedFilter"),
|
||||
getter: "contactLinked",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"contact-unlinked": {
|
||||
name: window.lang.strings("contactUnlinkedFilter"),
|
||||
getter: "contactUnlinked",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"password-change": {
|
||||
name: window.lang.strings("passwordChangeFilter"),
|
||||
getter: "passwordChange",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"password-reset": {
|
||||
name: window.lang.strings("passwordResetFilter"),
|
||||
getter: "passwordReset",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"invite-created": {
|
||||
name: window.lang.strings("inviteCreatedFilter"),
|
||||
getter: "inviteCreated",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"invite-deleted": {
|
||||
name: window.lang.strings("inviteDeletedFilter"),
|
||||
getter: "inviteDeleted",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
}
|
||||
};
|
||||
|
||||
get ascending(): boolean { return this._ascending; }
|
||||
set ascending(v: boolean) {
|
||||
this._ascending = v;
|
||||
this._sortDirection.innerHTML = `${window.lang.strings("sortDirection")} <i class="ri-arrow-${v ? "up" : "down"}-s-line ml-2"></i>`;
|
||||
// FIXME?: We don't actually re-sort the list here, instead just use setOrdering to apply this.ascending before a reload.
|
||||
this._search.setOrdering(this._search.ordering, this._c.defaultSortField, this.ascending);
|
||||
if (this._hasLoaded) {
|
||||
this.reload();
|
||||
}
|
||||
}
|
||||
|
||||
detectScroll = () => {
|
||||
if (!this._hasLoaded) return;
|
||||
// console.log(window.innerHeight + document.documentElement.scrollTop, document.scrollingElement.scrollHeight);
|
||||
if (Math.abs(window.innerHeight + document.documentElement.scrollTop - document.scrollingElement.scrollHeight) < 50) {
|
||||
// window.notifications.customSuccess("scroll", "Reached bottom.");
|
||||
// Wait .5s between loads
|
||||
if (this._lastLoad + 500 > Date.now()) return;
|
||||
this.loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
private _prevResultCount = 0;
|
||||
|
||||
private _notFoundCallback = (notFound: boolean) => {
|
||||
/*private _notFoundCallback = (notFound: boolean) => {
|
||||
if (notFound) this._loadMoreButton.classList.add("unfocused");
|
||||
else this._loadMoreButton.classList.remove("unfocused");
|
||||
|
||||
@@ -719,55 +598,6 @@ export class activityList {
|
||||
this._keepSearchingButton.classList.add("unfocused");
|
||||
this._keepSearchingDescription.classList.add("unfocused");
|
||||
}
|
||||
};
|
||||
};*/
|
||||
|
||||
constructor() {
|
||||
this._activityList = document.getElementById("activity-card-list");
|
||||
document.addEventListener("activity-reload", this.reload);
|
||||
|
||||
this._counter = new RecordCounter(document.getElementById("activity-record-counter"));
|
||||
|
||||
let conf: SearchConfiguration = {
|
||||
filterArea: this._filterArea,
|
||||
sortingByButton: this._sortingByButton,
|
||||
searchOptionsHeader: this._searchOptionsHeader,
|
||||
notFoundPanel: this._notFoundPanel,
|
||||
search: this._searchBox,
|
||||
clearSearchButtonSelector: ".activity-search-clear",
|
||||
queries: this._queries,
|
||||
setVisibility: this.setVisibility,
|
||||
filterList: document.getElementById("activity-filter-list"),
|
||||
// notFoundCallback: this._notFoundCallback,
|
||||
onSearchCallback: (visibleCount: number, newItems: boolean, loadAll: boolean) => {
|
||||
this._counter.shown = visibleCount;
|
||||
|
||||
if (this._search.inSearch && !this._lastPage) this._loadAllButton.classList.remove("unfocused");
|
||||
else this._loadAllButton.classList.add("unfocused");
|
||||
|
||||
if (visibleCount < 10 || loadAll) {
|
||||
if (!newItems || this._prevResultCount != visibleCount || (visibleCount == 0 && !this._lastPage) || loadAll) this.loadMore(() => {}, loadAll);
|
||||
}
|
||||
this._prevResultCount = visibleCount;
|
||||
}
|
||||
}
|
||||
this._search = new Search(conf);
|
||||
this._search.generateFilterList();
|
||||
|
||||
this._hasLoaded = false;
|
||||
this.ascending = false;
|
||||
this._sortDirection.addEventListener("click", () => this.ascending = !this.ascending);
|
||||
|
||||
this._loadMoreButton.onclick = () => this.loadMore();
|
||||
this._loadAllButton.onclick = () => {
|
||||
addLoader(this._loadAllButton, true);
|
||||
this.loadMore(() => {}, true);
|
||||
};
|
||||
/* this._keepSearchingButton.onclick = () => {
|
||||
addLoader(this._keepSearchingButton, true);
|
||||
this.loadMore(() => removeLoader(this._keepSearchingButton, true));
|
||||
}; */
|
||||
this._refreshButton.onclick = this.reload;
|
||||
|
||||
window.onscroll = this.detectScroll;
|
||||
}
|
||||
}
|
||||
|
||||
328
ts/modules/list.ts
Normal file
328
ts/modules/list.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import { _get, _post, addLoader, removeLoader } from "./common";
|
||||
import { Search, SearchConfiguration, SearchableItems } from "./search";
|
||||
|
||||
declare var window: GlobalWindow;
|
||||
|
||||
export class RecordCounter {
|
||||
private _container: HTMLElement;
|
||||
private _totalRecords: HTMLElement;
|
||||
private _loadedRecords: HTMLElement;
|
||||
private _shownRecords: HTMLElement;
|
||||
private _selectedRecords: HTMLElement;
|
||||
private _total: number;
|
||||
private _loaded: number;
|
||||
private _shown: number;
|
||||
private _selected: number;
|
||||
constructor(container: HTMLElement) {
|
||||
this._container = container;
|
||||
this._container.innerHTML = `
|
||||
<span class="records-total"></span>
|
||||
<span class="records-loaded"></span>
|
||||
<span class="records-shown"></span>
|
||||
<span class="records-selected"></span>
|
||||
`;
|
||||
this._totalRecords = document.getElementsByClassName("records-total")[0] as HTMLElement;
|
||||
this._loadedRecords = document.getElementsByClassName("records-loaded")[0] as HTMLElement;
|
||||
this._shownRecords = document.getElementsByClassName("records-shown")[0] as HTMLElement;
|
||||
this._selectedRecords = document.getElementsByClassName("records-selected")[0] as HTMLElement;
|
||||
this.total = 0;
|
||||
this.loaded = 0;
|
||||
this.shown = 0;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.total = 0;
|
||||
this.loaded = 0;
|
||||
this.shown = 0;
|
||||
this.selected = 0;
|
||||
}
|
||||
|
||||
// Sets the total using a PageCountDTO-returning API endpoint.
|
||||
getTotal(endpoint: string) {
|
||||
_get(endpoint, null, (req: XMLHttpRequest) => {
|
||||
if (req.readyState != 4 || req.status != 200) return;
|
||||
this.total = req.response["count"] as number;
|
||||
});
|
||||
}
|
||||
|
||||
get total(): number { return this._total; }
|
||||
set total(v: number) {
|
||||
this._total = v;
|
||||
this._totalRecords.textContent = window.lang.var("strings", "totalRecords", `${v}`);
|
||||
}
|
||||
|
||||
get loaded(): number { return this._loaded; }
|
||||
set loaded(v: number) {
|
||||
this._loaded = v;
|
||||
this._loadedRecords.textContent = window.lang.var("strings", "loadedRecords", `${v}`);
|
||||
}
|
||||
|
||||
get shown(): number { return this._shown; }
|
||||
set shown(v: number) {
|
||||
this._shown = v;
|
||||
this._shownRecords.textContent = window.lang.var("strings", "shownRecords", `${v}`);
|
||||
}
|
||||
|
||||
get selected(): number { return this._selected; }
|
||||
set selected(v: number) {
|
||||
this._selected = v;
|
||||
if (v == 0) this._selectedRecords.textContent = ``;
|
||||
else this._selectedRecords.textContent = window.lang.var("strings", "selectedRecords", `${v}`);
|
||||
}
|
||||
}
|
||||
|
||||
export interface PaginatedListConfig {
|
||||
loader: HTMLElement;
|
||||
loadMoreButton: HTMLButtonElement;
|
||||
loadAllButton: HTMLButtonElement;
|
||||
refreshButton: HTMLButtonElement;
|
||||
keepSearchingDescription: HTMLElement;
|
||||
keepSearchingButton: HTMLElement;
|
||||
notFoundPanel: HTMLElement;
|
||||
filterArea: HTMLElement;
|
||||
searchOptionsHeader: HTMLElement;
|
||||
searchBox: HTMLInputElement;
|
||||
recordCounter: HTMLElement;
|
||||
totalEndpoint: string;
|
||||
getPageEndpoint: string;
|
||||
limit: number;
|
||||
newElementsFromPage: (resp: paginatedDTO) => void;
|
||||
updateExistingElementsFromPage: (resp: paginatedDTO) => void;
|
||||
defaultSortField: string;
|
||||
pageLoadCallback?: (req: XMLHttpRequest) => void;
|
||||
}
|
||||
|
||||
export abstract class PaginatedList {
|
||||
protected _c: PaginatedListConfig;
|
||||
|
||||
protected _search: Search;
|
||||
|
||||
protected _counter: RecordCounter;
|
||||
|
||||
protected _hasLoaded: boolean;
|
||||
protected _lastLoad: number;
|
||||
protected _page: number = 0;
|
||||
protected _lastPage: boolean;
|
||||
get lastPage(): boolean { return this._lastPage };
|
||||
set lastPage(v: boolean) {
|
||||
this._lastPage = v;
|
||||
if (v) {
|
||||
this._c.loadAllButton.classList.add("unfocused");
|
||||
this._c.loadMoreButton.textContent = window.lang.strings("noMoreResults");
|
||||
this._c.loadMoreButton.disabled = true;
|
||||
} else {
|
||||
this._c.loadMoreButton.textContent = window.lang.strings("loadMore");
|
||||
this._c.loadMoreButton.disabled = false;
|
||||
if (this._search.inSearch) {
|
||||
this._c.loadAllButton.classList.remove("unfocused");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected _previousPageSize = 0;
|
||||
|
||||
// Stores a PaginatedReqDTO-implementing thing.
|
||||
// A standard PaginatedReqDTO will be overridden entirely,
|
||||
// but a ServerSearchDTO will keep it's fields.
|
||||
protected _searchParams: PaginatedReqDTO;
|
||||
defaultParams = (): PaginatedReqDTO => {
|
||||
return {
|
||||
limit: 0,
|
||||
page: 0,
|
||||
sortByField: "",
|
||||
ascending: false
|
||||
};
|
||||
}
|
||||
|
||||
constructor(c: PaginatedListConfig) {
|
||||
this._c = c;
|
||||
this._counter = new RecordCounter(this._c.recordCounter);
|
||||
this._hasLoaded = false;
|
||||
|
||||
this._c.loadMoreButton.onclick = () => this.loadMore(null, false);
|
||||
this._c.loadAllButton.onclick = () => {
|
||||
addLoader(this._c.loadAllButton, true);
|
||||
this.loadMore(null, true);
|
||||
};
|
||||
/* this._keepSearchingButton.onclick = () => {
|
||||
addLoader(this._keepSearchingButton, true);
|
||||
this.loadMore(() => removeLoader(this._keepSearchingButton, true));
|
||||
}; */
|
||||
// Since this.reload doesn't exist, we need an arrow function to wrap it.
|
||||
// FIXME: Make sure it works though!
|
||||
this._c.refreshButton.onclick = () => this.reload();
|
||||
}
|
||||
|
||||
initSearch = (searchConfig: SearchConfiguration) => {
|
||||
const previousCallback = searchConfig.onSearchCallback;
|
||||
searchConfig.onSearchCallback = (visibleCount: number, newItems: boolean, loadAll: boolean) => {
|
||||
this._counter.shown = visibleCount;
|
||||
|
||||
// if (this._search.inSearch && !this.lastPage) this._c.loadAllButton.classList.remove("unfocused");
|
||||
// else this._c.loadAllButton.classList.add("unfocused");
|
||||
|
||||
// FIXME: Figure out why this makes sense and make it clearer.
|
||||
if ((visibleCount < this._c.limit && !this.lastPage) || loadAll) {
|
||||
if (!newItems ||
|
||||
this._previousPageSize != visibleCount ||
|
||||
(visibleCount == 0 && !this.lastPage) ||
|
||||
loadAll
|
||||
) {
|
||||
this.loadMore(() => {}, loadAll);
|
||||
}
|
||||
}
|
||||
this._previousPageSize = visibleCount;
|
||||
if (previousCallback) previousCallback(visibleCount, newItems, loadAll);
|
||||
};
|
||||
const previousServerSearch = searchConfig.searchServer;
|
||||
searchConfig.searchServer = (params: PaginatedReqDTO, newSearch: boolean) => {
|
||||
this._searchParams = params;
|
||||
if (newSearch) this.reload();
|
||||
else this.loadMore(null, false);
|
||||
|
||||
if (previousServerSearch) previousServerSearch(params, newSearch);
|
||||
};
|
||||
searchConfig.clearServerSearch = () => {
|
||||
this._page = 0;
|
||||
this.reload();
|
||||
}
|
||||
this._search = new Search(searchConfig);
|
||||
this._search.generateFilterList();
|
||||
this.lastPage = false;
|
||||
};
|
||||
|
||||
// Sets the elements with "name"s in "elements" as visible or not.
|
||||
public abstract setVisibility: (elements: string[], visible: boolean) => void;
|
||||
|
||||
// Removes all elements, and reloads the first page.
|
||||
public abstract reload: () => void;
|
||||
protected _reload = (
|
||||
callback?: (req: XMLHttpRequest) => void
|
||||
) => {
|
||||
this._lastLoad = Date.now();
|
||||
this.lastPage = false;
|
||||
|
||||
this._counter.reset();
|
||||
this._counter.getTotal(this._c.totalEndpoint);
|
||||
|
||||
// Reload all currently visible elements, i.e. Load a new page of size (limit*(page+1)).
|
||||
let limit = this._c.limit;
|
||||
if (this._page != 0) {
|
||||
limit *= this._page+1;
|
||||
}
|
||||
|
||||
let params = this._search.inServerSearch ? this._searchParams : this.defaultParams();
|
||||
params.limit = limit;
|
||||
params.page = 0;
|
||||
if (params.sortByField == "") {
|
||||
params.sortByField = this._c.defaultSortField;
|
||||
params.ascending = true;
|
||||
}
|
||||
|
||||
_post(this._c.getPageEndpoint, params, (req: XMLHttpRequest) => {
|
||||
if (req.readyState != 4) return;
|
||||
if (req.status != 200) {
|
||||
if (this._c.pageLoadCallback) this._c.pageLoadCallback(req);
|
||||
if (callback) callback(req);
|
||||
return;
|
||||
}
|
||||
|
||||
this._hasLoaded = true;
|
||||
// Allow refreshes every 15s
|
||||
this._c.refreshButton.disabled = true;
|
||||
setTimeout(() => this._c.refreshButton.disabled = false, 15000);
|
||||
|
||||
let resp = req.response as paginatedDTO;
|
||||
|
||||
this.lastPage = resp.last_page;
|
||||
|
||||
this._c.updateExistingElementsFromPage(resp);
|
||||
|
||||
this._counter.loaded = this._search.ordering.length;
|
||||
|
||||
this._search.onSearchBoxChange(true);
|
||||
if (this._search.inSearch) {
|
||||
// this._c.loadAllButton.classList.remove("unfocused");
|
||||
} else {
|
||||
this._counter.shown = this._counter.loaded;
|
||||
this.setVisibility(this._search.ordering, true);
|
||||
this._c.loadAllButton.classList.add("unfocused");
|
||||
this._c.notFoundPanel.classList.add("unfocused");
|
||||
}
|
||||
if (this._c.pageLoadCallback) this._c.pageLoadCallback(req);
|
||||
if (callback) callback(req);
|
||||
}, true);
|
||||
}
|
||||
|
||||
// Loads the next page. If "loadAll", all pages will be loaded until the last is reached.
|
||||
public abstract loadMore: (callback: () => void, loadAll: boolean) => void;
|
||||
protected _loadMore = (
|
||||
loadAll: boolean = false,
|
||||
callback?: (req: XMLHttpRequest) => void
|
||||
) => {
|
||||
this._lastLoad = Date.now();
|
||||
this._c.loadMoreButton.disabled = true;
|
||||
const timeout = setTimeout(() => {
|
||||
this._c.loadMoreButton.disabled = false;
|
||||
}, 1000);
|
||||
this._page += 1;
|
||||
|
||||
let params = this._search.inServerSearch ? this._searchParams : this.defaultParams();
|
||||
params.limit = this._c.limit;
|
||||
params.page = this._page;
|
||||
if (params.sortByField == "") {
|
||||
params.sortByField = this._c.defaultSortField;
|
||||
params.ascending = true;
|
||||
}
|
||||
|
||||
_post(this._c.getPageEndpoint, params, (req: XMLHttpRequest) => {
|
||||
if (req.readyState != 4) return;
|
||||
if (req.status != 200) {
|
||||
if (this._c.pageLoadCallback) this._c.pageLoadCallback(req);
|
||||
if (callback) callback(req);
|
||||
return;
|
||||
}
|
||||
|
||||
let resp = req.response as paginatedDTO;
|
||||
|
||||
// Check before setting this.lastPage so we have a chance to cancel the timeout.
|
||||
if (resp.last_page) {
|
||||
clearTimeout(timeout);
|
||||
removeLoader(this._c.loadAllButton);
|
||||
}
|
||||
|
||||
this.lastPage = resp.last_page;
|
||||
|
||||
this._c.newElementsFromPage(resp);
|
||||
|
||||
this._counter.loaded = this._search.ordering.length;
|
||||
|
||||
if (this._search.inSearch || loadAll) {
|
||||
if (this.lastPage) {
|
||||
loadAll = false;
|
||||
}
|
||||
this._search.onSearchBoxChange(true, loadAll);
|
||||
} else {
|
||||
this.setVisibility(this._search.ordering, true);
|
||||
this._c.notFoundPanel.classList.add("unfocused");
|
||||
}
|
||||
if (this._c.pageLoadCallback) this._c.pageLoadCallback(req);
|
||||
if (callback) callback(req);
|
||||
}, true)
|
||||
}
|
||||
|
||||
// Should be assigned to window.onscroll whenever the list is in view.
|
||||
detectScroll = () => {
|
||||
if (!this._hasLoaded || this.lastPage) return;
|
||||
// console.log(window.innerHeight + document.documentElement.scrollTop, document.scrollingElement.scrollHeight);
|
||||
if (Math.abs(window.innerHeight + document.documentElement.scrollTop - document.scrollingElement.scrollHeight) < 50) {
|
||||
// window.notifications.customSuccess("scroll", "Reached bottom.");
|
||||
// Wait .5s between loads
|
||||
if (this._lastLoad + 500 > Date.now()) return;
|
||||
this.loadMore(null, false);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -32,29 +32,49 @@ export interface QueryType {
|
||||
|
||||
export interface SearchConfiguration {
|
||||
filterArea: HTMLElement;
|
||||
sortingByButton: HTMLButtonElement;
|
||||
sortingByButton?: HTMLButtonElement;
|
||||
searchOptionsHeader: HTMLElement;
|
||||
notFoundPanel: HTMLElement;
|
||||
notFoundCallback?: (notFound: boolean) => void;
|
||||
filterList: HTMLElement;
|
||||
clearSearchButtonSelector: string;
|
||||
serverSearchButtonSelector: string;
|
||||
search: HTMLInputElement;
|
||||
queries: { [field: string]: QueryType };
|
||||
setVisibility: (items: string[], visible: boolean) => void;
|
||||
onSearchCallback: (visibleCount: number, newItems: boolean, loadAll: boolean) => void;
|
||||
searchServer: (params: PaginatedReqDTO, newSearch: boolean) => void;
|
||||
clearServerSearch: () => void;
|
||||
loadMore?: () => void;
|
||||
}
|
||||
|
||||
export interface ServerSearchReqDTO extends PaginatedReqDTO {
|
||||
searchTerms: string[];
|
||||
queries: QueryDTO[];
|
||||
}
|
||||
|
||||
// FIXME: Generate ServerSearchReqDTO using Query.asDTO methods in serverSearch()!
|
||||
|
||||
export interface QueryDTO {
|
||||
class: "bool" | "string" | "date";
|
||||
// QueryType.getter
|
||||
field: string;
|
||||
operator: QueryOperator;
|
||||
value: boolean | string | DateAttempt;
|
||||
};
|
||||
|
||||
export abstract class Query {
|
||||
protected _subject: QueryType;
|
||||
protected _operator: QueryOperator;
|
||||
protected _card: HTMLElement;
|
||||
|
||||
constructor(subject: QueryType, operator: QueryOperator) {
|
||||
constructor(subject: QueryType | null, operator: QueryOperator) {
|
||||
this._subject = subject;
|
||||
this._operator = operator;
|
||||
this._card = document.createElement("span");
|
||||
this._card.ariaLabel = window.lang.strings("clickToRemoveFilter");
|
||||
if (subject != null) {
|
||||
this._card = document.createElement("span");
|
||||
this._card.ariaLabel = window.lang.strings("clickToRemoveFilter");
|
||||
}
|
||||
}
|
||||
|
||||
set onclick(v: () => void) {
|
||||
@@ -62,8 +82,16 @@ export abstract class Query {
|
||||
}
|
||||
|
||||
asElement(): HTMLElement { return this._card; }
|
||||
}
|
||||
|
||||
public abstract compare(subjectValue: any): boolean;
|
||||
|
||||
asDTO(): QueryDTO {
|
||||
let out = {} as QueryDTO;
|
||||
out.field = this._subject.getter;
|
||||
out.operator = this._operator;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
export class BoolQuery extends Query {
|
||||
protected _value: boolean;
|
||||
@@ -96,13 +124,20 @@ export class BoolQuery extends Query {
|
||||
public compare(subjectBool: boolean): boolean {
|
||||
return ((subjectBool && this._value) || (!subjectBool && !this._value))
|
||||
}
|
||||
|
||||
asDTO(): QueryDTO {
|
||||
let out = super.asDTO();
|
||||
out.class = "bool";
|
||||
out.value = this._value;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
export class StringQuery extends Query {
|
||||
protected _value: string;
|
||||
constructor(subject: QueryType, value: string) {
|
||||
super(subject, QueryOperator.Equal);
|
||||
this._value = value;
|
||||
this._value = value.toLowerCase();
|
||||
this._card.classList.add("button", "~neutral", "@low", "center", "mx-2", "h-full");
|
||||
this._card.innerHTML = `
|
||||
<span class="font-bold mr-2">${subject.name}:</span> "${this._value}"
|
||||
@@ -110,6 +145,17 @@ export class StringQuery extends Query {
|
||||
}
|
||||
|
||||
get value(): string { return this._value; }
|
||||
|
||||
public compare(subjectString: string): boolean {
|
||||
return subjectString.toLowerCase().includes(this._value);
|
||||
}
|
||||
|
||||
asDTO(): QueryDTO {
|
||||
let out = super.asDTO();
|
||||
out.class = "string";
|
||||
out.value = this._value;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
export interface DateAttempt {
|
||||
@@ -151,7 +197,6 @@ export class DateQuery extends Query {
|
||||
constructor(subject: QueryType, operator: QueryOperator, value: ParsedDate) {
|
||||
super(subject, operator);
|
||||
this._value = value;
|
||||
console.log("op:", operator, "date:", value);
|
||||
this._card.classList.add("button", "~neutral", "@low", "center", "m-2", "h-full");
|
||||
let dateText = QueryOperatorToDateText(operator);
|
||||
this._card.innerHTML = `
|
||||
@@ -204,30 +249,58 @@ export class DateQuery extends Query {
|
||||
}
|
||||
return subjectDate > temp;
|
||||
}
|
||||
|
||||
asDTO(): QueryDTO {
|
||||
let out = super.asDTO();
|
||||
out.class = "date";
|
||||
out.value = this._value.attempt;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// FIXME: Continue taking stuff from search function, making XQuery classes!
|
||||
|
||||
|
||||
|
||||
export interface SearchableItem {
|
||||
matchesSearch: (query: string) => boolean;
|
||||
}
|
||||
|
||||
export type SearchableItems = { [id: string]: SearchableItem };
|
||||
|
||||
export class Search {
|
||||
private _c: SearchConfiguration;
|
||||
private _sortField: string = "";
|
||||
private _ascending: boolean = true;
|
||||
private _ordering: string[] = [];
|
||||
private _items: { [id: string]: SearchableItem };
|
||||
inSearch: boolean;
|
||||
private _items: SearchableItems = {};
|
||||
// Search queries (filters)
|
||||
private _queries: Query[] = [];
|
||||
// Plain-text search terms
|
||||
private _searchTerms: string[] = [];
|
||||
inSearch: boolean = false;
|
||||
private _inServerSearch: boolean = false;
|
||||
get inServerSearch(): boolean { return this._inServerSearch; }
|
||||
set inServerSearch(v: boolean) {
|
||||
this._inServerSearch = v;
|
||||
if (!v) {
|
||||
this._c.clearServerSearch();
|
||||
}
|
||||
}
|
||||
|
||||
private _serverSearchButtons: HTMLElement[];
|
||||
|
||||
// Returns a list of identifiers (keys in items, values in ordering).
|
||||
search = (query: String): string[] => {
|
||||
this._c.filterArea.textContent = "";
|
||||
|
||||
query = query.toLowerCase();
|
||||
|
||||
let result: string[] = [...this._ordering];
|
||||
// If we're in a server search already, the results are already correct.
|
||||
if (this.inServerSearch) return result;
|
||||
|
||||
|
||||
let words: string[] = [];
|
||||
let queries = [];
|
||||
let searchTerms = [];
|
||||
|
||||
|
||||
let quoteSymbol = ``;
|
||||
let queryStart = -1;
|
||||
@@ -258,7 +331,6 @@ export class Search {
|
||||
}
|
||||
}
|
||||
words.push(query.substring(queryStart, end).replace(/['"]/g, ""));
|
||||
console.log("pushed", words);
|
||||
queryStart = -1;
|
||||
}
|
||||
}
|
||||
@@ -266,28 +338,31 @@ export class Search {
|
||||
|
||||
query = "";
|
||||
for (let word of words) {
|
||||
// 1. Normal search text, no filters or anything
|
||||
if (!word.includes(":")) {
|
||||
searchTerms.push(word);
|
||||
let cachedResult = [...result];
|
||||
for (let id of cachedResult) {
|
||||
const u = this._items[id];
|
||||
const u = this.items[id];
|
||||
if (!u.matchesSearch(word)) {
|
||||
result.splice(result.indexOf(id), 1);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// 2. A filter query of some sort.
|
||||
const split = [word.substring(0, word.indexOf(":")), word.substring(word.indexOf(":")+1)];
|
||||
|
||||
if (!(split[0] in this._c.queries)) continue;
|
||||
|
||||
const queryFormat = this._c.queries[split[0]];
|
||||
|
||||
let formattedQuery = []
|
||||
let q: Query | null = null;
|
||||
|
||||
if (queryFormat.bool) {
|
||||
let [boolState, isBool] = BoolQuery.paramsFromString(split[1]);
|
||||
if (isBool) {
|
||||
let q = new BoolQuery(queryFormat, boolState);
|
||||
q = new BoolQuery(queryFormat, boolState);
|
||||
q.onclick = () => {
|
||||
for (let quote of [`"`, `'`, ``]) {
|
||||
this._c.search.value = this._c.search.value.replace(split[0] + ":" + quote + split[1] + quote, "");
|
||||
@@ -297,24 +372,21 @@ export class Search {
|
||||
|
||||
this._c.filterArea.appendChild(q.asElement());
|
||||
|
||||
// console.log("is bool, state", boolState);
|
||||
// So removing elements doesn't affect us
|
||||
let cachedResult = [...result];
|
||||
for (let id of cachedResult) {
|
||||
const u = this._items[id];
|
||||
const u = this.items[id];
|
||||
const value = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u);
|
||||
// console.log("got", queryFormat.getter + ":", value);
|
||||
// Remove from result if not matching query
|
||||
if (!q.compare(value)) {
|
||||
// console.log("not matching, result is", result);
|
||||
result.splice(result.indexOf(id), 1);
|
||||
} else {
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (queryFormat.string) {
|
||||
const q = new StringQuery(queryFormat, split[1]);
|
||||
} else if (queryFormat.string) {
|
||||
q = new StringQuery(queryFormat, split[1]);
|
||||
|
||||
q.onclick = () => {
|
||||
for (let quote of [`"`, `'`, ``]) {
|
||||
@@ -328,18 +400,16 @@ export class Search {
|
||||
|
||||
let cachedResult = [...result];
|
||||
for (let id of cachedResult) {
|
||||
const u = this._items[id];
|
||||
const u = this.items[id];
|
||||
const value = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u).toLowerCase();
|
||||
if (!(value.includes(split[1]))) {
|
||||
if (!q.compare(value)) {
|
||||
result.splice(result.indexOf(id), 1);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (queryFormat.date) {
|
||||
} else if (queryFormat.date) {
|
||||
let [parsedDate, op, isDate] = DateQuery.paramsFromString(split[1]);
|
||||
if (!isDate) continue;
|
||||
const q = new DateQuery(queryFormat, op, parsedDate);
|
||||
q = new DateQuery(queryFormat, op, parsedDate);
|
||||
|
||||
q.onclick = () => {
|
||||
for (let quote of [`"`, `'`, ``]) {
|
||||
@@ -354,7 +424,7 @@ export class Search {
|
||||
|
||||
let cachedResult = [...result];
|
||||
for (let id of cachedResult) {
|
||||
const u = this._items[id];
|
||||
const u = this.items[id];
|
||||
const unixValue = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u);
|
||||
if (unixValue == 0) {
|
||||
result.splice(result.indexOf(id), 1);
|
||||
@@ -362,20 +432,24 @@ export class Search {
|
||||
}
|
||||
let value = new Date(unixValue*1000);
|
||||
|
||||
let match = q.compare(value);
|
||||
if (!match) {
|
||||
if (!q.compare(value)) {
|
||||
result.splice(result.indexOf(id), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (q != null) queries.push(q);
|
||||
}
|
||||
|
||||
this._queries = queries;
|
||||
this._searchTerms = searchTerms;
|
||||
return result;
|
||||
}
|
||||
|
||||
showHideSearchOptionsHeader = () => {
|
||||
const sortingBy = !(this._c.sortingByButton.parentElement.classList.contains("hidden"));
|
||||
let sortingBy = false;
|
||||
if (this._c.sortingByButton) sortingBy = !(this._c.sortingByButton.parentElement.classList.contains("hidden"));
|
||||
const hasFilters = this._c.filterArea.textContent != "";
|
||||
console.log("sortingBy", sortingBy, "hasFilters", hasFilters);
|
||||
if (sortingBy || hasFilters) {
|
||||
this._c.searchOptionsHeader.classList.remove("hidden");
|
||||
} else {
|
||||
@@ -383,14 +457,24 @@ export class Search {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -all- elements.
|
||||
get items(): { [id: string]: SearchableItem } { return this._items; }
|
||||
set items(v: { [id: string]: SearchableItem }) {
|
||||
this._items = v;
|
||||
// set items(v: { [id: string]: SearchableItem }) {
|
||||
// this._items = v;
|
||||
// }
|
||||
|
||||
// The order of -all- elements (even those hidden), by their identifier.
|
||||
get ordering(): string[] { return this._ordering; }
|
||||
// Specifically dis-allow setting ordering itself, so that setOrdering is used instead (for the field and ascending params).
|
||||
// set ordering(v: string[]) { this._ordering = v; }
|
||||
setOrdering = (v: string[], field: string, ascending: boolean) => {
|
||||
this._ordering = v;
|
||||
this._sortField = field;
|
||||
this._ascending = ascending;
|
||||
}
|
||||
|
||||
get ordering(): string[] { return this._ordering; }
|
||||
set ordering(v: string[]) { this._ordering = v; }
|
||||
get sortField(): string { return this._sortField; }
|
||||
get ascending(): boolean { return this._ascending; }
|
||||
|
||||
onSearchBoxChange = (newItems: boolean = false, loadAll: boolean = false) => {
|
||||
const query = this._c.search.value;
|
||||
@@ -402,11 +486,23 @@ export class Search {
|
||||
const results = this.search(query);
|
||||
this._c.setVisibility(results, true);
|
||||
this._c.onSearchCallback(results.length, newItems, loadAll);
|
||||
if (this.inServerSearch) {
|
||||
this._serverSearchButtons.forEach((v: HTMLElement) => {
|
||||
v.classList.add("@low");
|
||||
v.classList.remove("@high");
|
||||
});
|
||||
} else {
|
||||
this._serverSearchButtons.forEach((v: HTMLElement) => {
|
||||
v.classList.add("@high");
|
||||
v.classList.remove("@low");
|
||||
});
|
||||
}
|
||||
this.showHideSearchOptionsHeader();
|
||||
if (results.length == 0) {
|
||||
this._c.notFoundPanel.classList.remove("unfocused");
|
||||
} else {
|
||||
this._c.notFoundPanel.classList.add("unfocused");
|
||||
|
||||
}
|
||||
if (this._c.notFoundCallback) this._c.notFoundCallback(results.length == 0);
|
||||
}
|
||||
@@ -420,9 +516,8 @@ export class Search {
|
||||
this._c.search.setSelectionRange(newPos, newPos);
|
||||
this._c.search.oninput(null as any);
|
||||
};
|
||||
|
||||
|
||||
|
||||
// FIXME: Make XQuery classes less specifically for in-progress searches, and include this code for making info button things.
|
||||
generateFilterList = () => {
|
||||
// Generate filter buttons
|
||||
for (let queryName of Object.keys(this._c.queries)) {
|
||||
@@ -497,17 +592,55 @@ export class Search {
|
||||
}
|
||||
}
|
||||
|
||||
onServerSearch = () => {
|
||||
const newServerSearch = !this.inServerSearch;
|
||||
this.inServerSearch = true;
|
||||
this._c.searchServer(this.serverSearchParams(this._searchTerms, this._queries), newServerSearch);
|
||||
}
|
||||
|
||||
serverSearchParams = (searchTerms: string[], queries: Query[]): PaginatedReqDTO => {
|
||||
let req: ServerSearchReqDTO = {
|
||||
searchTerms: searchTerms,
|
||||
queries: queries.map((q: Query) => q.asDTO()),
|
||||
limit: -1,
|
||||
page: 0,
|
||||
sortByField: this.sortField,
|
||||
ascending: this.ascending
|
||||
};
|
||||
return req;
|
||||
}
|
||||
|
||||
constructor(c: SearchConfiguration) {
|
||||
// FIXME: Remove!
|
||||
if (c.search.id.includes("accounts")) {
|
||||
(window as any).s = this;
|
||||
}
|
||||
this._c = c;
|
||||
|
||||
this._c.search.oninput = () => this.onSearchBoxChange();
|
||||
this._c.search.oninput = () => {
|
||||
this.inServerSearch = false;
|
||||
this.onSearchBoxChange();
|
||||
}
|
||||
this._c.search.addEventListener("keyup", (ev: KeyboardEvent) => {
|
||||
if (ev.key == "Enter") {
|
||||
this.onServerSearch();
|
||||
}
|
||||
});
|
||||
|
||||
const clearSearchButtons = Array.from(document.querySelectorAll(this._c.clearSearchButtonSelector)) as Array<HTMLSpanElement>;
|
||||
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<HTMLSpanElement>;
|
||||
for (let b of this._serverSearchButtons) {
|
||||
b.addEventListener("click", () => {
|
||||
this.onServerSearch();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { PageManager, Page } from "../modules/pages.js";
|
||||
|
||||
declare var window: GlobalWindow;
|
||||
|
||||
export interface Tab {
|
||||
page: Page;
|
||||
tabEl: HTMLDivElement;
|
||||
|
||||
@@ -150,14 +150,16 @@ interface inviteList {
|
||||
loadInviteURL: () => void;
|
||||
}
|
||||
|
||||
// Finally added to typescript, dont need this anymore.
|
||||
// declare interface SubmitEvent extends Event {
|
||||
// submitter: HTMLInputElement;
|
||||
// }
|
||||
|
||||
interface paginatedDTO {
|
||||
last_page: boolean;
|
||||
}
|
||||
|
||||
interface PaginatedReqDTO {
|
||||
limit: number;
|
||||
page: number;
|
||||
sortByField: string;
|
||||
ascending: boolean;
|
||||
};
|
||||
|
||||
declare var config: Object;
|
||||
declare var modifiedConfig: Object;
|
||||
|
||||
353
usercache.go
353
usercache.go
@@ -2,56 +2,63 @@ package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// FIXME: Follow mediabrowser, or make tuneable, or both
|
||||
WEB_USER_CACHE_SYNC = 30 * time.Second
|
||||
WEB_USER_CACHE_SYNC = 30 * time.Second
|
||||
USER_DEFAULT_SORT_FIELD = "name"
|
||||
)
|
||||
|
||||
type UserCache struct {
|
||||
Cache []respUser
|
||||
Ref []*respUser
|
||||
Sorted bool
|
||||
LastSync time.Time
|
||||
Lock sync.Mutex
|
||||
}
|
||||
|
||||
func (c *UserCache) Gen(app *appContext) ([]respUser, error) {
|
||||
func (c *UserCache) gen(app *appContext) error {
|
||||
// FIXME: I don't like this.
|
||||
if !time.Now().After(c.LastSync.Add(WEB_USER_CACHE_SYNC)) {
|
||||
return c.Cache, nil
|
||||
return nil
|
||||
}
|
||||
c.Lock.Lock()
|
||||
users, err := app.jf.GetUsers(false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
c.Cache = make([]respUser, len(users))
|
||||
for i, jfUser := range users {
|
||||
c.Cache[i] = app.userSummary(jfUser)
|
||||
}
|
||||
c.Ref = make([]*respUser, len(c.Cache))
|
||||
for i := range c.Cache {
|
||||
c.Ref[i] = &(c.Cache[i])
|
||||
}
|
||||
c.Sorted = false
|
||||
c.LastSync = time.Now()
|
||||
c.Lock.Unlock()
|
||||
return c.Cache, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
type Less func(a, b *respUser) bool
|
||||
type SortableUserList struct {
|
||||
Cache []respUser
|
||||
lessFunc Less
|
||||
}
|
||||
|
||||
func (sc *SortableUserList) Len() int {
|
||||
return len(sc.Cache)
|
||||
}
|
||||
|
||||
func (sc *SortableUserList) Swap(i, j int) {
|
||||
sc.Cache[i], sc.Cache[j] = sc.Cache[j], sc.Cache[i]
|
||||
}
|
||||
|
||||
func (sc *SortableUserList) Less(i, j int) bool {
|
||||
return sc.lessFunc(&sc.Cache[i], &sc.Cache[j])
|
||||
func (c *UserCache) Gen(app *appContext, sorted bool) ([]*respUser, error) {
|
||||
if err := c.gen(app); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sorted && !c.Sorted {
|
||||
c.Lock.Lock()
|
||||
// FIXME: Check we want ascending!
|
||||
c.Sort(c.Ref, USER_DEFAULT_SORT_FIELD, true)
|
||||
c.Sorted = true
|
||||
c.Lock.Unlock()
|
||||
}
|
||||
return c.Ref, nil
|
||||
}
|
||||
|
||||
// instead of making a Less for bools, just convert them to integers
|
||||
@@ -66,93 +73,303 @@ func bool2int(b bool) int {
|
||||
return i
|
||||
}
|
||||
|
||||
// Returns -1 if respUser < value, 0 if equal, 1 is greater than
|
||||
type Sorter func(a, b *respUser) int
|
||||
|
||||
// Allow sorting by respUser's struct fields (well, it's JSON-representation's fields)
|
||||
// Ugly I know, but at least cmp.Less exists.
|
||||
// Done with vim macros, thank god they exist
|
||||
func SortUsersBy(u []respUser, field string) SortableUserList {
|
||||
s := SortableUserList{Cache: u}
|
||||
func SortUsersBy(field string) Sorter {
|
||||
switch field {
|
||||
case "id":
|
||||
return func(a, b *respUser) int {
|
||||
return cmp.Compare(strings.ToLower(a.ID), strings.ToLower(b.ID))
|
||||
}
|
||||
case "name":
|
||||
return func(a, b *respUser) int {
|
||||
return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
|
||||
}
|
||||
case "email":
|
||||
return func(a, b *respUser) int {
|
||||
return cmp.Compare(strings.ToLower(a.Email), strings.ToLower(b.Email))
|
||||
}
|
||||
case "notify_email":
|
||||
return func(a, b *respUser) int {
|
||||
return cmp.Compare(bool2int(a.NotifyThroughEmail), bool2int(b.NotifyThroughEmail))
|
||||
}
|
||||
case "last_active":
|
||||
return func(a, b *respUser) int {
|
||||
return cmp.Compare(a.LastActive, b.LastActive)
|
||||
}
|
||||
case "admin":
|
||||
return func(a, b *respUser) int {
|
||||
return cmp.Compare(bool2int(a.Admin), bool2int(b.Admin))
|
||||
}
|
||||
case "expiry":
|
||||
return func(a, b *respUser) int {
|
||||
return cmp.Compare(a.Expiry, b.Expiry)
|
||||
}
|
||||
case "disabled":
|
||||
return func(a, b *respUser) int {
|
||||
return cmp.Compare(bool2int(a.Disabled), bool2int(b.Disabled))
|
||||
}
|
||||
case "telegram":
|
||||
return func(a, b *respUser) int {
|
||||
return cmp.Compare(strings.ToLower(a.Telegram), strings.ToLower(b.Telegram))
|
||||
}
|
||||
case "notify_telegram":
|
||||
return func(a, b *respUser) int {
|
||||
return cmp.Compare(bool2int(a.NotifyThroughTelegram), bool2int(b.NotifyThroughTelegram))
|
||||
}
|
||||
case "discord":
|
||||
return func(a, b *respUser) int {
|
||||
return cmp.Compare(strings.ToLower(a.Discord), strings.ToLower(b.Discord))
|
||||
}
|
||||
case "discord_id":
|
||||
return func(a, b *respUser) int {
|
||||
return cmp.Compare(strings.ToLower(a.DiscordID), strings.ToLower(b.DiscordID))
|
||||
}
|
||||
case "notify_discord":
|
||||
return func(a, b *respUser) int {
|
||||
return cmp.Compare(bool2int(a.NotifyThroughDiscord), bool2int(b.NotifyThroughDiscord))
|
||||
}
|
||||
case "matrix":
|
||||
return func(a, b *respUser) int {
|
||||
return cmp.Compare(strings.ToLower(a.Matrix), strings.ToLower(b.Matrix))
|
||||
}
|
||||
case "notify_matrix":
|
||||
return func(a, b *respUser) int {
|
||||
return cmp.Compare(bool2int(a.NotifyThroughMatrix), bool2int(b.NotifyThroughMatrix))
|
||||
}
|
||||
case "label":
|
||||
return func(a, b *respUser) int {
|
||||
return cmp.Compare(strings.ToLower(a.Label), strings.ToLower(b.Label))
|
||||
}
|
||||
case "accounts_admin":
|
||||
return func(a, b *respUser) int {
|
||||
return cmp.Compare(bool2int(a.AccountsAdmin), bool2int(b.AccountsAdmin))
|
||||
}
|
||||
case "referrals_enabled":
|
||||
return func(a, b *respUser) int {
|
||||
return cmp.Compare(bool2int(a.ReferralsEnabled), bool2int(b.ReferralsEnabled))
|
||||
}
|
||||
}
|
||||
panic(fmt.Errorf("got invalid field %s", field))
|
||||
return nil
|
||||
}
|
||||
|
||||
type Filter func(*respUser) bool
|
||||
|
||||
type CompareResult int
|
||||
|
||||
const (
|
||||
Lesser CompareResult = -1
|
||||
Equal CompareResult = 0
|
||||
Greater CompareResult = 1
|
||||
)
|
||||
|
||||
// One day i'll figure out Go generics
|
||||
/*type FilterValue interface {
|
||||
bool | string | DateAttempt
|
||||
}*/
|
||||
|
||||
type DateAttempt struct {
|
||||
Year *int `json:"year,omitempty"`
|
||||
Month *int `json:"month,omitempty"`
|
||||
Day *int `json:"day,omitempty"`
|
||||
Hour *int `json:"hour,omitempty"`
|
||||
Minute *int `json:"minute,omitempty"`
|
||||
}
|
||||
|
||||
// Compares a Unix timestamp.
|
||||
// We want to compare only the fields given in DateAttempt,
|
||||
// so we copy subjectDate and apply on those fields from this._value.
|
||||
func (d DateAttempt) Compare(subject int64) int {
|
||||
subjectTime := time.Unix(subject, 0)
|
||||
yy, mo, dd := subjectTime.Date()
|
||||
hh, mm, _ := subjectTime.Clock()
|
||||
if d.Year != nil {
|
||||
yy = *d.Year
|
||||
}
|
||||
if d.Month != nil {
|
||||
// Month in Javascript is zero-based, so we need to increment it
|
||||
mo = time.Month((*d.Month) + 1)
|
||||
}
|
||||
if d.Day != nil {
|
||||
dd = *d.Day
|
||||
}
|
||||
if d.Hour != nil {
|
||||
hh = *d.Hour
|
||||
}
|
||||
if d.Minute != nil {
|
||||
mm = *d.Minute
|
||||
}
|
||||
return subjectTime.Compare(time.Date(yy, mo, dd, hh, mm, 0, 0, nil))
|
||||
}
|
||||
|
||||
// FIXME: Consider using QueryDTO.Class rather than assuming type from name? Probably not worthwhile though.
|
||||
func FilterUsersBy(field string, op QueryOperator, value any) Filter {
|
||||
operator := Equal
|
||||
switch op {
|
||||
case LesserOperator:
|
||||
operator = Lesser
|
||||
case EqualOperator:
|
||||
operator = Equal
|
||||
case GreaterOperator:
|
||||
operator = Greater
|
||||
}
|
||||
|
||||
switch field {
|
||||
case "id":
|
||||
s.lessFunc = func(a, b *respUser) bool {
|
||||
return cmp.Less(a.ID, b.ID)
|
||||
return func(a *respUser) bool {
|
||||
return cmp.Compare(strings.ToLower(a.ID), strings.ToLower(value.(string))) == int(operator)
|
||||
}
|
||||
|
||||
case "name":
|
||||
s.lessFunc = func(a, b *respUser) bool {
|
||||
return cmp.Less(a.Name, b.Name)
|
||||
return func(a *respUser) bool {
|
||||
return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(value.(string))) == int(operator)
|
||||
}
|
||||
case "email":
|
||||
s.lessFunc = func(a, b *respUser) bool {
|
||||
return cmp.Less(a.Email, b.Email)
|
||||
return func(a *respUser) bool {
|
||||
return cmp.Compare(strings.ToLower(a.Email), strings.ToLower(value.(string))) == int(operator)
|
||||
}
|
||||
case "notify_email":
|
||||
s.lessFunc = func(a, b *respUser) bool {
|
||||
return cmp.Less(bool2int(a.NotifyThroughEmail), bool2int(b.NotifyThroughEmail))
|
||||
return func(a *respUser) bool {
|
||||
return cmp.Compare(bool2int(a.NotifyThroughEmail), bool2int(value.(bool))) == int(operator)
|
||||
}
|
||||
case "last_active":
|
||||
s.lessFunc = func(a, b *respUser) bool {
|
||||
return cmp.Less(a.LastActive, b.LastActive)
|
||||
return func(a *respUser) bool {
|
||||
return value.(DateAttempt).Compare(a.LastActive) == int(operator)
|
||||
}
|
||||
case "admin":
|
||||
s.lessFunc = func(a, b *respUser) bool {
|
||||
return cmp.Less(bool2int(a.Admin), bool2int(b.Admin))
|
||||
return func(a *respUser) bool {
|
||||
return cmp.Compare(bool2int(a.Admin), bool2int(value.(bool))) == int(operator)
|
||||
}
|
||||
case "expiry":
|
||||
s.lessFunc = func(a, b *respUser) bool {
|
||||
return cmp.Less(a.Expiry, b.Expiry)
|
||||
return func(a *respUser) bool {
|
||||
return value.(DateAttempt).Compare(a.Expiry) == int(operator)
|
||||
}
|
||||
case "disabled":
|
||||
s.lessFunc = func(a, b *respUser) bool {
|
||||
return cmp.Less(bool2int(a.Disabled), bool2int(b.Disabled))
|
||||
return func(a *respUser) bool {
|
||||
return cmp.Compare(bool2int(a.Disabled), bool2int(value.(bool))) == int(operator)
|
||||
}
|
||||
case "telegram":
|
||||
s.lessFunc = func(a, b *respUser) bool {
|
||||
return cmp.Less(a.Telegram, b.Telegram)
|
||||
return func(a *respUser) bool {
|
||||
return cmp.Compare(strings.ToLower(a.Telegram), strings.ToLower(value.(string))) == int(operator)
|
||||
}
|
||||
case "notify_telegram":
|
||||
s.lessFunc = func(a, b *respUser) bool {
|
||||
return cmp.Less(bool2int(a.NotifyThroughTelegram), bool2int(b.NotifyThroughTelegram))
|
||||
return func(a *respUser) bool {
|
||||
return cmp.Compare(bool2int(a.NotifyThroughTelegram), bool2int(value.(bool))) == int(operator)
|
||||
}
|
||||
case "discord":
|
||||
s.lessFunc = func(a, b *respUser) bool {
|
||||
return cmp.Less(a.Discord, b.Discord)
|
||||
return func(a *respUser) bool {
|
||||
return cmp.Compare(strings.ToLower(a.Discord), strings.ToLower(value.(string))) == int(operator)
|
||||
}
|
||||
case "discord_id":
|
||||
s.lessFunc = func(a, b *respUser) bool {
|
||||
return cmp.Less(a.DiscordID, b.DiscordID)
|
||||
return func(a *respUser) bool {
|
||||
return cmp.Compare(strings.ToLower(a.DiscordID), strings.ToLower(value.(string))) == int(operator)
|
||||
}
|
||||
case "notify_discord":
|
||||
s.lessFunc = func(a, b *respUser) bool {
|
||||
return cmp.Less(bool2int(a.NotifyThroughDiscord), bool2int(b.NotifyThroughDiscord))
|
||||
return func(a *respUser) bool {
|
||||
return cmp.Compare(bool2int(a.NotifyThroughDiscord), bool2int(value.(bool))) == int(operator)
|
||||
}
|
||||
case "matrix":
|
||||
s.lessFunc = func(a, b *respUser) bool {
|
||||
return cmp.Less(a.Matrix, b.Matrix)
|
||||
return func(a *respUser) bool {
|
||||
return cmp.Compare(strings.ToLower(a.Matrix), strings.ToLower(value.(string))) == int(operator)
|
||||
}
|
||||
case "notify_matrix":
|
||||
s.lessFunc = func(a, b *respUser) bool {
|
||||
return cmp.Less(bool2int(a.NotifyThroughMatrix), bool2int(b.NotifyThroughMatrix))
|
||||
return func(a *respUser) bool {
|
||||
return cmp.Compare(bool2int(a.NotifyThroughMatrix), bool2int(value.(bool))) == int(operator)
|
||||
}
|
||||
case "label":
|
||||
s.lessFunc = func(a, b *respUser) bool {
|
||||
return cmp.Less(a.Label, b.Label)
|
||||
return func(a *respUser) bool {
|
||||
return cmp.Compare(strings.ToLower(a.Label), strings.ToLower(value.(string))) == int(operator)
|
||||
}
|
||||
case "accounts_admin":
|
||||
s.lessFunc = func(a, b *respUser) bool {
|
||||
return cmp.Less(bool2int(a.AccountsAdmin), bool2int(b.AccountsAdmin))
|
||||
return func(a *respUser) bool {
|
||||
return cmp.Compare(bool2int(a.AccountsAdmin), bool2int(value.(bool))) == int(operator)
|
||||
}
|
||||
case "referrals_enabled":
|
||||
s.lessFunc = func(a, b *respUser) bool {
|
||||
return cmp.Less(bool2int(a.ReferralsEnabled), bool2int(b.ReferralsEnabled))
|
||||
return func(a *respUser) bool {
|
||||
return cmp.Compare(bool2int(a.ReferralsEnabled), bool2int(value.(bool))) == int(operator)
|
||||
}
|
||||
}
|
||||
return s
|
||||
panic(fmt.Errorf("got invalid field %s", field))
|
||||
return nil
|
||||
}
|
||||
|
||||
type Filter func(yield func(*respUser) bool)
|
||||
|
||||
type FilterableList struct {
|
||||
Cache []respUser
|
||||
filterFunc Filter
|
||||
func (ru *respUser) MatchesSearch(term string) bool {
|
||||
return (strings.Contains(ru.ID, term) ||
|
||||
strings.Contains(strings.ToLower(ru.Name), term) ||
|
||||
strings.Contains(strings.ToLower(ru.Label), term) ||
|
||||
strings.Contains(strings.ToLower(ru.Email), term) ||
|
||||
strings.Contains(strings.ToLower(ru.Discord), term) ||
|
||||
strings.Contains(strings.ToLower(ru.Matrix), term) ||
|
||||
strings.Contains(strings.ToLower(ru.Telegram), term))
|
||||
}
|
||||
|
||||
type QueryClass string
|
||||
|
||||
const (
|
||||
BoolQuery QueryClass = "bool"
|
||||
StringQuery QueryClass = "string"
|
||||
DateQuery QueryClass = "date"
|
||||
)
|
||||
|
||||
type QueryOperator string
|
||||
|
||||
const (
|
||||
LesserOperator QueryOperator = "<"
|
||||
EqualOperator QueryOperator = "="
|
||||
GreaterOperator QueryOperator = ">"
|
||||
)
|
||||
|
||||
type QueryDTO struct {
|
||||
Class QueryClass `json:"class"`
|
||||
Field string `json:"field"`
|
||||
Operator QueryOperator `json:"operator"`
|
||||
// string | bool | DateAttempt
|
||||
Value any `json:"value"`
|
||||
}
|
||||
|
||||
type ServerSearchReqDTO struct {
|
||||
PaginatedReqDTO
|
||||
SearchTerms []string `json:"searchTerms"`
|
||||
Queries []QueryDTO `json:"queries"`
|
||||
}
|
||||
|
||||
// Filter by AND-ing all search terms and queries.
|
||||
func (c *UserCache) Filter(users []*respUser, terms []string, queries []QueryDTO) []*respUser {
|
||||
filters := make([]Filter, len(queries))
|
||||
for i, q := range queries {
|
||||
filters[i] = FilterUsersBy(q.Field, q.Operator, q.Value)
|
||||
}
|
||||
// FIXME: Properly consider pre-allocation size
|
||||
out := make([]*respUser, 0, len(users)/4)
|
||||
for i := range users {
|
||||
match := true
|
||||
for _, term := range terms {
|
||||
if !users[i].MatchesSearch(term) {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !match {
|
||||
continue
|
||||
}
|
||||
for _, filter := range filters {
|
||||
if filter == nil || !filter(users[i]) {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
out = append(out, users[i])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *UserCache) Sort(users []*respUser, field string, ascending bool) {
|
||||
slices.SortFunc(users, SortUsersBy(field))
|
||||
if !ascending {
|
||||
slices.Reverse(users)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user