1
0
mirror of https://github.com/hrfee/jfa-go.git synced 2024-12-28 03:50:10 +00:00

Compare commits

...

2 Commits

Author SHA1 Message Date
47ce8a9ec4
activity: refresh, load more buttons, ui adjustments 2023-10-22 01:03:48 +01:00
2d83718f81
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.
2023-10-22 00:31:30 +01:00
9 changed files with 166 additions and 56 deletions

View File

@ -104,7 +104,7 @@ func (app *appContext) GetActivities(gc *gin.Context) {
query = badgerhold.Where("Type").In(activityTypes...) query = badgerhold.Where("Type").In(activityTypes...)
} }
if req.Ascending { if !req.Ascending {
query = query.Reverse() query = query.Reverse()
} }
@ -125,6 +125,7 @@ func (app *appContext) GetActivities(gc *gin.Context) {
resp := GetActivitiesRespDTO{ resp := GetActivitiesRespDTO{
Activities: make([]ActivityDTO, len(results)), Activities: make([]ActivityDTO, len(results)),
LastPage: len(results) != req.Limit,
} }
for i, act := range results { for i, act := range results {

View File

@ -3,6 +3,10 @@
color: rgba(0, 0, 0, 0) !important; color: rgba(0, 0, 0, 0) !important;
} }
.loader.rel {
position: relative;
}
.loader .dot { .loader .dot {
--diameter: 0.5rem; --diameter: 0.5rem;
--radius: calc(var(--diameter) / 2); --radius: calc(var(--diameter) / 2);
@ -15,6 +19,12 @@
left: calc(50% - var(--radius)); left: calc(50% - var(--radius));
animation: osc 1s cubic-bezier(.72,.16,.31,.97) infinite; animation: osc 1s cubic-bezier(.72,.16,.31,.97) infinite;
} }
.loader.rel .dot {
position: absolute;
top: 50%;
}
.loader.loader-sm .dot { .loader.loader-sm .dot {
--deviation: 10%; --deviation: 10%;
} }

View File

@ -732,34 +732,29 @@
</div> </div>
</div> </div>
</div> </div>
<button class="button ~neutral @low ml-2" id="activity-sort-direction">{{ .strings.sortDirection }}</button>
<input type="search" class="field ~neutral @low input search ml-2 mr-2" id="activity-search" placeholder="{{ .strings.search }}"> <input type="search" class="field ~neutral @low input search ml-2 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> <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> </div>
<div class="supra py-1 sm hidden" id="activity-search-options-header">{{ .strings.searchOptions }}</div> <div class="supra py-1 sm hidden" id="activity-search-options-header">{{ .strings.searchOptions }}</div>
<div class="row -mx-2 mb-2"> <div class="row -mx-2 mb-2">
<button type="button" class="button ~neutral @low center mx-2 hidden"><span id="activity-sort-by-field"></span> <i class="ri-close-line ml-2 text-2xl"></i></button> <button type="button" class="button ~neutral @low center mx-2 hidden"><span id="activity-sort-by-field"></span> <i class="ri-close-line ml-2 text-2xl"></i></button>
<span id="activity-filter-area"></span> <span id="activity-filter-area"></span>
</div> </div>
<div class="flex flex-col md:flex-row gap-3"> <div class="my-2">
<div class="card @low dark:~d_neutral col max-w-[20%]"> <div id="activity-card-list"></div>
<div class="flex-expand"> <div id="activity-loader"></div>
<input type="search" class="field ~neutral @low input settings-section-button justify-between mb-2" id="settings-search" placeholder="{{ .strings.search }}"> <div class="unfocused h-[100%] my-3" id="activity-not-found">
<button class="button ~neutral @low center -ml-10 rounded-s-none mb-2 settings-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></button> <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 activity-search-clear">
<span class="mr-2">{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
</button>
</div> </div>
<aside class="aside sm ~urge dark:~d_info mb-2 @low" id="settings-message">Note: <span class="badge ~critical">*</span> indicates a required field, <span class="badge ~info dark:~d_warning">R</span> indicates changes require a restart.</aside>
<span class="button ~neutral @low settings-section-button justify-between mb-2" id="setting-about"><span class="flex">{{ .strings.aboutProgram }} <i class="ri-information-line ml-2"></i></span></span>
<span class="button ~neutral @low settings-section-button justify-between mb-2" id="setting-profiles"><span class="flex">{{ .strings.userProfiles }} <i class="ri-user-line ml-2"></i></span></span>
</div> </div>
<div class="card ~neutral @low col"> <div class="flex justify-center">
<div id="activity-card-list"></div> <button class="button my-2 ~neutral @low" id="activity-load-more">{{ .strings.loadMore }}</button>
<div class="unfocused h-[100%] my-3" id="activity-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 activity-search-clear">
<span class="mr-2">{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
</button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -121,6 +121,7 @@
"accessJFA": "Access jfa-go", "accessJFA": "Access jfa-go",
"accessJFASettings": "Cannot be changed as either \"Admin Only\" or \"Allow All\" has been set in Settings > General.", "accessJFASettings": "Cannot be changed as either \"Admin Only\" or \"Allow All\" has been set in Settings > General.",
"sortingBy": "Sorting By", "sortingBy": "Sorting By",
"sortDirection": "Sort Direction",
"filters": "Filters", "filters": "Filters",
"clickToRemoveFilter": "Click to remove this filter.", "clickToRemoveFilter": "Click to remove this filter.",
"clearSearch": "Clear search", "clearSearch": "Clear search",
@ -156,7 +157,7 @@
"title": "Title", "title": "Title",
"usersMentioned": "User mentioned", "usersMentioned": "User mentioned",
"actor": "Actor", "actor": "Actor",
"actorDescription": "The thing that caused this action. <hr class=\"sep\"> \"user\"/\"admin\"/\"daemon\" or a username.", "actorDescription": "The thing that caused this action. \"user\"/\"admin\"/\"daemon\" or a username.",
"accountCreationFilter": "Account Creation", "accountCreationFilter": "Account Creation",
"accountDeletionFilter": "Account Deletion", "accountDeletionFilter": "Account Deletion",
"accountDisabledFilter": "Account Disabled", "accountDisabledFilter": "Account Disabled",
@ -166,7 +167,9 @@
"passwordChangeFilter": "Password Changed", "passwordChangeFilter": "Password Changed",
"passwordResetFilter": "Password Reset", "passwordResetFilter": "Password Reset",
"inviteCreatedFilter": "Invite Created", "inviteCreatedFilter": "Invite Created",
"inviteDeletedFilter": "Invite Deleted/Expired" "inviteDeletedFilter": "Invite Deleted/Expired",
"loadMore": "Load More",
"noMoreResults": "No more results."
}, },
"notifications": { "notifications": {
"changedEmailAddress": "Changed email address of {n}.", "changedEmailAddress": "Changed email address of {n}.",

View File

@ -453,4 +453,5 @@ type GetActivitiesDTO struct {
type GetActivitiesRespDTO struct { type GetActivitiesRespDTO struct {
Activities []ActivityDTO `json:"activities"` Activities []ActivityDTO `json:"activities"`
LastPage bool `json:"last_page"`
} }

View File

@ -1808,7 +1808,7 @@ export class accountsList {
queries: this._queries, queries: this._queries,
setVisibility: this.setVisibility, setVisibility: this.setVisibility,
clearSearchButtonSelector: ".accounts-search-clear", clearSearchButtonSelector: ".accounts-search-clear",
onSearchCallback: () => { onSearchCallback: (_0: number, _1: boolean) => {
this._checkCheckCount(); this._checkCheckCount();
} }
}; };

View File

@ -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"; import { Search, SearchConfiguration, QueryType, SearchableItem } from "../modules/search.js";
export interface activity { export interface activity {
@ -31,7 +31,7 @@ var activityTypeMoods = {
export var activityReload = new CustomEvent("activity-reload"); 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 _card: HTMLElement;
private _title: HTMLElement; private _title: HTMLElement;
private _time: 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()); 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; } get time(): number { return this._timeUnix; }
@ -289,7 +281,6 @@ export class Activity implements activity, SearchableItem { // FIXME: Add "imple
} }
update = (act: activity) => { update = (act: activity) => {
// FIXME
this._act = act; this._act = act;
this.source_type = act.source_type; this.source_type = act.source_type;
this.invite_code = act.invite_code; this.invite_code = act.invite_code;
@ -312,6 +303,7 @@ export class Activity implements activity, SearchableItem { // FIXME: Add "imple
interface ActivitiesDTO { interface ActivitiesDTO {
activities: activity[]; activities: activity[];
last_page: boolean;
} }
export class activityList { export class activityList {
@ -323,7 +315,16 @@ export class activityList {
private _sortingByButton = document.getElementById("activity-sort-by-field") as HTMLButtonElement; private _sortingByButton = document.getElementById("activity-sort-by-field") as HTMLButtonElement;
private _notFoundPanel = document.getElementById("activity-not-found"); private _notFoundPanel = document.getElementById("activity-not-found");
private _searchBox = document.getElementById("activity-search") as HTMLInputElement; 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 _refreshButton = document.getElementById("activity-refresh") as HTMLButtonElement;
private _search: Search; private _search: Search;
private _ascending: boolean;
private _hasLoaded: boolean;
private _lastLoad: number;
private _page: number = 0;
private _lastPage: boolean;
setVisibility = (activities: string[], visible: boolean) => { setVisibility = (activities: string[], visible: boolean) => {
@ -338,12 +339,70 @@ export class activityList {
} }
reload = () => { reload = () => {
this._lastLoad = Date.now();
this._lastPage = false;
// this._page = 0;
let limit = 10;
if (this._page != 0) {
limit *= this._page+1;
};
let send = { let send = {
"type": [], "type": [],
"limit": 60, "limit": limit,
"page": 0, "page": 0,
"ascending": false "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;
if (this._search.inSearch) {
this._search.onSearchBoxChange(true);
} else {
this.setVisibility(this._ordering, true);
this._notFoundPanel.classList.add("unfocused");
}
}, true);
}
loadMore = () => {
this._lastLoad = Date.now();
this._loadMoreButton.disabled = true;
const timeout = setTimeout(() => this._loadMoreButton.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) => { _post("/activity", send, (req: XMLHttpRequest) => {
if (req.readyState != 4) return; if (req.readyState != 4) return;
if (req.status != 200) { if (req.status != 200) {
@ -352,32 +411,32 @@ export class activityList {
} }
let resp = req.response as ActivitiesDTO; let resp = req.response as ActivitiesDTO;
// FIXME: Don't destroy everything each reload!
this._activities = {}; this._lastPage = resp.last_page;
if (this._lastPage) {
clearTimeout(timeout);
this._loadMoreButton.disabled = true;
this._loadMoreButton.textContent = window.lang.strings("noMoreResults");
}
for (let act of resp.activities) { for (let act of resp.activities) {
this._activities[act.id] = new Activity(act); 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; // this._search.items = this._activities;
// FIXME: Actually implement sorting // this._search.ordering = this._ordering;
this._ordering = Object.keys(this._activities);
this._search.ordering = this._ordering;
if (this._search.inSearch) { if (this._search.inSearch) {
const results = this._search.search(this._searchBox.value); this._search.onSearchBoxChange(true);
this.setVisibility(results, true);
if (results.length == 0) {
this._notFoundPanel.classList.remove("unfocused");
} else {
this._notFoundPanel.classList.add("unfocused");
}
} else { } else {
this.setVisibility(this._ordering, true); this.setVisibility(this._ordering, true);
this._notFoundPanel.classList.add("unfocused"); this._notFoundPanel.classList.add("unfocused");
} }
// removeLoader(this._loader);
// this._activityList.classList.remove("unfocused");
}, true); }, true);
} }
private _queries: { [field: string]: QueryType } = { private _queries: { [field: string]: QueryType } = {
"id": { "id": {
name: window.lang.strings("activityID"), name: window.lang.strings("activityID"),
@ -494,6 +553,27 @@ export class activityList {
} }
}; };
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>`;
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() { constructor() {
this._activityList = document.getElementById("activity-card-list"); this._activityList = document.getElementById("activity-card-list");
document.addEventListener("activity-reload", this.reload); document.addEventListener("activity-reload", this.reload);
@ -508,9 +588,24 @@ export class activityList {
queries: this._queries, queries: this._queries,
setVisibility: this.setVisibility, setVisibility: this.setVisibility,
filterList: document.getElementById("activity-filter-list"), 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 = new Search(conf);
this._search.generateFilterList(); this._search.generateFilterList();
this._hasLoaded = false;
this.ascending = false;
this._sortDirection.addEventListener("click", () => this.ascending = !this.ascending);
this._loadMoreButton.onclick = this.loadMore;
this._refreshButton.onclick = this.reload;
window.onscroll = this.detectScroll;
} }
} }

View File

@ -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")) { if (!el.classList.contains("loader")) {
el.classList.add("loader"); el.classList.add("loader");
if (relative) el.classList.add("rel");
if (small) { el.classList.add("loader-sm"); } if (small) { el.classList.add("loader-sm"); }
const dot = document.createElement("span") as HTMLSpanElement; const dot = document.createElement("span") as HTMLSpanElement;
dot.classList.add("dot") dot.classList.add("dot")
@ -213,6 +214,7 @@ export function removeLoader(el: HTMLElement, small: boolean = true) {
if (el.classList.contains("loader")) { if (el.classList.contains("loader")) {
el.classList.remove("loader"); el.classList.remove("loader");
el.classList.remove("loader-sm"); el.classList.remove("loader-sm");
el.classList.remove("rel");
const dot = el.querySelector("span.dot"); const dot = el.querySelector("span.dot");
if (dot) { dot.remove(); } if (dot) { dot.remove(); }
} }

View File

@ -21,7 +21,8 @@ export interface SearchConfiguration {
search: HTMLInputElement; search: HTMLInputElement;
queries: { [field: string]: QueryType }; queries: { [field: string]: QueryType };
setVisibility: (items: string[], visible: boolean) => void; setVisibility: (items: string[], visible: boolean) => void;
onSearchCallback: () => void; onSearchCallback: (visibleCount: number, newItems: boolean) => void;
loadMore?: () => void;
} }
export interface SearchableItem { export interface SearchableItem {
@ -267,7 +268,7 @@ export class Search {
get ordering(): string[] { return this._ordering; } get ordering(): string[] { return this._ordering; }
set ordering(v: string[]) { this._ordering = v; } set ordering(v: string[]) { this._ordering = v; }
onSearchBoxChange = () => { onSearchBoxChange = (newItems: boolean = false) => {
const query = this._c.search.value; const query = this._c.search.value;
if (!query) { if (!query) {
this.inSearch = false; this.inSearch = false;
@ -276,7 +277,7 @@ export class Search {
} }
const results = this.search(query); const results = this.search(query);
this._c.setVisibility(results, true); this._c.setVisibility(results, true);
this._c.onSearchCallback(); this._c.onSearchCallback(results.length, newItems);
this.showHideSearchOptionsHeader(); this.showHideSearchOptionsHeader();
if (results.length == 0) { if (results.length == 0) {
this._c.notFoundPanel.classList.remove("unfocused"); this._c.notFoundPanel.classList.remove("unfocused");
@ -294,6 +295,8 @@ export class Search {
this._c.search.setSelectionRange(newPos, newPos); this._c.search.setSelectionRange(newPos, newPos);
this._c.search.oninput(null as any); this._c.search.oninput(null as any);
}; };
generateFilterList = () => { generateFilterList = () => {
// Generate filter buttons // Generate filter buttons
@ -372,7 +375,7 @@ export class Search {
constructor(c: SearchConfiguration) { constructor(c: SearchConfiguration) {
this._c = c; 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<HTMLSpanElement>; const clearSearchButtons = Array.from(document.querySelectorAll(this._c.clearSearchButtonSelector)) as Array<HTMLSpanElement>;
for (let b of clearSearchButtons) { for (let b of clearSearchButtons) {