diff --git a/activitysort.go b/activitysort.go new file mode 100644 index 0000000..63d27c5 --- /dev/null +++ b/activitysort.go @@ -0,0 +1,255 @@ +package main + +import ( + "fmt" + "strings" + "time" + + "github.com/hrfee/mediabrowser" + "github.com/timshannon/badgerhold/v4" +) + +const ( + ACTIVITY_DEFAULT_SORT_FIELD = "Time" + // This will be default anyway, as the default value of a bool field is false. + // ACTIVITY_DEFAULT_SORT_ASCENDING = false +) + +func activityDTONameToField(field string) string { + // Only "ID" and "Time" of these are actually searched by the UI. + // We support the rest though for other consumers of the API. + switch field { + case "id": + return "ID" + case "type": + return "Type" + case "user_id": + return "UserID" + case "username": + return "Username" + case "source_type": + return "SourceType" + case "source": + return "Source" + case "source_username": + return "SourceUsername" + case "invite_code": + return "InviteCode" + case "value": + return "Value" + case "time": + return "Time" + case "ip": + return "IP" + } + return "unknown" +} + +func activityTypeGetterNameToType(getter string) ActivityType { + switch getter { + case "accountCreation": + return ActivityCreation + case "accountDeletion": + return ActivityDeletion + case "accountDisabled": + return ActivityDisabled + case "accountEnabled": + return ActivityEnabled + case "contactLinked": + return ActivityContactLinked + case "contactUnlinked": + return ActivityContactUnlinked + case "passwordChange": + return ActivityChangePassword + case "passwordReset": + return ActivityResetPassword + case "inviteCreated": + return ActivityCreateInvite + case "inviteDeleted": + return ActivityDeleteInvite + } + return ActivityUnknown +} + +// andField appends to the existing query if not nil, and otherwise creates a new one. +func andField(q *badgerhold.Query, field string) *badgerhold.Criterion { + if q == nil { + return badgerhold.Where(field) + } + return q.And(field) +} + +// AsDBQuery returns a mutated "query" filtering for the conditions in "q". +func (q QueryDTO) AsDBQuery(query *badgerhold.Query) *badgerhold.Query { + // Special case for activity type: + // In the app, there isn't an "activity:" query, but rather "<~fieldname>:true/false" queries. + // For other API consumers, we also handle the former later. + activityType := activityTypeGetterNameToType(q.Field) + if activityType != ActivityUnknown { + criterion := andField(query, "Type") + if q.Operator != EqualOperator { + panic(fmt.Errorf("impossible operator for activity type: %v", q.Operator)) + } + if q.Value.(bool) == true { + query = criterion.Eq(activityType) + } else { + query = criterion.Ne(activityType) + } + return query + } + + fieldName := activityDTONameToField(q.Field) + // Fail if unrecognized, or recognized as time (we handle this with DateAttempt.Compare separately). + if fieldName == "unknown" || fieldName == "Time" { + // Caller is expected to fall back to ActivityDBQueryFromSpecialField after this. + return nil + } + criterion := andField(query, fieldName) + + switch q.Operator { + case LesserOperator: + query = criterion.Lt(q.Value) + case EqualOperator: + query = criterion.Eq(q.Value) + case GreaterOperator: + query = criterion.Gt(q.Value) + } + return query +} + +// ActivityMatchesSearchAsDBBaseQuery returns a base query (which you should then apply other mutations to) matching the search "term" to Activities by searching all fields. Does not search the generated title like the web app. +func ActivityMatchesSearchAsDBBaseQuery(terms []string) *badgerhold.Query { + var baseQuery *badgerhold.Query = nil + // I don't believe you can just do Where("*"), so instead run for each field. + // FIXME: Match username and source_username and source_type and type + for _, fieldName := range []string{"ID", "UserID", "Source", "InviteCode", "Value", "IP"} { + criterion := badgerhold.Where(fieldName) + // No case-insentive Contains method, so we use MatchFunc instead + f := criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) { + field := ra.Field() + // _, ok := field.(string) + // if !ok { + // return false, fmt.Errorf("field not string: %s", fieldName) + // } + lower := strings.ToLower(field.(string)) + for _, term := range terms { + if strings.Contains(lower, term) { + return true, nil + } + } + return false, nil + }) + if baseQuery == nil { + baseQuery = f + } else { + baseQuery = baseQuery.Or(f) + } + } + + return baseQuery +} + +func (act Activity) SourceIsUser() bool { + return (act.SourceType == ActivityUser || act.SourceType == ActivityAdmin) && act.Source != "" +} + +func (act Activity) MustGetUsername(jf *mediabrowser.MediaBrowser) string { + if act.Type == ActivityDeletion || act.Type == ActivityCreation { + return act.Value + } + if act.UserID == "" { + return "" + } + // Don't care abt errors, user.Name will be blank in that case anyway + user, _ := jf.UserByID(act.UserID, false) + return user.Name +} + +func (act Activity) MustGetSourceUsername(jf *mediabrowser.MediaBrowser) string { + if !act.SourceIsUser() { + return "" + } + // Don't care abt errors, user.Name will be blank in that case anyway + user, _ := jf.UserByID(act.Source, false) + return user.Name +} + +func ActivityDBQueryFromSpecialField(jf *mediabrowser.MediaBrowser, query *badgerhold.Query, q QueryDTO) *badgerhold.Query { + switch q.Field { + case "mentionedUsers": + query = matchMentionedUsersAsQuery(jf, query, q) + case "actor": + query = matchActorAsQuery(jf, query, q) + case "referrer": + query = matchReferrerAsQuery(jf, query, q) + case "time": + query = matchTimeAsQuery(query, q) + default: + panic(fmt.Errorf("unknown activity query field %s", q.Field)) + } + return query +} + +// matchMentionedUsersAsQuery is a custom match function for the "mentionedUsers" getter/query type. +func matchMentionedUsersAsQuery(jf *mediabrowser.MediaBrowser, query *badgerhold.Query, q QueryDTO) *badgerhold.Query { + criterion := andField(query, "UserID") + query = criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) { + act := ra.Record().(*Activity) + usernames := act.MustGetUsername(jf) + " " + act.MustGetSourceUsername(jf) + return strings.Contains(strings.ToLower(usernames), strings.ToLower(q.Value.(string))), nil + }) + return query +} + +// matchActorAsQuery is a custom match function for the "actor" getter/query type. +func matchActorAsQuery(jf *mediabrowser.MediaBrowser, query *badgerhold.Query, q QueryDTO) *badgerhold.Query { + criterion := andField(query, "SourceType") + query = criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) { + act := ra.Record().(*Activity) + matchString := activitySourceToString(act.SourceType) + if act.SourceType == ActivityAdmin || act.SourceType == ActivityUser && act.SourceIsUser() { + matchString += " " + act.MustGetSourceUsername(jf) + } + return strings.Contains(strings.ToLower(matchString), strings.ToLower(q.Value.(string))), nil + }) + return query +} + +// matchReferrerAsQuery is a custom match function for the "referrer" getter/query type. +func matchReferrerAsQuery(jf *mediabrowser.MediaBrowser, query *badgerhold.Query, q QueryDTO) *badgerhold.Query { + criterion := andField(query, "Type") + query = criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) { + act := ra.Record().(*Activity) + if act.Type != ActivityCreation || act.SourceType != ActivityUser || !act.SourceIsUser() { + return false, nil + } + sourceUsername := act.MustGetSourceUsername(jf) + if q.Class == BoolQuery { + val := sourceUsername != "" + if q.Value.(bool) == false { + val = !val + } + return val, nil + } + return strings.Contains(strings.ToLower(sourceUsername), strings.ToLower(q.Value.(string))), nil + }) + return query +} + +// mathcTimeAsQuery is a custom match function for the "time" getter/query type. Roughly matches the same way as the web app, and in usercache.go. +func matchTimeAsQuery(query *badgerhold.Query, q QueryDTO) *badgerhold.Query { + operator := Equal + switch q.Operator { + case LesserOperator: + operator = Lesser + case EqualOperator: + operator = Equal + case GreaterOperator: + operator = Greater + } + criterion := andField(query, "Time") + query = criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) { + return q.Value.(DateAttempt).CompareWithOperator(ra.Field().(time.Time), operator), nil + }) + return query +} diff --git a/api-activities.go b/api-activities.go index f4b2581..3f2e6c6 100644 --- a/api-activities.go +++ b/api-activities.go @@ -6,32 +6,6 @@ import ( "github.com/timshannon/badgerhold/v4" ) -func stringToActivityType(v string) ActivityType { - switch v { - case "creation": - return ActivityCreation - case "deletion": - return ActivityDeletion - case "disabled": - return ActivityDisabled - case "enabled": - return ActivityEnabled - case "contactLinked": - return ActivityContactLinked - case "contactUnlinked": - return ActivityContactUnlinked - case "changePassword": - return ActivityChangePassword - case "resetPassword": - return ActivityResetPassword - case "createInvite": - return ActivityCreateInvite - case "deleteInvite": - return ActivityDeleteInvite - } - return ActivityUnknown -} - func activityTypeToString(v ActivityType) string { switch v { case ActivityCreation: @@ -58,6 +32,32 @@ func activityTypeToString(v ActivityType) string { return "unknown" } +func stringToActivityType(v string) ActivityType { + switch v { + case "creation": + return ActivityCreation + case "deletion": + return ActivityDeletion + case "disabled": + return ActivityDisabled + case "enabled": + return ActivityEnabled + case "contactLinked": + return ActivityContactLinked + case "contactUnlinked": + return ActivityContactUnlinked + case "changePassword": + return ActivityChangePassword + case "resetPassword": + return ActivityResetPassword + case "createInvite": + return ActivityCreateInvite + case "deleteInvite": + return ActivityDeleteInvite + } + return ActivityUnknown +} + func stringToActivitySource(v string) ActivitySource { switch v { case "user": @@ -88,71 +88,73 @@ func activitySourceToString(v ActivitySource) string { // @Summary Get the requested set of activities, Paginated, filtered and sorted. Is a POST because of some issues I was having, ideally should be a GET. // @Produce json -// @Param GetActivitiesDTO body GetActivitiesDTO true "search parameters" +// @Param ServerSearchReqDTO body ServerSearchReqDTO true "search parameters" // @Success 200 {object} GetActivitiesRespDTO // @Router /activity [post] // @Security Bearer // @tags Activity func (app *appContext) GetActivities(gc *gin.Context) { - req := GetActivitiesDTO{} + req := ServerSearchReqDTO{} gc.BindJSON(&req) - query := &badgerhold.Query{} - activityTypes := make([]interface{}, len(req.Type)) - for i, v := range req.Type { - activityTypes[i] = stringToActivityType(v) - } - if len(activityTypes) != 0 { - query = badgerhold.Where("Type").In(activityTypes...) + if req.SortByField == "" { + req.SortByField = USER_DEFAULT_SORT_FIELD + } else { + req.SortByField = activityDTONameToField(req.SortByField) } + var query *badgerhold.Query + if len(req.SearchTerms) != 0 { + query = ActivityMatchesSearchAsDBBaseQuery(req.SearchTerms) + } else { + query = nil + } + + for _, q := range req.Queries { + nq := q.AsDBQuery(query) + if nq == nil { + nq = ActivityDBQueryFromSpecialField(app.jf, query, q) + } + query = nq + } + + if query == nil { + query = &badgerhold.Query{} + } + + query = query.SortBy(req.SortByField) if !req.Ascending { query = query.Reverse() } - query = query.SortBy("Time") - - if req.Limit == 0 { - req.Limit = 10 - } - query = query.Skip(req.Page * req.Limit).Limit(req.Limit) var results []Activity err := app.storage.db.Find(&results, query) - if err != nil { app.err.Printf(lm.FailedDBReadActivities, err) } resp := GetActivitiesRespDTO{ Activities: make([]ActivityDTO, len(results)), - LastPage: len(results) != req.Limit, } - + resp.LastPage = len(results) != req.Limit for i, act := range results { resp.Activities[i] = ActivityDTO{ - ID: act.ID, - Type: activityTypeToString(act.Type), - UserID: act.UserID, - SourceType: activitySourceToString(act.SourceType), - Source: act.Source, - InviteCode: act.InviteCode, - Value: act.Value, - Time: act.Time.Unix(), - IP: act.IP, + ID: act.ID, + Type: activityTypeToString(act.Type), + UserID: act.UserID, + SourceType: activitySourceToString(act.SourceType), + Source: act.Source, + InviteCode: act.InviteCode, + Value: act.Value, + Time: act.Time.Unix(), + IP: act.IP, + Username: act.MustGetUsername(app.jf), + SourceUsername: act.MustGetSourceUsername(app.jf), } if act.Type == ActivityDeletion || act.Type == ActivityCreation { - resp.Activities[i].Username = act.Value + // Username would've been in here, clear it to avoid confusion to the consumer resp.Activities[i].Value = "" - } else if user, err := app.jf.UserByID(act.UserID, false); err == nil { - resp.Activities[i].Username = user.Name - } - - if (act.SourceType == ActivityUser || act.SourceType == ActivityAdmin) && act.Source != "" { - user, err := app.jf.UserByID(act.Source, false) - if err == nil { - resp.Activities[i].SourceUsername = user.Name - } } } @@ -173,12 +175,12 @@ func (app *appContext) DeleteActivity(gc *gin.Context) { // @Summary Returns the total number of activities stored in the database. // @Produce json -// @Success 200 {object} GetActivityCountDTO +// @Success 200 {object} PageCountDTO // @Router /activity/count [get] // @Security Bearer // @tags Activity func (app *appContext) GetActivityCount(gc *gin.Context) { - resp := GetActivityCountDTO{} + resp := PageCountDTO{} var err error resp.Count, err = app.storage.db.Count(&Activity{}, &badgerhold.Query{}) if err != nil { diff --git a/api-users.go b/api-users.go index 849bdfe..c061d01 100644 --- a/api-users.go +++ b/api-users.go @@ -4,6 +4,7 @@ import ( "fmt" "net/url" "os" + "slices" "strings" "time" @@ -337,8 +338,8 @@ func (app *appContext) PostNewUserFromInvite(nu NewUserData, req ConfirmationKey // FIXME: figure these out in a nicer way? this relies on the current ordering, // which may not be fixed. if discordEnabled { - if req.completeContactMethods[0].User != nil { - discordUser = req.completeContactMethods[0].User.(*DiscordUser) + if req.completeContactMethods[0].User != nil { + discordUser = req.completeContactMethods[0].User.(*DiscordUser) } if telegramEnabled && req.completeContactMethods[1].User != nil { telegramUser = req.completeContactMethods[1].User.(*TelegramUser) @@ -894,7 +895,25 @@ func (app *appContext) userSummary(jfUser mediabrowser.User) respUser { } -// @Summary Get a list of Jellyfin users. +// @Summary Returns the total number of Jellyfin users. +// @Produce json +// @Success 200 {object} PageCountDTO +// @Router /users/count [get] +// @Security Bearer +// @tags Activity +func (app *appContext) GetUserCount(gc *gin.Context) { + resp := PageCountDTO{} + userList, err := app.userCache.GetUserDTOs(app, false) + if err != nil { + app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err) + respond(500, "Couldn't get users", gc) + return + } + resp.Count = uint64(len(userList)) + gc.JSON(200, resp) +} + +// @Summary Get a list of -all- Jellyfin users. // @Produce json // @Success 200 {object} getUsersDTO // @Failure 500 {object} stringResponse @@ -903,19 +922,61 @@ func (app *appContext) userSummary(jfUser mediabrowser.User) respUser { // @tags Users func (app *appContext) GetUsers(gc *gin.Context) { var resp getUsersDTO - users, err := app.jf.GetUsers(false) - resp.UserList = make([]respUser, len(users)) + // We're sending all users, so this is always true + resp.LastPage = true + var err error + resp.UserList, err = app.userCache.GetUserDTOs(app, true) if err != nil { app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err) respond(500, "Couldn't get users", gc) return } - i := 0 - for _, jfUser := range users { - user := app.userSummary(jfUser) - resp.UserList[i] = user - i++ + gc.JSON(200, resp) +} + +// @Summary Get a paginated, searchable list of Jellyfin users. +// @Produce json +// @Param ServerSearchReqDTO body ServerSearchReqDTO true "search / pagination parameters" +// @Success 200 {object} getUsersDTO +// @Failure 500 {object} stringResponse +// @Router /users [post] +// @Security Bearer +// @tags Users +func (app *appContext) SearchUsers(gc *gin.Context) { + req := ServerSearchReqDTO{} + gc.BindJSON(&req) + if req.SortByField == "" { + req.SortByField = USER_DEFAULT_SORT_FIELD } + + var resp getUsersDTO + userList, err := app.userCache.GetUserDTOs(app, req.SortByField == USER_DEFAULT_SORT_FIELD) + if err != nil { + app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err) + respond(500, "Couldn't get users", gc) + return + } + var filtered []*respUser + if len(req.SearchTerms) != 0 || len(req.Queries) != 0 { + filtered = app.userCache.Filter(userList, req.SearchTerms, req.Queries) + } else { + filtered = slices.Clone(userList) + } + + if req.SortByField == USER_DEFAULT_SORT_FIELD { + if req.Ascending != USER_DEFAULT_SORT_ASCENDING { + slices.Reverse(filtered) + } + } else { + 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) } diff --git a/config.go b/config.go index 53f846f..9ad5b59 100644 --- a/config.go +++ b/config.go @@ -188,6 +188,10 @@ func (app *appContext) loadConfig() error { app.config.Section("jellyfin").Key("device").SetValue("jfa-go") app.config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit)) + app.MustSetValue("jellyfin", "cache_timeout", "30") + app.MustSetValue("jellyfin", "web_cache_async_timeout", "1") + app.MustSetValue("jellyfin", "web_cache_sync_timeout", "10") + LOGIP = app.config.Section("advanced").Key("log_ips").MustBool(false) LOGIPU = app.config.Section("advanced").Key("log_ips_users").MustBool(false) diff --git a/config/config-base.yaml b/config/config-base.yaml index 00a4319..d7f7f1d 100644 --- a/config/config-base.yaml +++ b/config/config-base.yaml @@ -65,6 +65,20 @@ sections: type: number value: 30 description: Timeout of user cache in minutes. Set to 0 to disable. + - setting: web_cache_async_timeout + name: User search cache asynchronous timeout (minutes) + requires_restart: true + advanced: true + type: number + value: 1 + description: "Synchronise after cache is this old, but don't wait for it: The accounts tab will load quickly but show old results until the next request." + - setting: web_cache_sync_timeout + name: User search cache synchronous timeout (minutes) + requires_restart: true + advanced: true + type: number + value: 10 + description: "Synchronise after cache is this old, and wait for it: The accounts tab may take a little longer to load while it does." - setting: type name: Server type requires_restart: true diff --git a/css/base.css b/css/base.css index d9b53e6..2c084c2 100644 --- a/css/base.css +++ b/css/base.css @@ -470,3 +470,19 @@ section.section:not(.\~neutral) { } } +:root { + /* seems to be the sweet spot */ + --inside-input-base: -2.6rem; + + /* thought --spacing would do the trick but apparently not */ + --tailwind-spacing: 0.25rem; +} + +/* places buttons inside a sibling input element (hopefully), based on the flex gap of the parent. */ +.gap-1 > .button.inside-input { + margin-left: calc(var(--inside-input-base) - 1.0*var(--tailwind-spacing)); +} + +.gap-2 > .button.inside-input { + margin-left: calc(var(--inside-input-base) - 2.0*var(--tailwind-spacing)); +} diff --git a/css/tooltip.css b/css/tooltip.css index 85a289e..fc92a1d 100644 --- a/css/tooltip.css +++ b/css/tooltip.css @@ -27,6 +27,12 @@ right: 0; } +.tooltip.above .content { + bottom: 2.5rem; + left: 0; + right: 0; +} + .tooltip.darker .content { background-color: rgba(0, 0, 0, 0.8); } diff --git a/go.mod b/go.mod index 6b17a34..b4edb41 100644 --- a/go.mod +++ b/go.mod @@ -43,7 +43,6 @@ require ( github.com/hrfee/jfa-go/logger v0.0.0-20241105225412-da4470bc4fbc github.com/hrfee/jfa-go/logmessages v0.0.0-20241105225412-da4470bc4fbc github.com/hrfee/jfa-go/ombi v0.0.0-20241105225412-da4470bc4fbc - github.com/hrfee/mediabrowser v0.3.25 github.com/itchyny/timefmt-go v0.1.6 github.com/lithammer/shortuuid/v3 v3.0.7 github.com/mailgun/mailgun-go/v4 v4.18.1 @@ -98,6 +97,7 @@ require ( github.com/google/flatbuffers v24.3.25+incompatible // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect + github.com/hrfee/mediabrowser v0.3.27 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.17.11 // indirect diff --git a/go.sum b/go.sum index c9c85b6..bfeb19e 100644 --- a/go.sum +++ b/go.sum @@ -205,8 +205,8 @@ github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hrfee/mediabrowser v0.3.25 h1:UxpSSTmr5q12gKfeOR2ommvA/xhrP2rxVWmpWjDPRUY= -github.com/hrfee/mediabrowser v0.3.25/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U= +github.com/hrfee/mediabrowser v0.3.27 h1:8bxPamBFLD1Xqy6pf6M3Oc5GUQ0iU/flO0S64G1AsIM= +github.com/hrfee/mediabrowser v0.3.27/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q= github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg= diff --git a/html/admin.html b/html/admin.html index 25630bd..b2dda34 100644 --- a/html/admin.html +++ b/html/admin.html @@ -715,16 +715,24 @@
-
+
- -
- - +
+ +
-
{{ .strings.actions }}
-
+
+
+ +
+
{{ .strings.actions }}
+
{{ .quantityStrings.addUser.Singular }} -
+
@@ -802,19 +813,31 @@
+
-
- {{ .strings.noResultsFound }} - +
+ {{ .strings.noResultsFound }} + {{ .strings.noResultsFoundLocally }} +
+ +
+
+ + + +
-
+
-
+
-
- - - -
+
-
- - +
+
-
-
-
-
-
- {{ .strings.noResultsFound }} - {{ .strings.keepSearchingDescription }} -
- - -
+
+
+ {{ .strings.noResultsFound }} + {{ .strings.noResultsFoundLocally }} +
+ +
-
- - -
+
+
+
+
+ + +
diff --git a/lang/admin/en-us.json b/lang/admin/en-us.json index eed6603..6638176 100644 --- a/lang/admin/en-us.json +++ b/lang/admin/en-us.json @@ -58,6 +58,7 @@ "disabled": "Disabled", "sendPWR": "Send Password Reset", "noResultsFound": "No Results Found", + "noResultsFoundLocally": "Only loaded records were searched. You can load more, or perform the search over all records on the server.", "keepSearching": "Keep Searching", "keepSearchingDescription": "Only the current loaded activities were searched. Click below if you wish to search all activities.", "contactThrough": "Contact through:", @@ -136,6 +137,8 @@ "filters": "Filters", "clickToRemoveFilter": "Click to remove this filter.", "clearSearch": "Clear search", + "searchAll": "Search/sort all", + "searchAllRecords": "Search/sort all records (on server)", "actions": "Actions", "searchOptions": "Search Options", "matchText": "Match Text", @@ -190,6 +193,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.", diff --git a/log.go b/log.go index 636f342..f1b79e2 100644 --- a/log.go +++ b/log.go @@ -59,7 +59,7 @@ func logOutput() (closeFunc func(), err error) { // Regex that removes ANSI color escape sequences. Used for outputting to log file and log cache. var stripColors = func() *regexp.Regexp { - r, err := regexp.Compile("\\x1b\\[[0-9;]*m") + r, err := regexp.Compile(`\x1b\[[0-9;]*m`) if err != nil { log.Fatalf("Failed to compile color escape regexp: %v", err) } diff --git a/logmessages/logmessages.go b/logmessages/logmessages.go index 9e8249b..160ce3b 100644 --- a/logmessages/logmessages.go +++ b/logmessages/logmessages.go @@ -118,8 +118,7 @@ const ( SetAdminNotify = "Set \"%s\" to %t for admin address \"%s\"" // *jellyseerr*.go - FailedGetUsers = "Failed to get user(s) from %s: %v" - // FIXME: Once done, look back at uses of FailedGetUsers for places where this would make more sense. + FailedGetUsers = "Failed to get user(s) from %s: %v" FailedGetUser = "Failed to get user \"%s\" from %s: %v" FailedGetJellyseerrNotificationPrefs = "Failed to get user \"%s\"'s notification prefs from " + Jellyseerr + ": %v" FailedSyncContactMethods = "Failed to sync contact methods with %s: %v" diff --git a/main.go b/main.go index 7d1de5a..2ec8f7c 100644 --- a/main.go +++ b/main.go @@ -134,6 +134,7 @@ type appContext struct { pwrCaptchas map[string]Captcha ConfirmationKeys map[string]map[string]ConfirmationKey // Map of invite code to jwt to request confirmationKeysLock sync.Mutex + userCache *UserCache } func generateSecret(length int) (string, error) { @@ -405,7 +406,7 @@ func start(asDaemon, firstCall bool) { // Initialize jellyfin/emby connection server := app.config.Section("jellyfin").Key("server").String() - cacheTimeout := int(app.config.Section("jellyfin").Key("cache_timeout").MustUint(30)) + cacheTimeout := app.config.Section("jellyfin").Key("cache_timeout").MustInt() stringServerType := app.config.Section("jellyfin").Key("type").String() timeoutHandler := mediabrowser.NewNamedTimeoutHandler("Jellyfin", "\""+server+"\"", true) if stringServerType == "emby" { @@ -468,6 +469,11 @@ func start(asDaemon, firstCall bool) { } } + app.userCache = NewUserCache( + time.Minute*time.Duration(app.config.Section("jellyfin").Key("web_cache_async_timeout").MustInt()), + time.Minute*time.Duration(app.config.Section("jellyfin").Key("web_cache_sync_timeout").MustInt()), + ) + // Since email depends on language, the email reload in loadConfig won't work first time. // Email also handles its own proxying, as (SMTP atleast) doesn't use a HTTP transport. app.email = NewEmailer(app) @@ -527,7 +533,7 @@ func start(asDaemon, firstCall bool) { // NOTE: The order in which these are placed in app.contactMethods matters. // Add new ones to the end. - // FIXME: Add proxies. + // Proxies are added a little later through ContactMethodLinker[].SetTransport. if discordEnabled { app.discord, err = newDiscordDaemon(app) if err != nil { diff --git a/models.go b/models.go index 686effd..144bfcf 100644 --- a/models.go +++ b/models.go @@ -164,8 +164,20 @@ type respUser struct { ReferralsEnabled bool `json:"referrals_enabled"` } +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 getUsersDTO struct { - UserList []respUser `json:"users"` + UserList []*respUser `json:"users"` + LastPage bool `json:"last_page"` } type ombiUser struct { @@ -429,19 +441,12 @@ type ActivityDTO struct { IP string `json:"ip"` } -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"` -} - type GetActivitiesRespDTO struct { + PaginatedDTO Activities []ActivityDTO `json:"activities"` - LastPage bool `json:"last_page"` } -type GetActivityCountDTO struct { +type PageCountDTO struct { Count uint64 `json:"count"` } diff --git a/package-lock.json b/package-lock.json index cd02357..23aaab8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@af-utils/scrollend-polyfill": "^0.0.14", "@ts-stack/markdown": "^1.4.0", "@types/node": "^20.3.0", "a17t": "^0.10.1", @@ -36,6 +37,12 @@ "esbuild": "^0.18.20" } }, + "node_modules/@af-utils/scrollend-polyfill": { + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@af-utils/scrollend-polyfill/-/scrollend-polyfill-0.0.14.tgz", + "integrity": "sha512-pThXK3XqbWeJHJJAEzhNqCEgOiZ7Flk/Wj/uM6+TGJuA/3n/NeKP3C+5o4jt79i46Cc18iA0kJaMd056GQTfYQ==", + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -7293,6 +7300,11 @@ } }, "dependencies": { + "@af-utils/scrollend-polyfill": { + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@af-utils/scrollend-polyfill/-/scrollend-polyfill-0.0.14.tgz", + "integrity": "sha512-pThXK3XqbWeJHJJAEzhNqCEgOiZ7Flk/Wj/uM6+TGJuA/3n/NeKP3C+5o4jt79i46Cc18iA0kJaMd056GQTfYQ==" + }, "@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", diff --git a/package.json b/package.json index 441a052..64a083a 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ }, "homepage": "https://github.com/hrfee/jfa-go#readme", "dependencies": { + "@af-utils/scrollend-polyfill": "^0.0.14", "@ts-stack/markdown": "^1.4.0", "@types/node": "^20.3.0", "a17t": "^0.10.1", diff --git a/router.go b/router.go index 9ab3530..4fa3952 100644 --- a/router.go +++ b/router.go @@ -183,6 +183,8 @@ func (app *appContext) loadRoutes(router *gin.Engine) { router.POST(p+"/logout", app.Logout) api.DELETE(p+"/users", app.DeleteUsers) api.GET(p+"/users", app.GetUsers) + api.GET(p+"/users/count", app.GetUserCount) + api.POST(p+"/users", app.SearchUsers) api.POST(p+"/user", app.NewUserFromAdmin) api.POST(p+"/users/extend", app.ExtendExpiry) api.DELETE(p+"/users/:id/expiry", app.RemoveExpiry) diff --git a/scripts/account-gen/main.go b/scripts/account-gen/main.go index bc1c528..c61928f 100644 --- a/scripts/account-gen/main.go +++ b/scripts/account-gen/main.go @@ -14,12 +14,13 @@ import ( ) var ( - names = []string{"Aaron", "Agnes", "Bridget", "Brandon", "Dolly", "Drake", "Elizabeth", "Erika", "Geoff", "Graham", "Haley", "Halsey", "Josie", "John", "Kayleigh", "Luka", "Melissa", "Nasreen", "Paul", "Ross", "Sam", "Talib", "Veronika", "Zaynab"} + names = []string{"Aaron", "Agnes", "Bridget", "Brandon", "Dolly", "Drake", "Elizabeth", "Erika", "Geoff", "Graham", "Haley", "Halsey", "Josie", "John", "Kayleigh", "Luka", "Melissa", "Nasreen", "Paul", "Ross", "Sam", "Talib", "Veronika", "Zaynab", "Graig", "Rhoda", "Tyler", "Quentin", "Melinda", "Zelma", "Jack", "Clifton", "Sherry", "Boyce", "Elma", "Jere", "Shelby", "Caitlin", "Bertie", "Mallory", "Thelma", "Charley", "Santo", "Merrill", "Royal", "Jefferson", "Ester", "Dee", "Susanna", "Adriana", "Alfonso", "Lillie", "Carmen", "Federico", "Ernie", "Kory", "Kimberly", "Donn", "Lilian", "Irvin", "Sherri", "Cordell", "Adrienne", "Edwin", "Serena", "Otis", "Latasha", "Johanna", "Clarence", "Noe", "Mindy", "Felix", "Audra"} + COUNT = 4000 + DELAY = 1 * time.Millisecond ) const ( PASSWORD = "test" - COUNT = 10 ) func main() { @@ -57,6 +58,12 @@ func main() { password = strings.TrimSuffix(password, "\n") } + if countEnv := os.Getenv("COUNT"); countEnv != "" { + COUNT, _ = strconv.Atoi(countEnv) + } + + fmt.Printf("Will generate %d users\n", COUNT) + jf, err := mediabrowser.NewServer( mediabrowser.JellyfinServer, server, @@ -95,11 +102,12 @@ func main() { rand.Seed(time.Now().Unix()) for i := 0; i < COUNT; i++ { - name := names[rand.Intn(len(names))] + strconv.Itoa(rand.Intn(100)) + name := names[rand.Intn(len(names))] + strconv.Itoa(rand.Intn(500)) user, status, err := jf.NewUser(name, PASSWORD) if (status != 200 && status != 201 && status != 204) || err != nil { - log.Fatalf("Failed to create user \"%s\" (%d): %+v\n", name, status, err) + log.Printf("Acc no %d: Failed to create user \"%s\" (%d): %+v\n", i, name, status, err) + continue } if rand.Intn(100) > 65 { @@ -110,13 +118,17 @@ func main() { user.Policy.IsDisabled = true } + time.Sleep(DELAY / 4) status, err = jf.SetPolicy(user.ID, user.Policy) if (status != 200 && status != 201 && status != 204) || err != nil { - log.Fatalf("Failed to set policy for user \"%s\" (%d): %+v\n", name, status, err) + log.Fatalf("Acc no %d: Failed to set policy for user \"%s\" (%d): %+v\n", i, name, status, err) } if rand.Intn(100) > 20 { + time.Sleep(DELAY / 4) jfTemp.Authenticate(name, PASSWORD) } + log.Printf("Acc %d done\n", i) + time.Sleep(DELAY / 4) } } diff --git a/ts/admin.ts b/ts/admin.ts index 6ee213b..925e58d 100644 --- a/ts/admin.ts +++ b/ts/admin.ts @@ -127,7 +127,7 @@ let isInviteURL = window.invites.isInviteURL(); let isAccountURL = accounts.isAccountURL(); // load tabs -const tabs: { id: string, url: string, reloader: () => void }[] = [ +const tabs: { id: string, url: string, reloader: () => void, unloader?: () => void }[] = [ { id: "invites", url: "", @@ -148,12 +148,19 @@ const tabs: { id: string, url: string, reloader: () => void }[] = [ // Don't keep loading the same item on every tab refresh isAccountURL = false; } + accounts.bindPageEvents(); }), + unloader: accounts.unbindPageEvents + }, { id: "activity", url: "activity", - reloader: activity.reload + reloader: () => { + activity.reload() + activity.bindPageEvents(); + }, + unloader: activity.unbindPageEvents }, { id: "settings", @@ -167,7 +174,7 @@ const defaultTab = tabs[0]; window.tabs = new Tabs(); for (let tab of tabs) { - window.tabs.addTab(tab.id, window.pages.Admin + "/" + tab.url, null, tab.reloader); + window.tabs.addTab(tab.id, window.pages.Admin + "/" + tab.url, null, tab.reloader, tab.unloader || null); } let matchedTab = false @@ -188,7 +195,7 @@ login.onLogin = () => { window.updater = new Updater(); // FIXME: Decide whether to autoload activity or not reloadProfileNames(); - setInterval(() => { window.invites.reload(); accounts.reload(); }, 30*1000); + setInterval(() => { window.invites.reload(); accounts.reloadIfNotInScroll(); }, 30*1000); // Triggers pre and post funcs, even though we're already on that page window.tabs.switch(window.tabs.current); } diff --git a/ts/crash.ts b/ts/crash.ts index 05bae39..1efa825 100644 --- a/ts/crash.ts +++ b/ts/crash.ts @@ -7,7 +7,6 @@ const logNormal = document.getElementById("log-normal") as HTMLInputElement; const logSanitized = document.getElementById("log-sanitized") as HTMLInputElement; const buttonChange = (type: string) => { - console.log("RUN"); if (type == "normal") { logSanitized.classList.add("unfocused"); logNormal.classList.remove("unfocused"); diff --git a/ts/modules/accounts.ts b/ts/modules/accounts.ts index c338bfc..cbf6209 100644 --- a/ts/modules/accounts.ts +++ b/ts/modules/accounts.ts @@ -1,13 +1,17 @@ -import { _get, _post, _delete, toggleLoader, addLoader, removeLoader, toDateString, insertText, toClipboard } from "../modules/common.js"; -import { templateEmail } from "../modules/settings.js"; +import { _get, _post, _delete, toggleLoader, addLoader, removeLoader, toDateString, insertText, toClipboard } from "../modules/common" +import { templateEmail } from "../modules/settings" import { Marked } from "@ts-stack/markdown"; -import { stripMarkdown } from "../modules/stripmd.js"; -import { DiscordUser, newDiscordSearch } from "../modules/discord.js"; -import { Search, SearchConfiguration, QueryType, SearchableItem } from "../modules/search.js"; -import { HiddenInputField } from "./ui.js"; +import { stripMarkdown } from "../modules/stripmd" +import { DiscordUser, newDiscordSearch } from "../modules/discord" +import { SearchConfiguration, QueryType, SearchableItem, SearchableItemDataAttribute } from "../modules/search" +import { HiddenInputField } from "./ui" +import { PaginatedList } from "./list" declare var window: GlobalWindow; +const USER_DEFAULT_SORT_FIELD = "name"; +const USER_DEFAULT_SORT_ASCENDING = true; + const dateParser = require("any-date-parser"); interface User { @@ -43,6 +47,117 @@ interface announcementTemplate { } var addDiscord: (passData: string) => void; + +const queries = (): { [field: string]: QueryType } => { return { + "id": { + // We don't use a translation here to circumvent the name substitution feature. + name: "Jellyfin/Emby ID", + getter: "id", + bool: false, + string: true, + date: false + }, + "label": { + name: window.lang.strings("label"), + getter: "label", + bool: true, + string: true, + date: false + }, + "username": { + name: window.lang.strings("username"), + getter: "name", + bool: false, + string: true, + date: false + }, + "name": { + name: window.lang.strings("username"), + getter: "name", + bool: false, + string: true, + date: false, + show: false + }, + "admin": { + name: window.lang.strings("admin"), + getter: "admin", + bool: true, + string: false, + date: false + }, + "disabled": { + name: window.lang.strings("disabled"), + getter: "disabled", + bool: true, + string: false, + date: false + }, + "access-jfa": { + name: window.lang.strings("accessJFA"), + getter: "accounts_admin", + bool: true, + string: false, + date: false, + dependsOnElement: ".accounts-header-access-jfa" + }, + "email": { + name: window.lang.strings("emailAddress"), + getter: "email", + bool: true, + string: true, + date: false, + dependsOnElement: ".accounts-header-email" + }, + "telegram": { + name: "Telegram", + getter: "telegram", + bool: true, + string: true, + date: false, + dependsOnElement: ".accounts-header-telegram" + }, + "matrix": { + name: "Matrix", + getter: "matrix", + bool: true, + string: true, + date: false, + dependsOnElement: ".accounts-header-matrix" + }, + "discord": { + name: "Discord", + getter: "discord", + bool: true, + string: true, + date: false, + dependsOnElement: ".accounts-header-discord" + }, + "expiry": { + name: window.lang.strings("expiry"), + getter: "expiry", + bool: true, + string: false, + date: true, + dependsOnElement: ".accounts-header-expiry" + }, + "last-active": { + name: window.lang.strings("lastActiveTime"), + getter: "last_active", + bool: true, + string: false, + date: true + }, + "referrals-enabled": { + name: window.lang.strings("referrals"), + getter: "referrals_enabled", + bool: true, + string: false, + date: false, + dependsOnElement: ".accounts-header-referrals" + } +}}; + class user implements User, SearchableItem { private _id = ""; @@ -111,7 +226,7 @@ class user implements User, SearchableItem { get name(): string { return this._username.textContent; } set name(value: string) { this._username.textContent = value; } - get admin(): boolean { return this._admin.classList.contains("chip"); } + get admin(): boolean { return !(this._admin.classList.contains("hidden")); } set admin(state: boolean) { if (state) { this._admin.classList.remove("hidden") @@ -471,7 +586,6 @@ class user implements User, SearchableItem { } matchesSearch = (query: string): boolean => { - console.log(this.name, "matches", query, ":", this.name.includes(query)); return ( this.id.includes(query) || this.name.toLowerCase().includes(query) || @@ -535,7 +649,6 @@ class user implements User, SearchableItem { `; this._row.innerHTML = innerHTML; - const emailEditor = ``; this._check = this._row.querySelector("input[type=checkbox].accounts-select-user") as HTMLInputElement; this._accounts_admin = this._row.querySelector("input[type=checkbox].accounts-access-jfa") as HTMLInputElement; this._username = this._row.querySelector(".accounts-username") as HTMLSpanElement; @@ -667,7 +780,10 @@ class user implements User, SearchableItem { }); get id() { return this._id; } - set id(v: string) { this._id = v; } + set id(v: string) { + this._id = v; + this._row.setAttribute(SearchableItemDataAttribute, v); + } update = (user: User) => { @@ -702,10 +818,14 @@ class user implements User, SearchableItem { } this._row.remove(); } -} +} -export class accountsList { - private _table = document.getElementById("accounts-list") as HTMLTableSectionElement; +interface UsersDTO extends paginatedDTO { + users: User[]; +} + +export class accountsList extends PaginatedList { + protected _container = document.getElementById("accounts-list") as HTMLTableSectionElement; private _addUserButton = document.getElementById("accounts-add-user") as HTMLSpanElement; private _announceButton = document.getElementById("accounts-announce") as HTMLSpanElement; @@ -742,8 +862,6 @@ export class accountsList { private _referralsProfileSelect = document.getElementById("enable-referrals-user-profiles") as HTMLSelectElement; private _referralsInviteSelect = document.getElementById("enable-referrals-user-invites") as HTMLSelectElement; private _referralsExpiry = document.getElementById("enable-referrals-user-expiry") as HTMLInputElement; - private _searchBox = document.getElementById("accounts-search") as HTMLInputElement; - private _search: Search; private _applyHomescreen = document.getElementById("modify-user-homescreen") as HTMLInputElement; private _applyConfiguration = document.getElementById("modify-user-configuration") as HTMLInputElement; @@ -751,9 +869,11 @@ export class accountsList { private _applyJellyseerr = document.getElementById("modify-user-jellyseerr") as HTMLInputElement; private _selectAll = document.getElementById("accounts-select-all") as HTMLInputElement; - private _users: { [id: string]: user }; - private _ordering: string[] = []; - private _checkCount: number = 0; + // private _users: { [id: string]: user }; + // private _ordering: string[] = []; + get users(): { [id: string]: user } { return this._search.items as { [id: string]: user }; } + // set users(v: { [id: string]: user }) { this._search.items = v as SearchableItems; } + // Whether the enable/disable button should enable or not. private _shouldEnable = false; @@ -765,16 +885,13 @@ export class accountsList { // Columns for sorting. private _columns: { [className: string]: Column } = {}; - private _activeSortColumn: string; - - private _sortingByButton = document.getElementById("accounts-sort-by-field") as HTMLButtonElement; - private _filterArea = document.getElementById("accounts-filter-area"); - private _searchOptionsHeader = document.getElementById("accounts-search-options-header"); // Whether the "Extend expiry" is extending or setting an expiry. private _settingExpiry = false; - private _count = 30; + private _sortingByButton = document.getElementById("accounts-sort-by-field") as HTMLButtonElement; + + private _maxDayHourMinuteOptions = 30; private _populateNumbers = () => { const fieldIDs = ["months", "days", "hours", "minutes"]; const prefixes = ["extend-expiry-"]; @@ -782,7 +899,7 @@ export class accountsList { for (let j = 0; j < prefixes.length; j++) { const field = document.getElementById(prefixes[j] + fieldIDs[i]); field.textContent = ''; - for (let n = 0; n <= this._count; n++) { + for (let n = 0; n <= this._maxDayHourMinuteOptions; n++) { const opt = document.createElement("option") as HTMLOptionElement; opt.textContent = ""+n; opt.value = ""+n; @@ -791,164 +908,372 @@ export class accountsList { } } } + + constructor() { + super({ + loader: document.getElementById("accounts-loader"), + loadMoreButton: document.getElementById("accounts-load-more") as HTMLButtonElement, + loadAllButton: document.getElementById("accounts-load-all") as HTMLButtonElement, + refreshButton: document.getElementById("accounts-refresh") as HTMLButtonElement, + filterArea: document.getElementById("accounts-filter-area"), + searchOptionsHeader: document.getElementById("accounts-search-options-header"), + searchBox: document.getElementById("accounts-search") as HTMLInputElement, + recordCounter: document.getElementById("accounts-record-counter"), + totalEndpoint: "/users/count", + getPageEndpoint: "/users", + itemsPerPage: 40, + maxItemsLoadedForSearch: 200, + appendNewItems: (resp: paginatedDTO) => { + for (let u of ((resp as UsersDTO).users || [])) { + if (u.id in this.users) { + this.users[u.id].update(u); + } else { + this.add(u); + } + } - showHideSearchOptionsHeader = () => { - const sortingBy = !(this._sortingByButton.parentElement.classList.contains("hidden")); - const hasFilters = this._filterArea.textContent != ""; - console.log("sortingBy", sortingBy, "hasFilters", hasFilters); - if (sortingBy || hasFilters) { - this._searchOptionsHeader.classList.remove("hidden"); + this._search.setOrdering( + this._columns[this._search.sortField].sort(this.users), + this._search.sortField, + this._search.ascending + ); + }, + replaceWithNewItems: (resp: paginatedDTO) => { + let accountsOnDOM: { [id: string]: boolean } = {}; + + for (let id of Object.keys(this.users)) { accountsOnDOM[id] = true; } + for (let u of ((resp as UsersDTO).users || [])) { + if (u.id in accountsOnDOM) { + this.users[u.id].update(u); + delete accountsOnDOM[u.id]; + } else { + this.add(u); + } + } + + // Delete accounts w/ remaining IDs (those not in resp.users) + // console.log("Removing", Object.keys(accountsOnDOM).length, "from DOM"); + for (let id in accountsOnDOM) { + this.users[id].remove() + delete this.users[id]; + } + + this._search.setOrdering( + this._columns[this._search.sortField].sort(this.users), + this._search.sortField, + this._search.ascending + ); + }, + defaultSortField: USER_DEFAULT_SORT_FIELD, + defaultSortAscending: USER_DEFAULT_SORT_ASCENDING, + pageLoadCallback: (req: XMLHttpRequest) => { + if (req.readyState != 4) return; + // FIXME: Error message + if (req.status != 200) return; + } + }); + this._populateNumbers(); + + let searchConfig: SearchConfiguration = { + filterArea: this._c.filterArea, + sortingByButton: this._sortingByButton, + searchOptionsHeader: this._c.searchOptionsHeader, + notFoundPanel: document.getElementById("accounts-not-found"), + notFoundLocallyText: document.getElementById("accounts-no-local-results"), + filterList: document.getElementById("accounts-filter-list"), + search: this._c.searchBox, + queries: queries(), + setVisibility: null, + clearSearchButtonSelector: ".accounts-search-clear", + serverSearchButtonSelector: ".accounts-search-server", + onSearchCallback: (_0: boolean, _1: boolean) => { + this._checkCheckCount(); + }, + searchServer: null, + clearServerSearch: null, + }; + + this.initSearch(searchConfig); + + this._selectAll.checked = false; + this._selectAll.onchange = () => { + this.selectAll = this._selectAll.checked; + }; + document.addEventListener("accounts-reload", () => this.reload()); + document.addEventListener("accountCheckEvent", () => { this._counter.selected++; this._checkCheckCount(); }); + document.addEventListener("accountUncheckEvent", () => { this._counter.selected--; this._checkCheckCount(); }); + this._addUserButton.onclick = () => { + this._populateAddUserProfiles(); + window.modals.addUser.toggle(); + }; + this._addUserForm.addEventListener("submit", this._addUser); + + this._deleteNotify.onchange = () => { + if (this._deleteNotify.checked) { + this._deleteReason.classList.remove("unfocused"); + } else { + this._deleteReason.classList.add("unfocused"); + } + }; + this._modifySettings.onclick = this.modifyUsers; + this._modifySettings.classList.add("unfocused"); + + if (window.ombiEnabled) + this._applyOmbi.parentElement.classList.remove("unfocused"); + else + this._applyOmbi.parentElement.classList.add("unfocused"); + if (window.jellyseerrEnabled) + this._applyJellyseerr.parentElement.classList.remove("unfocused"); + else + this._applyJellyseerr.parentElement.classList.add("unfocused"); + + const checkSource = () => { + const profileSpan = this._modifySettingsProfile.nextElementSibling as HTMLSpanElement; + const userSpan = this._modifySettingsUser.nextElementSibling as HTMLSpanElement; + if (this._modifySettingsProfile.checked) { + this._userSelect.parentElement.classList.add("unfocused"); + this._profileSelect.parentElement.classList.remove("unfocused") + profileSpan.classList.add("@high"); + profileSpan.classList.remove("@low"); + userSpan.classList.remove("@high"); + userSpan.classList.add("@low"); + this._applyOmbi.parentElement.classList.remove("unfocused"); + this._applyJellyseerr.parentElement.classList.remove("unfocused"); + } else { + this._userSelect.parentElement.classList.remove("unfocused"); + this._profileSelect.parentElement.classList.add("unfocused"); + userSpan.classList.add("@high"); + userSpan.classList.remove("@low"); + profileSpan.classList.remove("@high"); + profileSpan.classList.add("@low"); + this._applyOmbi.parentElement.classList.add("unfocused"); + this._applyJellyseerr.parentElement.classList.add("unfocused"); + } + }; + this._modifySettingsProfile.onchange = checkSource; + this._modifySettingsUser.onchange = checkSource; + + if (window.referralsEnabled) { + const profileSpan = this._enableReferralsProfile.nextElementSibling as HTMLSpanElement; + const inviteSpan = this._enableReferralsInvite.nextElementSibling as HTMLSpanElement; + const checkReferralSource = () => { + console.debug("States:", this._enableReferralsProfile.checked, this._enableReferralsInvite.checked); + if (this._enableReferralsProfile.checked) { + this._referralsInviteSelect.parentElement.classList.add("unfocused"); + this._referralsProfileSelect.parentElement.classList.remove("unfocused") + profileSpan.classList.add("@high"); + profileSpan.classList.remove("@low"); + inviteSpan.classList.remove("@high"); + inviteSpan.classList.add("@low"); + } else { + this._referralsInviteSelect.parentElement.classList.remove("unfocused"); + this._referralsProfileSelect.parentElement.classList.add("unfocused"); + inviteSpan.classList.add("@high"); + inviteSpan.classList.remove("@low"); + profileSpan.classList.remove("@high"); + profileSpan.classList.add("@low"); + } + }; + profileSpan.onclick = () => { + this._enableReferralsProfile.checked = true; + this._enableReferralsInvite.checked = false; + checkReferralSource(); + }; + inviteSpan.onclick = () => {; + this._enableReferralsInvite.checked = true; + this._enableReferralsProfile.checked = false; + checkReferralSource(); + }; + this._enableReferrals.onclick = () => { + this.enableReferrals(); + profileSpan.onclick(null); + }; + } + + this._deleteUser.onclick = this.deleteUsers; + this._deleteUser.classList.add("unfocused"); + + this._announceButton.onclick = this.announce; + this._announceButton.parentElement.classList.add("unfocused"); + + this._extendExpiry.onclick = () => { this.extendExpiry(); }; + this._removeExpiry.onclick = () => { this.removeExpiry(); }; + this._expiryDropdown.classList.add("unfocused"); + this._extendExpiryDate.classList.add("unfocused"); + + this._extendExpiryTextInput.onkeyup = () => { + this._extendExpiryTextInput.parentElement.parentElement.classList.remove("opacity-60"); + this._extendExpiryFieldInputs.classList.add("opacity-60"); + this._usingExtendExpiryTextInput = true; + this._displayExpiryDate(); + } + + this._extendExpiryTextInput.onclick = () => { + this._extendExpiryTextInput.parentElement.parentElement.classList.remove("opacity-60"); + this._extendExpiryFieldInputs.classList.add("opacity-60"); + this._usingExtendExpiryTextInput = true; + this._displayExpiryDate(); + }; + + this._extendExpiryFieldInputs.onclick = () => { + this._extendExpiryFieldInputs.classList.remove("opacity-60"); + this._extendExpiryTextInput.parentElement.parentElement.classList.add("opacity-60"); + this._usingExtendExpiryTextInput = false; + this._displayExpiryDate(); + }; + + for (let field of ["months", "days", "hours", "minutes"]) { + (document.getElementById("extend-expiry-"+field) as HTMLSelectElement).onchange = () => { + this._extendExpiryFieldInputs.classList.remove("opacity-60"); + this._extendExpiryTextInput.parentElement.parentElement.classList.add("opacity-60"); + this._usingExtendExpiryTextInput = false; + this._displayExpiryDate(); + }; + } + + this._disableEnable.onclick = this.enableDisableUsers; + this._disableEnable.parentElement.classList.add("unfocused"); + + this._enableExpiry.onclick = () => { this.extendExpiry(true); }; + this._enableExpiryNotify.onchange = () => { + if (this._enableExpiryNotify.checked) { + this._enableExpiryReason.classList.remove("unfocused"); + } else { + this._enableExpiryReason.classList.add("unfocused"); + } + }; + + if (!window.usernameEnabled) { + this._addUserName.classList.add("unfocused"); + this._addUserName = this._addUserEmail; + } + + if (!window.linkResetEnabled) { + this._sendPWR.classList.add("unfocused"); } else { - this._searchOptionsHeader.classList.add("hidden"); + this._sendPWR.onclick = this.sendPWR; } + /*if (!window.emailEnabled) { + this._deleteNotify.parentElement.classList.add("unfocused"); + this._deleteNotify.checked = false; + }*/ + + this._announceTextarea.onkeyup = this.loadPreview; + addDiscord = newDiscordSearch(window.lang.strings("linkDiscord"), window.lang.strings("searchDiscordUser"), window.lang.strings("add"), (user: DiscordUser, id: string) => { + _post("/users/discord", {jf_id: id, discord_id: user.id}, (req: XMLHttpRequest) => { + if (req.readyState == 4) { + document.dispatchEvent(new CustomEvent("accounts-reload")); + if (req.status != 200) { + window.notifications.customError("errorConnectDiscord", window.lang.notif("errorFailureCheckLogs")); + return + } + window.notifications.customSuccess("discordConnected", window.lang.notif("accountConnected")); + window.modals.discord.close() + } + }); + }); + + this._announceSaveButton.onclick = this.saveAnnouncement; + const announceVarUsername = document.getElementById("announce-variables-username") as HTMLSpanElement; + announceVarUsername.onclick = () => { + insertText(this._announceTextarea, announceVarUsername.children[0].textContent); + this.loadPreview(); + }; + + const headerNames: string[] = ["username", "access-jfa", "email", "telegram", "matrix", "discord", "expiry", "last-active", "referrals"]; + const headerGetters: string[] = ["name", "accounts_admin", "email", "telegram", "matrix", "discord", "expiry", "last_active", "referrals_enabled"]; + for (let i = 0; i < headerNames.length; i++) { + const header: HTMLTableCellElement = document.querySelector(".accounts-header-" + headerNames[i]) as HTMLTableCellElement; + if (header !== null) { + this._columns[headerGetters[i]] = new Column(header, headerGetters[i], Object.getOwnPropertyDescriptor(user.prototype, headerGetters[i]).get); + } + } + + // Start off sorting by username (this._c.defaultSortField) + const defaultSort = () => { + document.dispatchEvent(new CustomEvent("header-click", { detail: this._c.defaultSortField })); + this._columns[this._c.defaultSortField].ascending = this._c.defaultSortAscending; + this._columns[this._c.defaultSortField].hideIcon(); + this._sortingByButton.classList.add("hidden"); + this._search.showHideSearchOptionsHeader(); + }; + + this._sortingByButton.addEventListener("click", defaultSort); + + document.addEventListener("header-click", (event: CustomEvent) => { + this._search.setOrdering( + this._columns[event.detail].sort(this.users), + event.detail, + this._columns[event.detail].ascending + ); + this._sortingByButton.replaceChildren(this._columns[event.detail].asElement()); + this._sortingByButton.classList.remove("hidden"); + // console.log("ordering by", event.detail, ": ", this._ordering); + if (this._search.inSearch) { + this._search.onSearchBoxChange(); + } else { + this.setVisibility(this._search.ordering, true); + this._search.setNotFoundPanelVisibility(false); + } + this._search.inServerSearch = false; + this.autoSetServerSearchButtonsDisabled(); + this._search.showHideSearchOptionsHeader(); + }); + + defaultSort(); + + this._search.showHideSearchOptionsHeader(); + + this.registerURLListener(); } - private _queries: { [field: string]: QueryType } = { - "id": { - // We don't use a translation here to circumvent the name substitution feature. - name: "Jellyfin/Emby ID", - getter: "id", - bool: false, - string: true, - date: false - }, - "label": { - name: window.lang.strings("label"), - getter: "label", - bool: true, - string: true, - date: false - }, - "username": { - name: window.lang.strings("username"), - getter: "name", - bool: false, - string: true, - date: false - }, - "name": { - name: window.lang.strings("username"), - getter: "name", - bool: false, - string: true, - date: false, - show: false - }, - "admin": { - name: window.lang.strings("admin"), - getter: "admin", - bool: true, - string: false, - date: false - }, - "disabled": { - name: window.lang.strings("disabled"), - getter: "disabled", - bool: true, - string: false, - date: false - }, - "access-jfa": { - name: window.lang.strings("accessJFA"), - getter: "accounts_admin", - bool: true, - string: false, - date: false, - dependsOnElement: ".accounts-header-access-jfa" - }, - "email": { - name: window.lang.strings("emailAddress"), - getter: "email", - bool: true, - string: true, - date: false, - dependsOnElement: ".accounts-header-email" - }, - "telegram": { - name: "Telegram", - getter: "telegram", - bool: true, - string: true, - date: false, - dependsOnElement: ".accounts-header-telegram" - }, - "matrix": { - name: "Matrix", - getter: "matrix", - bool: true, - string: true, - date: false, - dependsOnElement: ".accounts-header-matrix" - }, - "discord": { - name: "Discord", - getter: "discord", - bool: true, - string: true, - date: false, - dependsOnElement: ".accounts-header-discord" - }, - "expiry": { - name: window.lang.strings("expiry"), - getter: "expiry", - bool: true, - string: false, - date: true, - dependsOnElement: ".accounts-header-expiry" - }, - "last-active": { - name: window.lang.strings("lastActiveTime"), - getter: "last_active", - bool: true, - string: false, - date: true - }, - "referrals-enabled": { - name: window.lang.strings("referrals"), - getter: "referrals_enabled", - bool: true, - string: false, - date: false, - dependsOnElement: ".accounts-header-referrals" - } + reload = (callback?: (resp: paginatedDTO) => void) => { + this._reload(callback); + this.loadTemplates(); } - private _notFoundPanel: HTMLElement = document.getElementById("accounts-not-found"); + loadMore = (loadAll: boolean = false, callback?: () => void) => { + this._loadMore( + loadAll, + callback + ); + }; get selectAll(): boolean { return this._selectAll.checked; } set selectAll(state: boolean) { let count = 0; - for (let id in this._users) { - if (this._table.contains(this._users[id].asElement())) { // Only select visible elements - this._users[id].selected = state; + for (let id in this.users) { + if (this._container.contains(this.users[id].asElement())) { // Only select visible elements + this.users[id].selected = state; count++; } } this._selectAll.checked = state; this._selectAll.indeterminate = false; - state ? this._checkCount = count : 0; + state ? this._counter.selected = count : 0; } selectAllBetweenIDs = (startID: string, endID: string) => { let inRange = false; - for (let id of this._ordering) { + for (let id of this._search.ordering) { if (!(inRange || id == startID)) continue; inRange = true; - if (!(this._table.contains(this._users[id].asElement()))) continue; - this._users[id].selected = true; + if (!(this._container.contains(this.users[id].asElement()))) continue; + this.users[id].selected = true; if (id == endID) return; } } add = (u: User) => { let domAccount = new user(u); - this._users[u.id] = domAccount; + this.users[u.id] = domAccount; + // console.log("after appending lengths:", Object.keys(this.users).length, Object.keys(this._search.items).length); } private _checkCheckCount = () => { const list = this._collectUsers(); - this._checkCount = list.length; - if (this._checkCount == 0) { + this._counter.selected = list.length; + if (this._counter.selected == 0) { this._selectAll.indeterminate = false; this._selectAll.checked = false; this._modifySettings.classList.add("unfocused"); @@ -964,12 +1289,12 @@ export class accountsList { this._sendPWR.classList.add("unfocused"); } else { let visibleCount = 0; - for (let id in this._users) { - if (this._table.contains(this._users[id].asElement())) { + for (let id in this.users) { + if (this._container.contains(this.users[id].asElement())) { visibleCount++; } } - if (this._checkCount == visibleCount) { + if (this._counter.selected == visibleCount) { this._selectAll.checked = true; this._selectAll.indeterminate = false; } else { @@ -988,27 +1313,27 @@ export class accountsList { let anyNonExpiries = list.length == 0 ? true : false; let allNonExpiries = true; let noContactCount = 0; - let referralState = Number(this._users[list[0]].referrals_enabled); // -1 = hide, 0 = show "enable", 1 = show "disable" + let referralState = Number(this.users[list[0]].referrals_enabled); // -1 = hide, 0 = show "enable", 1 = show "disable" // Only show enable/disable button if all selected have the same state. - this._shouldEnable = this._users[list[0]].disabled + this._shouldEnable = this.users[list[0]].disabled let showDisableEnable = true; for (let id of list) { - if (!anyNonExpiries && !this._users[id].expiry) { + if (!anyNonExpiries && !this.users[id].expiry) { anyNonExpiries = true; this._expiryDropdown.classList.add("unfocused"); } - if (this._users[id].expiry) { + if (this.users[id].expiry) { allNonExpiries = false; } - if (showDisableEnable && this._users[id].disabled != this._shouldEnable) { + if (showDisableEnable && this.users[id].disabled != this._shouldEnable) { showDisableEnable = false; this._disableEnable.parentElement.classList.add("unfocused"); } if (!showDisableEnable && anyNonExpiries) { break; } - if (!this._users[id].lastNotifyMethod()) { + if (!this.users[id].lastNotifyMethod()) { noContactCount++; } - if (window.referralsEnabled && referralState != -1 && Number(this._users[id].referrals_enabled) != referralState) { + if (window.referralsEnabled && referralState != -1 && Number(this.users[id].referrals_enabled) != referralState) { referralState = -1; } } @@ -1067,8 +1392,8 @@ export class accountsList { private _collectUsers = (): string[] => { let list: string[] = []; - for (let id in this._users) { - if (this._table.contains(this._users[id].asElement()) && this._users[id].selected) { list.push(id); } + for (let id in this.users) { + if (this._container.contains(this.users[id].asElement()) && this.users[id].selected) { list.push(id); } } return list; } @@ -1096,7 +1421,7 @@ export class accountsList { window.notifications.customSuccess("addUser", window.lang.var("notifications", "userCreated", `"${send['username']}"`)); if (!req.response["email"]) { window.notifications.customError("sendWelcome", window.lang.notif("errorSendWelcomeEmail")); - console.log("User created, but welcome email failed"); + console.error("User created, but welcome email failed"); } } else { let msg = window.lang.var("notifications", "errorUserCreated", `"${send['username']}"`); @@ -1107,7 +1432,7 @@ export class accountsList { window.notifications.customError("addUser", msg); } if (req.response["error"] as String) { - console.log(req.response["error"]); + console.error(req.response["error"]); } this.reload(); @@ -1375,7 +1700,7 @@ export class accountsList { let list = this._collectUsers(); let manualUser: user; for (let id of list) { - let user = this._users[id]; + let user = this.users[id]; if (!user.lastNotifyMethod() && !user.email) { manualUser = user; break; @@ -1441,8 +1766,8 @@ export class accountsList { (() => { let innerHTML = ""; - for (let id in this._users) { - innerHTML += ``; + for (let id in this.users) { + innerHTML += ``; } this._userSelect.innerHTML = innerHTML; })(); @@ -1506,7 +1831,7 @@ export class accountsList { let list = this._collectUsers(); // Check if we're disabling or enabling - if (this._users[list[0]].referrals_enabled) { + if (this.users[list[0]].referrals_enabled) { _delete("/users/referral", {"users": list}, (req: XMLHttpRequest) => { if (req.readyState != 4 || req.status != 200) return; window.notifications.customSuccess("disabledReferralsSuccess", window.lang.quantity("appliedSettings", list.length)); @@ -1625,8 +1950,8 @@ export class accountsList { let id = users.length > 0 ? users[0] : ""; if (!id) invalid = true; else { - date = new Date(this._users[id].expiry*1000); - if (this._users[id].expiry == 0) date = new Date(); + date = new Date(this.users[id].expiry*1000); + if (this.users[id].expiry == 0) date = new Date(); date.setMonth(date.getMonth() + (+fields[0].value)) date.setDate(date.getDate() + (+fields[1].value)); date.setHours(date.getHours() + (+fields[2].value)); @@ -1730,19 +2055,7 @@ export class accountsList { this._displayExpiryDate(); window.modals.extendExpiry.show(); } - - - setVisibility = (users: string[], visible: boolean) => { - this._table.textContent = ""; - for (let id of this._ordering) { - if (visible && users.indexOf(id) != -1) { - this._table.appendChild(this._users[id].asElement()); - } else if (!visible && users.indexOf(id) == -1) { - this._table.appendChild(this._users[id].asElement()); - } - } - } - + private _populateAddUserProfiles = () => { this._addUserProfile.textContent = ""; let innerHTML = ``; @@ -1753,10 +2066,10 @@ export class accountsList { } focusAccount = (userID: string) => { - console.log("focusing user", userID); - this._searchBox.value = `id:"${userID}"`; + console.debug("focusing user", userID); + this._c.searchBox.value = `id:"${userID}"`; this._search.onSearchBoxChange(); - if (userID in this._users) this._users[userID].focus(); + if (userID in this.users) this.users[userID].focus(); } public static readonly _accountURLEvent = "account-url"; @@ -1764,7 +2077,6 @@ export class accountsList { this.focusAccount(event.detail); }); - // FIXME: Use Query Param! so it doesn't get cleared by pages.ts. isAccountURL = () => { const urlParams = new URLSearchParams(window.location.search); const userID = urlParams.get("user"); @@ -1777,303 +2089,6 @@ export class accountsList { this.focusAccount(userID); } - constructor() { - this._populateNumbers(); - this._users = {}; - this._selectAll.checked = false; - this._selectAll.onchange = () => { - this.selectAll = this._selectAll.checked; - }; - document.addEventListener("accounts-reload", () => this.reload()); - document.addEventListener("accountCheckEvent", () => { this._checkCount++; this._checkCheckCount(); }); - document.addEventListener("accountUncheckEvent", () => { this._checkCount--; this._checkCheckCount(); }); - this._addUserButton.onclick = () => { - this._populateAddUserProfiles(); - window.modals.addUser.toggle(); - }; - this._addUserForm.addEventListener("submit", this._addUser); - - this._deleteNotify.onchange = () => { - if (this._deleteNotify.checked) { - this._deleteReason.classList.remove("unfocused"); - } else { - this._deleteReason.classList.add("unfocused"); - } - }; - this._modifySettings.onclick = this.modifyUsers; - this._modifySettings.classList.add("unfocused"); - - if (window.ombiEnabled) - this._applyOmbi.parentElement.classList.remove("unfocused"); - else - this._applyOmbi.parentElement.classList.add("unfocused"); - if (window.jellyseerrEnabled) - this._applyJellyseerr.parentElement.classList.remove("unfocused"); - else - this._applyJellyseerr.parentElement.classList.add("unfocused"); - - const checkSource = () => { - const profileSpan = this._modifySettingsProfile.nextElementSibling as HTMLSpanElement; - const userSpan = this._modifySettingsUser.nextElementSibling as HTMLSpanElement; - if (this._modifySettingsProfile.checked) { - this._userSelect.parentElement.classList.add("unfocused"); - this._profileSelect.parentElement.classList.remove("unfocused") - profileSpan.classList.add("@high"); - profileSpan.classList.remove("@low"); - userSpan.classList.remove("@high"); - userSpan.classList.add("@low"); - this._applyOmbi.parentElement.classList.remove("unfocused"); - this._applyJellyseerr.parentElement.classList.remove("unfocused"); - } else { - this._userSelect.parentElement.classList.remove("unfocused"); - this._profileSelect.parentElement.classList.add("unfocused"); - userSpan.classList.add("@high"); - userSpan.classList.remove("@low"); - profileSpan.classList.remove("@high"); - profileSpan.classList.add("@low"); - this._applyOmbi.parentElement.classList.add("unfocused"); - this._applyJellyseerr.parentElement.classList.add("unfocused"); - } - }; - this._modifySettingsProfile.onchange = checkSource; - this._modifySettingsUser.onchange = checkSource; - - if (window.referralsEnabled) { - const profileSpan = this._enableReferralsProfile.nextElementSibling as HTMLSpanElement; - const inviteSpan = this._enableReferralsInvite.nextElementSibling as HTMLSpanElement; - const checkReferralSource = () => { - console.log("States:", this._enableReferralsProfile.checked, this._enableReferralsInvite.checked); - if (this._enableReferralsProfile.checked) { - this._referralsInviteSelect.parentElement.classList.add("unfocused"); - this._referralsProfileSelect.parentElement.classList.remove("unfocused") - profileSpan.classList.add("@high"); - profileSpan.classList.remove("@low"); - inviteSpan.classList.remove("@high"); - inviteSpan.classList.add("@low"); - } else { - this._referralsInviteSelect.parentElement.classList.remove("unfocused"); - this._referralsProfileSelect.parentElement.classList.add("unfocused"); - inviteSpan.classList.add("@high"); - inviteSpan.classList.remove("@low"); - profileSpan.classList.remove("@high"); - profileSpan.classList.add("@low"); - } - }; - profileSpan.onclick = () => { - this._enableReferralsProfile.checked = true; - this._enableReferralsInvite.checked = false; - checkReferralSource(); - }; - inviteSpan.onclick = () => {; - this._enableReferralsInvite.checked = true; - this._enableReferralsProfile.checked = false; - checkReferralSource(); - }; - this._enableReferrals.onclick = () => { - this.enableReferrals(); - profileSpan.onclick(null); - }; - } - - this._deleteUser.onclick = this.deleteUsers; - this._deleteUser.classList.add("unfocused"); - - this._announceButton.onclick = this.announce; - this._announceButton.parentElement.classList.add("unfocused"); - - this._extendExpiry.onclick = () => { this.extendExpiry(); }; - this._removeExpiry.onclick = () => { this.removeExpiry(); }; - this._expiryDropdown.classList.add("unfocused"); - this._extendExpiryDate.classList.add("unfocused"); - - this._extendExpiryTextInput.onkeyup = () => { - this._extendExpiryTextInput.parentElement.parentElement.classList.remove("opacity-60"); - this._extendExpiryFieldInputs.classList.add("opacity-60"); - this._usingExtendExpiryTextInput = true; - this._displayExpiryDate(); - } - - this._extendExpiryTextInput.onclick = () => { - this._extendExpiryTextInput.parentElement.parentElement.classList.remove("opacity-60"); - this._extendExpiryFieldInputs.classList.add("opacity-60"); - this._usingExtendExpiryTextInput = true; - this._displayExpiryDate(); - }; - - this._extendExpiryFieldInputs.onclick = () => { - this._extendExpiryFieldInputs.classList.remove("opacity-60"); - this._extendExpiryTextInput.parentElement.parentElement.classList.add("opacity-60"); - this._usingExtendExpiryTextInput = false; - this._displayExpiryDate(); - }; - - for (let field of ["months", "days", "hours", "minutes"]) { - (document.getElementById("extend-expiry-"+field) as HTMLSelectElement).onchange = () => { - this._extendExpiryFieldInputs.classList.remove("opacity-60"); - this._extendExpiryTextInput.parentElement.parentElement.classList.add("opacity-60"); - this._usingExtendExpiryTextInput = false; - this._displayExpiryDate(); - }; - } - - this._disableEnable.onclick = this.enableDisableUsers; - this._disableEnable.parentElement.classList.add("unfocused"); - - this._enableExpiry.onclick = () => { this.extendExpiry(true); }; - this._enableExpiryNotify.onchange = () => { - if (this._enableExpiryNotify.checked) { - this._enableExpiryReason.classList.remove("unfocused"); - } else { - this._enableExpiryReason.classList.add("unfocused"); - } - }; - - if (!window.usernameEnabled) { - this._addUserName.classList.add("unfocused"); - this._addUserName = this._addUserEmail; - } - - if (!window.linkResetEnabled) { - this._sendPWR.classList.add("unfocused"); - } else { - this._sendPWR.onclick = this.sendPWR; - } - /*if (!window.emailEnabled) { - this._deleteNotify.parentElement.classList.add("unfocused"); - this._deleteNotify.checked = false; - }*/ - - let conf: SearchConfiguration = { - filterArea: this._filterArea, - sortingByButton: this._sortingByButton, - searchOptionsHeader: this._searchOptionsHeader, - notFoundPanel: this._notFoundPanel, - filterList: document.getElementById("accounts-filter-list"), - search: this._searchBox, - queries: this._queries, - setVisibility: this.setVisibility, - clearSearchButtonSelector: ".accounts-search-clear", - onSearchCallback: (_0: number, _1: boolean, _2: boolean) => { - this._checkCheckCount(); - } - }; - this._search = new Search(conf); - this._search.items = this._users; - - - this._announceTextarea.onkeyup = this.loadPreview; - addDiscord = newDiscordSearch(window.lang.strings("linkDiscord"), window.lang.strings("searchDiscordUser"), window.lang.strings("add"), (user: DiscordUser, id: string) => { - _post("/users/discord", {jf_id: id, discord_id: user.id}, (req: XMLHttpRequest) => { - if (req.readyState == 4) { - document.dispatchEvent(new CustomEvent("accounts-reload")); - if (req.status != 200) { - window.notifications.customError("errorConnectDiscord", window.lang.notif("errorFailureCheckLogs")); - return - } - window.notifications.customSuccess("discordConnected", window.lang.notif("accountConnected")); - window.modals.discord.close() - } - }); - }); - - this._announceSaveButton.onclick = this.saveAnnouncement; - const announceVarUsername = document.getElementById("announce-variables-username") as HTMLSpanElement; - announceVarUsername.onclick = () => { - insertText(this._announceTextarea, announceVarUsername.children[0].textContent); - this.loadPreview(); - }; - - const headerNames: string[] = ["username", "access-jfa", "email", "telegram", "matrix", "discord", "expiry", "last-active", "referrals"]; - const headerGetters: string[] = ["name", "accounts_admin", "email", "telegram", "matrix", "discord", "expiry", "last_active", "referrals_enabled"]; - for (let i = 0; i < headerNames.length; i++) { - const header: HTMLTableHeaderCellElement = document.querySelector(".accounts-header-" + headerNames[i]) as HTMLTableHeaderCellElement; - if (header !== null) { - this._columns[header.className] = new Column(header, Object.getOwnPropertyDescriptor(user.prototype, headerGetters[i]).get); - } - } - - // Start off sorting by Name - const defaultSort = () => { - this._activeSortColumn = document.getElementsByClassName("accounts-header-" + headerNames[0])[0].className; - document.dispatchEvent(new CustomEvent("header-click", { detail: this._activeSortColumn })); - this._columns[this._activeSortColumn].ascending = true; - this._columns[this._activeSortColumn].hideIcon(); - this._sortingByButton.parentElement.classList.add("hidden"); - this.showHideSearchOptionsHeader(); - }; - - this._sortingByButton.parentElement.addEventListener("click", defaultSort); - - document.addEventListener("header-click", (event: CustomEvent) => { - this._ordering = this._columns[event.detail].sort(this._users); - this._search.ordering = this._ordering; - this._activeSortColumn = event.detail; - this._sortingByButton.innerHTML = this._columns[event.detail].buttonContent; - this._sortingByButton.parentElement.classList.remove("hidden"); - // console.log("ordering by", event.detail, ": ", this._ordering); - if (!(this._search.inSearch)) { - this.setVisibility(this._ordering, true); - this._notFoundPanel.classList.add("unfocused"); - } else { - 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.showHideSearchOptionsHeader(); - }); - - defaultSort(); - this.showHideSearchOptionsHeader(); - - this._search.generateFilterList(); - - this.registerURLListener(); - } - - reload = (callback?: () => void) => { - _get("/users", null, (req: XMLHttpRequest) => { - if (req.readyState == 4 && req.status == 200) { - // same method as inviteList.reload() - let accountsOnDOM: { [id: string]: boolean } = {}; - for (let id in this._users) { accountsOnDOM[id] = true; } - for (let u of (req.response["users"] as User[])) { - if (u.id in this._users) { - this._users[u.id].update(u); - delete accountsOnDOM[u.id]; - } else { - this.add(u); - } - } - for (let id in accountsOnDOM) { - this._users[id].remove(); - delete this._users[id]; - } - // console.log("reload, so sorting by", this._activeSortColumn); - this._ordering = this._columns[this._activeSortColumn].sort(this._users); - this._search.ordering = this._ordering; - if (!(this._search.inSearch)) { - this.setVisibility(this._ordering, true); - this._notFoundPanel.classList.add("unfocused"); - } else { - const results = this._search.search(this._searchBox.value); - if (results.length == 0) { - this._notFoundPanel.classList.remove("unfocused"); - } else { - this._notFoundPanel.classList.add("unfocused"); - } - this.setVisibility(results, true); - } - this._checkCheckCount(); - - if (callback) callback(); - } - }); - this.loadTemplates(); - } } export const accountURLEvent = (id: string) => { return new CustomEvent(accountsList._accountURLEvent, {"detail": id}) }; @@ -2085,14 +2100,18 @@ type Getter = () => GetterReturnType; // When list is refreshed, accountList calls method of the specific Column and re-orders accordingly. // Listen for broadcast event from others, check its not us by comparing the header className in the message, then hide the arrow icon class Column { - private _header: HTMLTableHeaderCellElement; + private _header: HTMLTableCellElement; + private _card: HTMLElement; + private _cardSortingByIcon: HTMLElement; + private _name: string; private _headerContent: string; private _getter: Getter; private _ascending: boolean; private _active: boolean; - constructor(header: HTMLTableHeaderCellElement, getter: Getter) { + constructor(header: HTMLTableCellElement, name: string, getter: Getter) { this._header = header; + this._name = name; this._headerContent = this._header.textContent; this._getter = getter; this._ascending = true; @@ -2101,23 +2120,30 @@ class Column { this._header.addEventListener("click", () => { // If we are the active sort column, a click means to switch between ascending/descending. if (this._active) { - this._ascending = !this._ascending; - console.log("was already active, switching direction to", this._ascending ? "ascending" : "descending"); - } else { - console.log("wasn't active keeping direction as", this._ascending ? "ascending" : "descending"); + this.ascending = !this.ascending; + return; } this._active = true; this._header.setAttribute("aria-sort", this._headerContent); this.updateHeader(); - document.dispatchEvent(new CustomEvent("header-click", { detail: this._header.className })); + document.dispatchEvent(new CustomEvent("header-click", { detail: this._name })); }); document.addEventListener("header-click", (event: CustomEvent) => { - if (event.detail != this._header.className) { + if (event.detail != this._name) { this._active = false; this._header.removeAttribute("aria-sort"); this.hideIcon(); } }); + + this._card = document.createElement("button"); + this._card.classList.add("button", "~neutral", "@low", "center", "flex", "flex-row", "gap-1"); + this._card.innerHTML = ` + + ${window.lang.strings("sortingBy")}: ${this._headerContent} + + `; + this._cardSortingByIcon = this._card.querySelector(".sorting-by-direction"); } hideIcon = () => { @@ -2131,26 +2157,32 @@ class Column { `; } - // Returns the inner HTML to show in the "Sorting By" button. - get buttonContent() { - return `` + window.lang.strings("sortingBy") + ": " + `` + this._headerContent; - } + asElement = () => { return this._card }; get ascending() { return this._ascending; } set ascending(v: boolean) { this._ascending = v; + if (v) { + this._cardSortingByIcon.classList.add("ri-arrow-up-s-line"); + this._cardSortingByIcon.classList.remove("ri-arrow-down-s-line"); + } else { + this._cardSortingByIcon.classList.add("ri-arrow-down-s-line"); + this._cardSortingByIcon.classList.remove("ri-arrow-up-s-line"); + } if (!this._active) return; this.updateHeader(); this._header.setAttribute("aria-sort", this._headerContent); - document.dispatchEvent(new CustomEvent("header-click", { detail: this._header.className })); + document.dispatchEvent(new CustomEvent("header-click", { detail: this._name })); } // Sorts the user list. previouslyActive is whether this column was previously sorted by, indicating that the direction should change. sort = (users: { [id: string]: user }): string[] => { let userIDs = Object.keys(users); userIDs.sort((a: string, b: string): number => { - const av: GetterReturnType = this._getter.call(users[a]); - const bv: GetterReturnType = this._getter.call(users[b]); + let av: GetterReturnType = this._getter.call(users[a]); + if (typeof av === "string") av = av.toLowerCase(); + let bv: GetterReturnType = this._getter.call(users[b]); + if (typeof bv === "string") bv = bv.toLowerCase(); if (av < bv) return this._ascending ? -1 : 1; if (av > bv) return this._ascending ? 1 : -1; return 0; diff --git a/ts/modules/activity.ts b/ts/modules/activity.ts index 135d50b..94af779 100644 --- a/ts/modules/activity.ts +++ b/ts/modules/activity.ts @@ -1,10 +1,14 @@ -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, SearchableItemDataAttribute } 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; +const ACTIVITY_DEFAULT_SORT_FIELD = "time"; +const ACTIVITY_DEFAULT_SORT_ASCENDING = false; + export interface activity { id: string; type: string; @@ -32,6 +36,124 @@ 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, + localOnly: true + }, + "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 + }, + "time": { + name: window.lang.strings("date"), + getter: "time", + 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"); @@ -232,7 +354,10 @@ export class Activity implements activity, SearchableItem { } get id(): string { return this._act.id; } - set id(v: string) { this._act.id = v; } + set id(v: string) { + this._act.id = v; + this._card.setAttribute(SearchableItemDataAttribute, v); + } get user_id(): string { return this._act.user_id; } set user_id(v: string) { this._act.user_id = v; } @@ -258,6 +383,7 @@ export class Activity implements activity, SearchableItem { this._card = document.createElement("div"); this._card.classList.add("card", "@low", "my-2"); + this._card.innerHTML = `
@@ -346,343 +472,127 @@ export class Activity implements activity, SearchableItem { asElement = () => { return this._card; }; } -interface ActivitiesDTO { +interface ActivitiesReqDTO extends PaginatedReqDTO { + type: string[]; +}; + +interface ActivitiesDTO extends paginatedDTO { activities: activity[]; - last_page: boolean; } -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 _container: HTMLElement; + protected _sortDirection = document.getElementById("activity-sort-direction") as HTMLButtonElement; - private _totalRecords = document.getElementById("activity-total-records"); - private _loadedRecords = document.getElementById("activity-loaded-records"); - private _shownRecords = document.getElementById("activity-shown-records"); - - private _total: number; - private _loaded: number; - private _shown: number; - - get total(): number { return this._total; } - set total(v: number) { - this._total = v; - this._totalRecords.textContent = window.lang.var("strings", "totalRecords", `${v}`); - } + protected _ascending: boolean; - get loaded(): number { return this._loaded; } - set loaded(v: number) { - this._loaded = v; - this._loadedRecords.textContent = window.lang.var("strings", "loadedRecords", `${v}`); - } + 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; } - get shown(): number { return this._shown; } - set shown(v: number) { - this._shown = v; - this._shownRecords.textContent = window.lang.var("strings", "shownRecords", `${v}`); - } - - private _search: Search; - private _ascending: boolean; - private _hasLoaded: boolean; - private _lastLoad: number; - private _page: number = 0; - private _lastPage: boolean; - - - setVisibility = (activities: string[], visible: boolean) => { - this._activityList.textContent = ``; - for (let id of this._ordering) { - if (visible && activities.indexOf(id) != -1) { - this._activityList.appendChild(this._activities[id].asElement()); - } else if (!visible && activities.indexOf(id) == -1) { - 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.total = 0; - this.loaded = 0; - this.shown = 0; - - // this._page = 0; - let limit = 10; - if (this._page != 0) { - limit *= this._page+1; - }; - - let send = { - "type": [], - "limit": limit, - "page": 0, - "ascending": this.ascending - } - - _get("/activity/count", null, (req: XMLHttpRequest) => { - if (req.readyState != 4 || req.status != 200) return; - this.total = req.response["count"] as number; - }); - - _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.loaded = this._ordering.length; - - if (this._search.inSearch) { - this._search.onSearchBoxChange(true); - this._loadAllButton.classList.remove("unfocused"); - } else { - this.shown = this.loaded; - this.setVisibility(this._ordering, true); - this._loadAllButton.classList.add("unfocused"); - this._notFoundPanel.classList.add("unfocused"); - } - }, true); - } - - 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; - } - - 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.loaded = this._ordering.length; - - if (this._search.inSearch || loadAll) { - if (this._lastPage) { - loadAll = false; + 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, + filterArea: document.getElementById("activity-filter-area"), + searchOptionsHeader: document.getElementById("activity-search-options-header"), + searchBox: document.getElementById("activity-search") as HTMLInputElement, + recordCounter: document.getElementById("activity-record-counter"), + totalEndpoint: "/activity/count", + getPageEndpoint: "/activity", + itemsPerPage: 20, + maxItemsLoadedForSearch: 200, + appendNewItems: (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); + } + this._search.setOrdering(ordering, this._c.defaultSortField, this.ascending); + }, + replaceWithNewItems: (resp: paginatedDTO) => { + // FIXME: Implement updates to existing elements, rather than just wiping each time. + + // Remove existing items + for (let id of Object.keys(this.activities)) { + delete this.activities[id]; + } + // And wipe their ordering + this._search.setOrdering([], this._c.defaultSortField, this.ascending); + this._c.appendNewItems(resp); + }, + defaultSortField: ACTIVITY_DEFAULT_SORT_FIELD, + defaultSortAscending: ACTIVITY_DEFAULT_SORT_ASCENDING, + pageLoadCallback: (req: XMLHttpRequest) => { + if (req.readyState != 4) return; + if (req.status != 200) { + window.notifications.customError("loadActivitiesError", window.lang.notif("errorLoadActivities")); + return; } - this._search.onSearchBoxChange(true, loadAll); - } else { - this.setVisibility(this._ordering, true); - this._notFoundPanel.classList.add("unfocused"); } + }); + + this._container = document.getElementById("activity-card-list") + document.addEventListener("activity-reload", () => this.reload()); - if (callback) callback(); - // removeLoader(this._loader); - // this._activityList.classList.remove("unfocused"); - }, true); + 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: document.getElementById("activity-not-found"), + notFoundLocallyText: document.getElementById("activity-no-local-results"), + search: this._c.searchBox, + clearSearchButtonSelector: ".activity-search-clear", + serverSearchButtonSelector: ".activity-search-server", + queries: queries(), + setVisibility: null, + filterList: document.getElementById("activity-filter-list"), + // notFoundCallback: this._notFoundCallback, + onSearchCallback: null, + searchServer: null, + clearServerSearch: null, + } + + this.initSearch(searchConfig); + + this.ascending = this._c.defaultSortAscending; + this._sortDirection.addEventListener("click", () => this.ascending = !this.ascending); } - 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 - } + reload = (callback?: (resp: paginatedDTO) => void) => { + this._reload(callback); + } + + loadMore = (loadAll: boolean = false, callback?: () => void) => { + this._loadMore( + loadAll, + callback + ); }; - get ascending(): boolean { return this._ascending; } + get ascending(): boolean { + return this._ascending; + } set ascending(v: boolean) { this._ascending = v; + // Setting default sort makes sense, since this is the only sort ever being done. + this._c.defaultSortAscending = this.ascending; this._sortDirection.innerHTML = `${window.lang.strings("sortDirection")} `; + // NOTE: 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(); + if (this._search.inServerSearch) { + // Re-run server search as new, since we changed the sort. + this._search.searchServer(true); + } else { + 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"); @@ -693,53 +603,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); - - 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.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; - } } diff --git a/ts/modules/common.ts b/ts/modules/common.ts index 40b5ddd..6a3319f 100644 --- a/ts/modules/common.ts +++ b/ts/modules/common.ts @@ -1,4 +1,5 @@ declare var window: GlobalWindow; +import dateParser from "any-date-parser"; export function toDateString(date: Date): string { const locale = window.language || (window as any).navigator.userLanguage || window.navigator.language; @@ -21,6 +22,25 @@ export function toDateString(date: Date): string { return date.toLocaleDateString(locale, args1) + " " + date.toLocaleString(locale, args2); } +export const parseDateString = (value: string): ParsedDate => { + let out: ParsedDate = { + text: value, + // Used just to tell use what fields the user passed. + attempt: dateParser.attempt(value), + // note Date.fromString is also provided by dateParser. + date: (Date as any).fromString(value) as Date + }; + if (("invalid" in (out.date as any))) { + out.invalid = true; + } else { + // getTimezoneOffset returns UTC - Timezone, so invert it to get distance from UTC -to- timezone. + out.attempt.offsetMinutesFromUTC = -1 * out.date.getTimezoneOffset(); + } + // Month in Date objects is 0-based, so make our parsed date that way too + if ("month" in out.attempt) out.attempt.month -= 1; + return out; +} + export const _get = (url: string, data: Object, onreadystatechange: (req: XMLHttpRequest) => void, noConnectionError: boolean = false): void => { let req = new XMLHttpRequest(); if (window.pages) { url = window.pages.Base + url; } @@ -306,3 +326,20 @@ export function unicodeB64Encode(s: string): string { const bin = String.fromCodePoint(...encoded); return btoa(bin); } + +// Only allow running a function every n milliseconds. +// Source: Clément Prévost at https://stackoverflow.com/questions/27078285/simple-throttle-in-javascript +// function foo(bar: T): T { +export function throttle (callback: () => void, limitMilliseconds: number): () => void { + var waiting = false; // Initially, we're not waiting + return function () { // We return a throttled function + if (!waiting) { // If we're not waiting + callback.apply(this, arguments); // Execute users function + waiting = true; // Prevent future invocations + setTimeout(function () { // After a period of time + waiting = false; // And allow future invocations + }, limitMilliseconds); + } + } +} + diff --git a/ts/modules/list.ts b/ts/modules/list.ts new file mode 100644 index 0000000..4c25366 --- /dev/null +++ b/ts/modules/list.ts @@ -0,0 +1,510 @@ +import { _get, _post, addLoader, removeLoader, throttle } from "./common"; +import { Search, SearchConfiguration } from "./search"; +import "@af-utils/scrollend-polyfill"; + +declare var window: GlobalWindow; + +export interface ListItem { + asElement: () => HTMLElement; +}; + +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 = ` + + + + + `; + this._totalRecords = this._container.getElementsByClassName("records-total")[0] as HTMLElement; + this._loadedRecords = this._container.getElementsByClassName("records-loaded")[0] as HTMLElement; + this._shownRecords = this._container.getElementsByClassName("records-shown")[0] as HTMLElement; + this._selectedRecords = this._container.getElementsByClassName("records-selected")[0] as HTMLElement; + this.total = 0; + this.loaded = 0; + this.shown = 0; + } + + 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; + filterArea: HTMLElement; + searchOptionsHeader: HTMLElement; + searchBox: HTMLInputElement; + recordCounter: HTMLElement; + totalEndpoint: string; + getPageEndpoint: string; + itemsPerPage: number; + maxItemsLoadedForSearch: number; + appendNewItems: (resp: paginatedDTO) => void; + replaceWithNewItems: (resp: paginatedDTO) => void; + defaultSortField: string; + defaultSortAscending: boolean; + pageLoadCallback?: (req: XMLHttpRequest) => void; +} + +export abstract class PaginatedList { + protected _c: PaginatedListConfig; + + // Container to append items to. + protected _container: HTMLElement; + // List of visible IDs (i.e. those set with setVisibility). + protected _visible: string[]; + // Infinite-scroll related data. + // Implementation partially based on this blog post, thank you Miina Lervik: + // https://www.bekk.christmas/post/2021/02/how-to-lazy-render-large-data-tables-to-up-performance + protected _scroll = { + rowHeight: 0, + screenHeight: 0, + // Render this many screen's worth of content below the viewport. + renderNExtraScreensWorth: 3, + rendered: 0, + initialRenderCount: 0, + scrollLoading: false, + // Used to calculate scroll speed, so more pages are loaded when scrolling fast. + lastScrollY: 0, + }; + + 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; + this._c.loadAllButton.classList.remove("unfocused"); + } + this.autoSetServerSearchButtonsDisabled(); + } + + protected _previousVisibleItemCount = 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(false); + this._c.loadAllButton.onclick = () => { + addLoader(this._c.loadAllButton, true); + this.loadMore(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. + this._c.refreshButton.onclick = () => this.reload(); + } + + autoSetServerSearchButtonsDisabled = () => { + const serverSearchSortChanged = this._search.inServerSearch && (this._searchParams.sortByField != this._search.sortField || this._searchParams.ascending != this._search.ascending); + if (this._search.inServerSearch) { + if (serverSearchSortChanged) { + this._search.setServerSearchButtonsDisabled(false); + } else { + this._search.setServerSearchButtonsDisabled(this.lastPage); + } + return; + } + if (!this._search.inSearch && this._search.sortField == this._c.defaultSortField && this._search.ascending == this._c.defaultSortAscending) { + this._search.setServerSearchButtonsDisabled(true); + return; + } + this._search.setServerSearchButtonsDisabled(false); + } + + initSearch = (searchConfig: SearchConfiguration) => { + const previousCallback = searchConfig.onSearchCallback; + searchConfig.onSearchCallback = (newItems: boolean, loadAll: boolean) => { + // if (this._search.inSearch && !this.lastPage) this._c.loadAllButton.classList.remove("unfocused"); + // else this._c.loadAllButton.classList.add("unfocused"); + + this.autoSetServerSearchButtonsDisabled(); + + // FIXME: Figure out why this makes sense and make it clearer. + if ((this._visible.length < this._c.itemsPerPage && this._counter.loaded < this._c.maxItemsLoadedForSearch && !this.lastPage) || loadAll) { + if (!newItems || + this._previousVisibleItemCount != this._visible.length || + (this._visible.length == 0 && !this.lastPage) || + loadAll + ) { + this.loadMore(loadAll); + } + } + this._previousVisibleItemCount = this._visible.length; + if (previousCallback) previousCallback(newItems, loadAll); + }; + const previousServerSearch = searchConfig.searchServer; + searchConfig.searchServer = (params: PaginatedReqDTO, newSearch: boolean) => { + this._searchParams = params; + if (newSearch) this.reload(); + else this.loadMore(false); + + if (previousServerSearch) previousServerSearch(params, newSearch); + }; + searchConfig.clearServerSearch = () => { + console.trace("Clearing server search"); + this._page = 0; + this.reload(); + } + searchConfig.setVisibility = this.setVisibility; + this._search = new Search(searchConfig); + this._search.generateFilterList(); + this.lastPage = false; + }; + + // Sets the elements with "name"s in "elements" as visible or not. + // setVisibilityNaive = (elements: string[], visible: boolean) => { + // let timer = this._search.timeSearches ? performance.now() : null; + // if (visible) this._visible = elements; + // else this._visible = this._search.ordering.filter(v => !elements.includes(v)); + // const frag = document.createDocumentFragment() + // for (let i = 0; i < this._visible.length; i++) { + // frag.appendChild(this._search.items[this._visible[i]].asElement()) + // } + // this._container.replaceChildren(frag); + // if (this._search.timeSearches) { + // const totalTime = performance.now() - timer; + // console.log(`setVisibility took ${totalTime}ms`); + // } + // } + + // FIXME: Might have broken _counter.shown! + // Sets the elements with "name"s in "elements" as visible or not. + // appendedItems==true implies "elements" is the previously rendered elements plus some new ones on the end. Knowing this means the page's infinite scroll doesn't have to be reset. + setVisibility = (elements: string[], visible: boolean, appendedItems: boolean = false) => { + let timer = this._search.timeSearches ? performance.now() : null; + if (visible) this._visible = elements; + else this._visible = this._search.ordering.filter(v => !elements.includes(v)); + // console.log(elements.length, visible, this._visible.length); + this._counter.shown = this._visible.length; + if (this._visible.length == 0) { + this._container.textContent = ``; + return; + } + + if (!appendedItems) { + // Wipe old elements and render 1 new one, so we can take the element height. + this._container.replaceChildren(this._search.items[this._visible[0]].asElement()) + } + + this._computeScrollInfo(); + + // Initial render of min(_visible.length, max(rowsOnPage*renderNExtraScreensWorth, itemsPerPage)), skipping 1 as we already did it. + this._scroll.initialRenderCount = Math.floor(Math.min( + this._visible.length, + Math.max( + ((this._scroll.renderNExtraScreensWorth+1)*this._scroll.screenHeight)/this._scroll.rowHeight, + this._c.itemsPerPage) + )); + + let baseIndex = 1; + if (appendedItems) { + baseIndex = this._scroll.rendered; + } + const frag = document.createDocumentFragment() + for (let i = baseIndex; i < this._scroll.initialRenderCount; i++) { + frag.appendChild(this._search.items[this._visible[i]].asElement()) + } + this._scroll.rendered = Math.max(baseIndex, this._scroll.initialRenderCount); + // appendChild over replaceChildren because there's already elements on the DOM + this._container.appendChild(frag); + + if (this._search.timeSearches) { + const totalTime = performance.now() - timer; + console.debug(`setVisibility took ${totalTime}ms`); + } + } + + // Computes required scroll info, requiring one on-DOM item. Should be computed on page resize and this._visible change. + _computeScrollInfo = () => { + if (this._visible.length == 0) return; + + this._scroll.screenHeight = Math.max( + document.documentElement.clientHeight, + window.innerHeight || 0 + ); + + this._scroll.rowHeight = this._search.items[this._visible[0]].asElement().offsetHeight; + } + + // returns the item index to render up to for the given scroll position. + // might return a value greater than this._visible.length, indicating a need for a page load. + maximumItemsToRender = (scrollY: number): number => { + const bottomScroll = scrollY + ((this._scroll.renderNExtraScreensWorth+1)*this._scroll.screenHeight); + const bottomIdx = Math.floor(bottomScroll / this._scroll.rowHeight); + return bottomIdx; + } + + private _load = ( + itemLimit: number, + page: number, + appendFunc: (resp: paginatedDTO) => void, // Function to append/put items in storage. + pre?: (resp: paginatedDTO) => void, + post?: (resp: paginatedDTO) => void, + failCallback?: (req: XMLHttpRequest) => void + ) => { + this._lastLoad = Date.now(); + let params = this._search.inServerSearch ? this._searchParams : this.defaultParams(); + params.limit = itemLimit; + params.page = page; + if (params.sortByField == "") { + params.sortByField = this._c.defaultSortField; + params.ascending = this._c.defaultSortAscending; + } + + _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 (failCallback) failCallback(req); + return; + } + this._hasLoaded = true; + + let resp = req.response as paginatedDTO; + if (pre) pre(resp); + + this.lastPage = resp.last_page; + + appendFunc(resp); + + this._counter.loaded = this._search.ordering.length; + + if (post) post(resp); + + if (this._c.pageLoadCallback) this._c.pageLoadCallback(req); + }, true); + } + + // Removes all elements, and reloads the first page. + public abstract reload: (callback?: (resp: paginatedDTO) => void) => void; + protected _reload = (callback?: (resp: paginatedDTO) => void) => { + 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.itemsPerPage; + if (this._page != 0) { + limit *= this._page+1; + } + this._load( + limit, + 0, + this._c.replaceWithNewItems, + (_0: paginatedDTO) => { + // Allow refreshes every 15s + this._c.refreshButton.disabled = true; + setTimeout(() => this._c.refreshButton.disabled = false, 15000); + }, + (resp: paginatedDTO) => { + this._search.onSearchBoxChange(true, false, false); + if (this._search.inSearch) { + // this._c.loadAllButton.classList.remove("unfocused"); + } else { + this._counter.shown = this._counter.loaded; + this.setVisibility(this._search.ordering, true); + // this._search.showHideNotFoundPanel(false); + } + if (callback) callback(resp); + }, + ); + } + + // Loads the next page. If "loadAll", all pages will be loaded until the last is reached. + public abstract loadMore: (loadAll?: boolean, callback?: () => void) => void; + protected _loadMore = (loadAll: boolean = false, callback?: (resp: paginatedDTO) => void) => { + this._c.loadMoreButton.disabled = true; + const timeout = setTimeout(() => { + this._c.loadMoreButton.disabled = false; + }, 1000); + this._page += 1; + + this._load( + this._c.itemsPerPage, + this._page, + this._c.appendNewItems, + (resp: 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); + } + }, + (resp: paginatedDTO) => { + if (this._search.inSearch || loadAll) { + if (this.lastPage) { + loadAll = false; + } + this._search.onSearchBoxChange(true, true, loadAll); + } else { + // Since results come to us ordered already, we can assume "ordering" + // will be identical to pre-page-load but with extra elements at the end, + // allowing infinite scroll to continue + this.setVisibility(this._search.ordering, true, true); + this._search.setNotFoundPanelVisibility(false); + } + if (callback) callback(resp); + }, + ); + } + + loadNItems = (n: number) => { + const cb = () => { + if (this._counter.loaded > n) return; + this.loadMore(false, cb); + } + cb(); + } + + // As reloading can disrupt long-scrolling, this function will only do it if you're at the top of the page, essentially. + public reloadIfNotInScroll = () => { + if (this._visible.length == 0 || this.maximumItemsToRender(window.scrollY) < this._scroll.initialRenderCount) { + return this.reload(); + } + } + + + _detectScroll = () => { + if (!this._hasLoaded || this._scroll.scrollLoading || this._visible.length == 0) return; + const scrollY = window.scrollY; + const scrollSpeed = scrollY - this._scroll.lastScrollY; + this._scroll.lastScrollY = scrollY; + // If you've scrolled back up, do nothing + if (scrollSpeed < 0) return; + let endIdx = this.maximumItemsToRender(scrollY); + + // Throttling this function means we might not catch up in time if the user scrolls fast, + // so we calculate the scroll speed (in rows/call) from the previous scrollY value. + // This still might not be enough, so hackily we'll just scale it up. + // With onscrollend, this is less necessary, but with both I wasn't able to hit the bottom of the page on my mouse. + const rowsPerScroll = Math.round((scrollSpeed / this._scroll.rowHeight)); + // Render extra pages depending on scroll speed + endIdx += rowsPerScroll*2; + + const realEndIdx = Math.min(endIdx, this._visible.length); + const frag = document.createDocumentFragment(); + for (let i = this._scroll.rendered; i < realEndIdx; i++) { + frag.appendChild(this._search.items[this._visible[i]].asElement()); + } + this._scroll.rendered = realEndIdx; + this._container.appendChild(frag); + + if (endIdx >= this._visible.length) { + if (this.lastPage || this._lastLoad + 500 > Date.now()) return; + this._scroll.scrollLoading = true; + const cb = () => { + if (this._visible.length < endIdx && !this.lastPage) { + // FIXME: This causes scroll-to-top when in search. + this.loadMore(false, cb); + return; + } + + this._scroll.scrollLoading = false; + this._detectScroll(); + }; + cb(); + return; + } + } + + detectScroll = throttle(this._detectScroll, 200); + + computeScrollInfo = throttle(this._computeScrollInfo, 200); + + redrawScroll = this.computeScrollInfo; + + // bindPageEvents binds window event handlers for when this list/tab containing it is visible. + bindPageEvents = () => { + window.addEventListener("scroll", this.detectScroll); + // Not available on safari, we include a polyfill though. + window.addEventListener("scrollend", this.detectScroll); + window.addEventListener("resize", this.redrawScroll); + }; + + unbindPageEvents = () => { + window.removeEventListener("scroll", this.detectScroll); + window.removeEventListener("scrollend", this.detectScroll); + window.removeEventListener("resize", this.redrawScroll); + } +} + + diff --git a/ts/modules/login.ts b/ts/modules/login.ts index 11d09bd..eb9fff5 100644 --- a/ts/modules/login.ts +++ b/ts/modules/login.ts @@ -53,7 +53,7 @@ export class Login { } }, false, (req: XMLHttpRequest) => { if (req.readyState == 4 && req.status == 404 && tryAgain) { - console.log("trying without URL Base..."); + console.warn("logout failed, trying without URL Base..."); logoutFunc(this._endpoint, false); } }); diff --git a/ts/modules/search.ts b/ts/modules/search.ts index fa8d1e1..ac92692 100644 --- a/ts/modules/search.ts +++ b/ts/modules/search.ts @@ -1,7 +1,25 @@ -const dateParser = require("any-date-parser"); +import { ListItem } from "./list"; +import { parseDateString } from "./common"; declare var window: GlobalWindow; +export enum QueryOperator { + Greater = ">", + Lower = "<", + Equal = "=" +} + +export function QueryOperatorToDateText(op: QueryOperator): string { + switch (op) { + case QueryOperator.Greater: + return window.lang.strings("after"); + case QueryOperator.Lower: + return window.lang.strings("before"); + default: + return ""; + } +} + export interface QueryType { name: string; description?: string; @@ -11,41 +29,271 @@ export interface QueryType { date: boolean; dependsOnElement?: string; // Format for querySelector show?: boolean; + localOnly?: boolean // Indicates can't be performed server-side. } export interface SearchConfiguration { filterArea: HTMLElement; - sortingByButton: HTMLButtonElement; + sortingByButton?: HTMLButtonElement; searchOptionsHeader: HTMLElement; notFoundPanel: HTMLElement; + notFoundLocallyText: 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; + setVisibility: (items: string[], visible: boolean, appendedItems: boolean) => void; + onSearchCallback: (newItems: boolean, loadAll: boolean) => void; + searchServer: (params: PaginatedReqDTO, newSearch: boolean) => void; + clearServerSearch: () => void; loadMore?: () => void; } -export interface SearchableItem { +export interface ServerSearchReqDTO extends PaginatedReqDTO { + searchTerms: string[]; + queries: QueryDTO[]; +} + +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; + public type: string; + + constructor(subject: QueryType | null, operator: QueryOperator) { + this._subject = subject; + this._operator = operator; + if (subject != null) { + this._card = document.createElement("span"); + this._card.ariaLabel = window.lang.strings("clickToRemoveFilter"); + } + } + + set onclick(v: () => void) { + this._card.addEventListener("click", v); + } + + asElement(): HTMLElement { return this._card; } + + public abstract compare(subjectValue: any): boolean; + + asDTO(): QueryDTO | null { + if (this.localOnly) return null; + let out = {} as QueryDTO; + out.field = this._subject.getter; + out.operator = this._operator; + return out; + } + + get subject(): QueryType { return this._subject; } + + getValueFromItem(item: SearchableItem): any { + return Object.getOwnPropertyDescriptor(Object.getPrototypeOf(item), this.subject.getter).get.call(item); + } + + compareItem(item: SearchableItem): boolean { + return this.compare(this.getValueFromItem(item)); + } + + get localOnly(): boolean { return this._subject.localOnly ? true : false; } +} + +export class BoolQuery extends Query { + protected _value: boolean; + constructor(subject: QueryType, value: boolean) { + super(subject, QueryOperator.Equal); + this.type = "bool"; + this._value = value; + this._card.classList.add("button", "~" + (this._value ? "positive" : "critical"), "@high", "center"); + this._card.innerHTML = ` + ${subject.name} + + `; + } + + public static paramsFromString(valueString: string): [boolean, boolean] { + let isBool = false; + let boolState = false; + if (valueString == "true" || valueString == "yes" || valueString == "t" || valueString == "y") { + isBool = true; + boolState = true; + } else if (valueString == "false" || valueString == "no" || valueString == "f" || valueString == "n") { + isBool = true; + boolState = false; + } + return [boolState, isBool] + } + + get value(): boolean { return this._value; } + + // Ripped from old code. Why it's like this, I don't know + public compare(subjectBool: boolean): boolean { + return ((subjectBool && this._value) || (!subjectBool && !this._value)) + } + + asDTO(): QueryDTO | null { + let out = super.asDTO(); + if (out === null) return null; + 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.type = "string"; + this._value = value.toLowerCase(); + this._card.classList.add("button", "~neutral", "@low", "center"); + this._card.innerHTML = ` + ${subject.name}: "${this._value}" + `; + } + + get value(): string { return this._value; } + + public compare(subjectString: string): boolean { + return subjectString.toLowerCase().includes(this._value); + } + + asDTO(): QueryDTO | null { + let out = super.asDTO(); + if (out === null) return null; + out.class = "string"; + out.value = this._value; + return out; + } +} + +const dateGetters: Map number> = (() => { + let m = new Map number>(); + m.set("year", Date.prototype.getFullYear); + m.set("month", Date.prototype.getMonth); + m.set("day", Date.prototype.getDate); + m.set("hour", Date.prototype.getHours); + m.set("minute", Date.prototype.getMinutes); + return m; +})(); +const dateSetters: Map void> = (() => { + let m = new Map void>(); + m.set("year", Date.prototype.setFullYear); + m.set("month", Date.prototype.setMonth); + m.set("day", Date.prototype.setDate); + m.set("hour", Date.prototype.setHours); + m.set("minute", Date.prototype.setMinutes); + return m; +})(); + +export class DateQuery extends Query { + protected _value: ParsedDate; + + constructor(subject: QueryType, operator: QueryOperator, value: ParsedDate) { + super(subject, operator); + this.type = "date"; + this._value = value; + this._card.classList.add("button", "~neutral", "@low", "center"); + let dateText = QueryOperatorToDateText(operator); + this._card.innerHTML = ` + ${subject.name}: ${dateText != "" ? dateText+" " : ""}${value.text} + `; + } + + public static paramsFromString(valueString: string): [ParsedDate, QueryOperator, boolean] { + let op = QueryOperator.Equal; + if ((Object.values(QueryOperator) as string[]).includes(valueString.charAt(0))) { + op = valueString.charAt(0) as QueryOperator; + // Trim the operator from the string + valueString = valueString.substring(1); + } + let out = parseDateString(valueString); + let isValid = true; + if (out.invalid) isValid = false; + + return [out, op, isValid]; + } + + get value(): ParsedDate { return this._value; } + + public compare(subjectDate: Date): boolean { + // We want to compare only the fields given in this._value, + // so we copy subjectDate and apply on those fields from this._value. + const temp = new Date(subjectDate.valueOf()); + for (let [field] of dateGetters) { + if (field in this._value.attempt) { + dateSetters.get(field).call( + temp, + dateGetters.get(field).call(this._value.date) + ); + } + } + + if (this._operator == QueryOperator.Equal) { + return subjectDate.getTime() == temp.getTime(); + } else if (this._operator == QueryOperator.Lower) { + return subjectDate < temp; + } + return subjectDate > temp; + } + + asDTO(): QueryDTO | null { + let out = super.asDTO(); + if (out === null) return null; + out.class = "date"; + out.value = this._value.attempt; + return out; + } +} + +export interface SearchableItem extends ListItem { matchesSearch: (query: string) => boolean; } +export const SearchableItemDataAttribute = "data-search-item"; + +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) { + const previous = this._inServerSearch; + this._inServerSearch = v; + if (!v && previous != v) { + this._c.clearServerSearch(); + } + } - search = (query: String): string[] => { - this._c.filterArea.textContent = ""; + // Intended to be set from the JS console, if true searches are timed. + timeSearches: boolean = false; + private _serverSearchButtons: HTMLElement[]; + + static tokenizeSearch = (query: string): string[] => { query = query.toLowerCase(); - let result: string[] = [...this._ordering]; let words: string[] = []; - let quoteSymbol = ``; let queryStart = -1; let lastQuote = -1; @@ -75,174 +323,144 @@ export class Search { } } words.push(query.substring(queryStart, end).replace(/['"]/g, "")); - console.log("pushed", words); queryStart = -1; } } } + return words; + } - query = ""; - for (let word of words) { + parseTokens = (tokens: string[]): [string[], Query[]] => { + let queries: Query[] = []; + let searchTerms: string[] = []; + + for (let word of tokens) { + // 1. Normal search text, no filters or anything if (!word.includes(":")) { - let cachedResult = [...result]; - for (let id of cachedResult) { - const u = this._items[id]; - if (!u.matchesSearch(word)) { - result.splice(result.indexOf(id), 1); - } - } + searchTerms.push(word); 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]]; - if (queryFormat.bool) { - let isBool = false; - let boolState = false; - if (split[1] == "true" || split[1] == "yes" || split[1] == "t" || split[1] == "y") { - isBool = true; - boolState = true; - } else if (split[1] == "false" || split[1] == "no" || split[1] == "f" || split[1] == "n") { - isBool = true; - boolState = false; - } - if (isBool) { - const filterCard = document.createElement("span"); - filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter"); - filterCard.classList.add("button", "~" + (boolState ? "positive" : "critical"), "@high", "center", "mx-2", "h-full"); - filterCard.innerHTML = ` - ${queryFormat.name} - - `; + let q: Query | null = null; - filterCard.addEventListener("click", () => { + if (queryFormat.bool) { + let [boolState, isBool] = BoolQuery.paramsFromString(split[1]); + if (isBool) { + 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, ""); } this._c.search.oninput((null as Event)); - }) - - this._c.filterArea.appendChild(filterCard); - - // 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 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 (!((value && boolState) || (!value && !boolState))) { - // console.log("not matching, result is", result); - result.splice(result.indexOf(id), 1); - } - } - continue + }; + queries.push(q); + continue; } } if (queryFormat.string) { - const filterCard = document.createElement("span"); - filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter"); - filterCard.classList.add("button", "~neutral", "@low", "center", "mx-2", "h-full"); - filterCard.innerHTML = ` - ${queryFormat.name}: "${split[1]}" - `; + q = new StringQuery(queryFormat, split[1]); - filterCard.addEventListener("click", () => { + q.onclick = () => { for (let quote of [`"`, `'`, ``]) { let regex = new RegExp(split[0] + ":" + quote + split[1] + quote, "ig"); this._c.search.value = this._c.search.value.replace(regex, ""); } this._c.search.oninput((null as Event)); - }) - - this._c.filterArea.appendChild(filterCard); - - let cachedResult = [...result]; - for (let id of cachedResult) { - const u = this._items[id]; - const value = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u).toLowerCase(); - if (!(value.includes(split[1]))) { - result.splice(result.indexOf(id), 1); - } } + queries.push(q); continue; } if (queryFormat.date) { - // -1 = Before, 0 = On, 1 = After, 2 = No symbol, assume 0 - let compareType = (split[1][0] == ">") ? 1 : ((split[1][0] == "<") ? -1 : ((split[1][0] == "=") ? 0 : 2)); - let unmodifiedValue = split[1]; - if (compareType != 2) { - split[1] = split[1].substring(1); - } - if (compareType == 2) compareType = 0; - - let attempt: { year?: number, month?: number, day?: number, hour?: number, minute?: number } = dateParser.attempt(split[1]); - // Month in Date objects is 0-based, so make our parsed date that way too - if ("month" in attempt) attempt.month -= 1; - - let date: Date = (Date as any).fromString(split[1]) as Date; - console.log("Read", attempt, "and", date); - if ("invalid" in (date as any)) continue; - - const filterCard = document.createElement("span"); - filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter"); - filterCard.classList.add("button", "~neutral", "@low", "center", "m-2", "h-full"); - filterCard.innerHTML = ` - ${queryFormat.name}: ${(compareType == 1) ? window.lang.strings("after")+" " : ((compareType == -1) ? window.lang.strings("before")+" " : "")}${split[1]} - `; + let [parsedDate, op, isDate] = DateQuery.paramsFromString(split[1]); + if (!isDate) continue; + q = new DateQuery(queryFormat, op, parsedDate); - filterCard.addEventListener("click", () => { + q.onclick = () => { for (let quote of [`"`, `'`, ``]) { - let regex = new RegExp(split[0] + ":" + quote + unmodifiedValue + quote, "ig"); + let regex = new RegExp(split[0] + ":" + quote + split[1] + quote, "ig"); this._c.search.value = this._c.search.value.replace(regex, ""); } this._c.search.oninput((null as Event)); - }) - - this._c.filterArea.appendChild(filterCard); + } + queries.push(q); + continue; + } + // if (q != null) queries.push(q); + } + return [searchTerms, queries]; + } + + // Returns a list of identifiers (used as keys in items, values in ordering). + searchParsed = (searchTerms: string[], queries: Query[]): string[] => { + let result: string[] = [...this._ordering]; + // If we didn't care about rendering the query cards, we could run this to (maybe) return early. + // if (this.inServerSearch) { + // let hasLocalOnlyQueries = false; + // for (const q of queries) { + // if (q.localOnly) { + // hasLocalOnlyQueries = true; + // break; + // } + // } + // } + // Normal searches can be evaluated by the server, so skip this if we've already ran one. + if (!this.inServerSearch) { + for (let term of searchTerms) { let cachedResult = [...result]; for (let id of cachedResult) { - const u = this._items[id]; - const unixValue = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u); + const u = this.items[id]; + if (!u.matchesSearch(term)) { + result.splice(result.indexOf(id), 1); + } + } + } + } + + for (let q of queries) { + this._c.filterArea.appendChild(q.asElement()); + // Skip if this query has already been performed by the server. + if (this.inServerSearch && !(q.localOnly)) continue; + + let cachedResult = [...result]; + if (q.type == "bool") { + for (let id of cachedResult) { + const u = this.items[id]; + // Remove from result if not matching query + if (!q.compareItem(u)) { + // console.log("not matching, result is", result); + result.splice(result.indexOf(id), 1); + } + } + } else if (q.type == "string") { + for (let id of cachedResult) { + const u = this.items[id]; + // We want to compare case-insensitively, so we get value, lower-case it then compare, + // rather than doing both with compareItem. + const value = q.getValueFromItem(u).toLowerCase(); + if (!q.compare(value)) { + result.splice(result.indexOf(id), 1); + } + } + } else if (q.type == "date") { + for (let id of cachedResult) { + const u = this.items[id]; + // Getter here returns a unix timestamp rather than a date, so we can't use compareItem. + const unixValue = q.getValueFromItem(u); if (unixValue == 0) { result.splice(result.indexOf(id), 1); continue; } let value = new Date(unixValue*1000); - - const getterPairs: [string, () => number][] = [["year", Date.prototype.getFullYear], ["month", Date.prototype.getMonth], ["day", Date.prototype.getDate], ["hour", Date.prototype.getHours], ["minute", Date.prototype.getMinutes]]; - // When doing > or <