From 2d83718f810f2026e8a75615dfa9448155304aa3 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Sun, 22 Oct 2023 00:28:01 +0100 Subject: [PATCH] activity: sort, load more, compromises for client-side search my initial intent before starting search was for it to be server-sided, considering this activity log could rack up 100s or 1000s of entries, and then I forgot and did it client-sided. this commit adds a feature to load more results when scrolled to the bottom, and when a search returns few or no results (this is limited, so it wont loop infinitely). Also finally got rid of the useless left column, since my ideas didn't match my implementation. also, sorting is only by date, can't be bothered with anything else. --- api-activities.go | 3 +- css/loader.css | 10 ++++ html/admin.html | 29 ++++----- lang/admin/en-us.json | 3 +- models.go | 1 + ts/modules/accounts.ts | 2 +- ts/modules/activity.ts | 129 +++++++++++++++++++++++++++++++++-------- ts/modules/common.ts | 4 +- ts/modules/search.ts | 11 ++-- 9 files changed, 140 insertions(+), 52 deletions(-) diff --git a/api-activities.go b/api-activities.go index db13c95..71fb092 100644 --- a/api-activities.go +++ b/api-activities.go @@ -104,7 +104,7 @@ func (app *appContext) GetActivities(gc *gin.Context) { query = badgerhold.Where("Type").In(activityTypes...) } - if req.Ascending { + if !req.Ascending { query = query.Reverse() } @@ -125,6 +125,7 @@ func (app *appContext) GetActivities(gc *gin.Context) { resp := GetActivitiesRespDTO{ Activities: make([]ActivityDTO, len(results)), + LastPage: len(results) != req.Limit, } for i, act := range results { diff --git a/css/loader.css b/css/loader.css index b9c4bfa..40459ec 100644 --- a/css/loader.css +++ b/css/loader.css @@ -3,6 +3,10 @@ color: rgba(0, 0, 0, 0) !important; } +.loader.rel { + position: relative; +} + .loader .dot { --diameter: 0.5rem; --radius: calc(var(--diameter) / 2); @@ -15,6 +19,12 @@ left: calc(50% - var(--radius)); animation: osc 1s cubic-bezier(.72,.16,.31,.97) infinite; } + +.loader.rel .dot { + position: absolute; + top: 50%; +} + .loader.loader-sm .dot { --deviation: 10%; } diff --git a/html/admin.html b/html/admin.html index ed3fa62..e091f58 100644 --- a/html/admin.html +++ b/html/admin.html @@ -732,6 +732,7 @@ + @@ -740,25 +741,15 @@ -
-
-
- - -
- - {{ .strings.aboutProgram }} - {{ .strings.userProfiles }} -
-
-
-
-
- {{ .strings.noResultsFound }} - -
+
+
+
+
+
+ {{ .strings.noResultsFound }} +
diff --git a/lang/admin/en-us.json b/lang/admin/en-us.json index 86c4872..f7a16e9 100644 --- a/lang/admin/en-us.json +++ b/lang/admin/en-us.json @@ -121,6 +121,7 @@ "accessJFA": "Access jfa-go", "accessJFASettings": "Cannot be changed as either \"Admin Only\" or \"Allow All\" has been set in Settings > General.", "sortingBy": "Sorting By", + "sortDirection": "Sort Direction", "filters": "Filters", "clickToRemoveFilter": "Click to remove this filter.", "clearSearch": "Clear search", @@ -156,7 +157,7 @@ "title": "Title", "usersMentioned": "User mentioned", "actor": "Actor", - "actorDescription": "The thing that caused this action.
\"user\"/\"admin\"/\"daemon\" or a username.", + "actorDescription": "The thing that caused this action. \"user\"/\"admin\"/\"daemon\" or a username.", "accountCreationFilter": "Account Creation", "accountDeletionFilter": "Account Deletion", "accountDisabledFilter": "Account Disabled", diff --git a/models.go b/models.go index b8573fe..e24899b 100644 --- a/models.go +++ b/models.go @@ -453,4 +453,5 @@ type GetActivitiesDTO struct { type GetActivitiesRespDTO struct { Activities []ActivityDTO `json:"activities"` + LastPage bool `json:"last_page"` } diff --git a/ts/modules/accounts.ts b/ts/modules/accounts.ts index df1da8d..3fa73fa 100644 --- a/ts/modules/accounts.ts +++ b/ts/modules/accounts.ts @@ -1808,7 +1808,7 @@ export class accountsList { queries: this._queries, setVisibility: this.setVisibility, clearSearchButtonSelector: ".accounts-search-clear", - onSearchCallback: () => { + onSearchCallback: (_0: number, _1: boolean) => { this._checkCheckCount(); } }; diff --git a/ts/modules/activity.ts b/ts/modules/activity.ts index b09d42e..474a9a5 100644 --- a/ts/modules/activity.ts +++ b/ts/modules/activity.ts @@ -1,4 +1,4 @@ -import { _post, _delete, toDateString } from "../modules/common.js"; +import { _post, _delete, toDateString, addLoader, removeLoader } from "../modules/common.js"; import { Search, SearchConfiguration, QueryType, SearchableItem } from "../modules/search.js"; export interface activity { @@ -31,7 +31,7 @@ var activityTypeMoods = { export var activityReload = new CustomEvent("activity-reload"); -export class Activity implements activity, SearchableItem { // FIXME: Add "implements" +export class Activity implements activity, SearchableItem { private _card: HTMLElement; private _title: HTMLElement; private _time: HTMLElement; @@ -173,14 +173,6 @@ export class Activity implements activity, SearchableItem { // FIXME: Add "imple this._title.innerHTML = innerHTML.replace("{invite}", this._renderInvText()); } - - /*} else if (this.source_type == "admin") { - // FIXME: Handle contactLinked/Unlinked, creation/deletion, enable/disable, createInvite/deleteInvite - } else if (this.source_type == "anon") { - this._referrer.innerHTML = ``; - } else if (this.source_type == "daemon") { - // FIXME: Handle deleteInvite, disabled, deletion - }*/ } get time(): number { return this._timeUnix; } @@ -289,7 +281,6 @@ export class Activity implements activity, SearchableItem { // FIXME: Add "imple } update = (act: activity) => { - // FIXME this._act = act; this.source_type = act.source_type; this.invite_code = act.invite_code; @@ -312,6 +303,7 @@ export class Activity implements activity, SearchableItem { // FIXME: Add "imple interface ActivitiesDTO { activities: activity[]; + last_page: boolean; } export class activityList { @@ -323,7 +315,14 @@ export class activityList { 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 _search: Search; + private _ascending: boolean; + private _hasLoaded: boolean; + private _lastLoad: number; + private _page: number = 0; + private _lastPage: boolean; setVisibility = (activities: string[], visible: boolean) => { @@ -338,11 +337,19 @@ export class activityList { } reload = () => { + this._lastLoad = Date.now(); + this._lastPage = false; + // this._page = 0; + let limit = 10; + if (this._page != 0) { + limit *= this._page+1; + }; + let send = { "type": [], - "limit": 60, + "limit": limit, "page": 0, - "ascending": false + "ascending": this.ascending } _post("/activity", send, (req: XMLHttpRequest) => { if (req.readyState != 4) return; @@ -351,33 +358,72 @@ export class activityList { return; } + this._hasLoaded = true; + 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._activityList.appendChild(this._activities[act.id].asElement()); + this._ordering.push(act.id); } this._search.items = this._activities; - // FIXME: Actually implement sorting - this._ordering = Object.keys(this._activities); this._search.ordering = this._ordering; if (this._search.inSearch) { - const results = this._search.search(this._searchBox.value); - this.setVisibility(results, true); - if (results.length == 0) { - this._notFoundPanel.classList.remove("unfocused"); - } else { - this._notFoundPanel.classList.add("unfocused"); - } + this._search.onSearchBoxChange(true); } else { this.setVisibility(this._ordering, true); this._notFoundPanel.classList.add("unfocused"); } }, true); } - + + loadMore = () => { + this._lastLoad = Date.now(); + 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; + } + + let resp = req.response as ActivitiesDTO; + + this._lastPage = resp.last_page; + + 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; + + if (this._search.inSearch) { + this._search.onSearchBoxChange(true); + } else { + this.setVisibility(this._ordering, true); + this._notFoundPanel.classList.add("unfocused"); + } + // removeLoader(this._loader); + // this._activityList.classList.remove("unfocused"); + }, true); + } + private _queries: { [field: string]: QueryType } = { "id": { name: window.lang.strings("activityID"), @@ -494,6 +540,27 @@ export class activityList { } }; + get ascending(): boolean { return this._ascending; } + set ascending(v: boolean) { + this._ascending = v; + this._sortDirection.innerHTML = `${window.lang.strings("sortDirection")} `; + if (this._hasLoaded) { + this.reload(); + } + } + + detectScroll = () => { + // 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 1s between loads + if (this._lastLoad + 1000 > Date.now()) return; + this.loadMore(); + } + } + + private _prevResultCount = 0; + constructor() { this._activityList = document.getElementById("activity-card-list"); document.addEventListener("activity-reload", this.reload); @@ -508,9 +575,21 @@ export class activityList { queries: this._queries, setVisibility: this.setVisibility, filterList: document.getElementById("activity-filter-list"), - onSearchCallback: () => {} + onSearchCallback: (visibleCount: number, newItems: boolean) => { + + if (visibleCount < 10) { + if (!newItems || this._prevResultCount != visibleCount || (visibleCount == 0 && !this._lastPage)) this.loadMore(); + } + 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); + + window.onscroll = this.detectScroll; } } diff --git a/ts/modules/common.ts b/ts/modules/common.ts index c7deb0b..063253f 100644 --- a/ts/modules/common.ts +++ b/ts/modules/common.ts @@ -199,9 +199,10 @@ export function toggleLoader(el: HTMLElement, small: boolean = true) { } } -export function addLoader(el: HTMLElement, small: boolean = true) { +export function addLoader(el: HTMLElement, small: boolean = true, relative: boolean = false) { if (!el.classList.contains("loader")) { el.classList.add("loader"); + if (relative) el.classList.add("rel"); if (small) { el.classList.add("loader-sm"); } const dot = document.createElement("span") as HTMLSpanElement; dot.classList.add("dot") @@ -213,6 +214,7 @@ export function removeLoader(el: HTMLElement, small: boolean = true) { if (el.classList.contains("loader")) { el.classList.remove("loader"); el.classList.remove("loader-sm"); + el.classList.remove("rel"); const dot = el.querySelector("span.dot"); if (dot) { dot.remove(); } } diff --git a/ts/modules/search.ts b/ts/modules/search.ts index 0260f60..5aa9cc2 100644 --- a/ts/modules/search.ts +++ b/ts/modules/search.ts @@ -21,7 +21,8 @@ export interface SearchConfiguration { search: HTMLInputElement; queries: { [field: string]: QueryType }; setVisibility: (items: string[], visible: boolean) => void; - onSearchCallback: () => void; + onSearchCallback: (visibleCount: number, newItems: boolean) => void; + loadMore?: () => void; } export interface SearchableItem { @@ -267,7 +268,7 @@ export class Search { get ordering(): string[] { return this._ordering; } set ordering(v: string[]) { this._ordering = v; } - onSearchBoxChange = () => { + onSearchBoxChange = (newItems: boolean = false) => { const query = this._c.search.value; if (!query) { this.inSearch = false; @@ -276,7 +277,7 @@ export class Search { } const results = this.search(query); this._c.setVisibility(results, true); - this._c.onSearchCallback(); + this._c.onSearchCallback(results.length, newItems); this.showHideSearchOptionsHeader(); if (results.length == 0) { this._c.notFoundPanel.classList.remove("unfocused"); @@ -294,6 +295,8 @@ export class Search { this._c.search.setSelectionRange(newPos, newPos); this._c.search.oninput(null as any); }; + + generateFilterList = () => { // Generate filter buttons @@ -372,7 +375,7 @@ export class Search { constructor(c: SearchConfiguration) { this._c = c; - this._c.search.oninput = this.onSearchBoxChange; + this._c.search.oninput = () => this.onSearchBoxChange(); const clearSearchButtons = Array.from(document.querySelectorAll(this._c.clearSearchButtonSelector)) as Array; for (let b of clearSearchButtons) {