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