Compare commits

...

30 Commits

Author SHA1 Message Date
Harvey Tindall
1d7d82b793 search: fix server-side date behaviour
OffsetMinutesFromUTC was being passed incorrectly by the web app
(getTimezoneOffset if UTC - Timezone, we wanted Timezone - UTC), now
fixed. This value is now used if given in comparisons. Times are
truncated to minute-deep precision, and Any date comparison ignores
empty date fields (i.e. a unix timestamp being 0 or a time.Time.IsZero()
== true).
2025-05-27 14:57:56 +01:00
Harvey Tindall
b40abafb95 list: cleanup, include offset in DateAttempt
included UTC offset in minutes in DateAttempt, will be used shortly.
Also moved this stuff (ParsedDate, DateAttempt) to the common d.ts, and
the method for parsing from a string (now parseDateString) to common.
Also pre-emptively load the user cache when the admin page loads.
2025-05-27 14:29:09 +01:00
Harvey Tindall
18f8921eba list: add back default sort to _load
accidentally deleted it when merging reload and loadMore.
2025-05-27 11:00:58 +01:00
Harvey Tindall
285215cf4b Merge branch 'main' into user-pagination 2025-05-27 10:41:22 +01:00
Harvey Tindall
fe4097a724 email: remove ssltls mention 2025-05-27 10:27:35 +01:00
Harvey Tindall
364b010ceb list: refactor loading methods 2025-05-27 10:27:02 +01:00
Harvey Tindall
37bdf50bb0 Merge branch 'user-pagination' of github.com:hrfee/jfa-go into user-pagination 2025-05-26 21:52:45 +01:00
Harvey Tindall
70e35b8bd7 list: fix activities sort, css improvements
fixed activities sorting.
less oddities from me not knowing how to use flex boxes. Also kinda
standardised a button-inside-input thing, so added a .gap-<n> >
.inside-input selector which works on gap-1 and gap-2.
2025-05-26 21:52:31 +01:00
Harvey Tindall
2657e74803 search: fix "search all" button disabling logic, more
just a few more general fixes. Also changed the "Search all" button to
say "Search/sort all".
2025-05-26 21:52:31 +01:00
Harvey Tindall
372514709d search: add localOnly to web app queries, fix string+bool queries
localOnly: true in a queryType means it won't be sent to the server, but
will be evaluated by the web app on the returned search results.
2025-05-26 21:52:31 +01:00
Harvey Tindall
c922dc5b50 search: fix server-side dates, add mentionedUsers, referrer, time
QueryDTO.Value being classed as "any" meant DateAttempts would be
unmarshaled as map[string]any, so a custom UnmarshalJSON checks the data
type and unmarshals into a DateAttempt if needed. mentionedUers,
referrer and time matching implemented for activity search. Also, fixed
multi-class queries (e.g. date -and- bool for last-active).
2025-05-26 21:52:31 +01:00
Harvey Tindall
6fff8a887e activity: basic query support
only supports queries of fields actually in Activity. The web UI only
directly queries two of these, ID and Time (mistakenly referneced as date in
the web ui previously). Later commits will come up with creative ways of
dealing with all the other query types.
2025-05-26 21:52:31 +01:00
Harvey Tindall
0a7093a3b4 activities: fix updateExistingElements, rename
forgot to wipe this._ordering, so search was attempting to run on
non-existent items. Also renamed the two functions to appendNewItems and
replaceWithNewItems, this makes more sense to me.
2025-05-26 21:52:31 +01:00
Harvey Tindall
d8fe593323 list: fix RecordCounter, inf scroll in search, activities 2025-05-26 21:52:31 +01:00
Harvey Tindall
4dcec4b9c7 accounts: fix infinite scroll over-loading, use scrollend+polyfill
calculation for number of rows to be drawn was wrong, fixed now. To
compensate for overshooting with fast scrolling, speed is calculated
using previous scrollY in rows/scroll, and used to render more rows.
Also, the "scrollend" event is used to load more at the end of a scroll
always. Since this isn't available on safari/webkit(2gtk), a polyfill
has been added.
2025-05-26 21:52:31 +01:00
Harvey Tindall
ac56ad1400 accounts: add credit for infinite scroll 2025-05-26 21:52:31 +01:00
Harvey Tindall
d09ee59a1a accounts: infinite scroll for performance
Found out the bottleneck when ~2000 or more elements are loaded isn't
the search or sort or anything, but the DOM. An infinite scroll
implementation is added, where elements are added to the DOM as you
scroll. May still be a little buggy, and can't yet cope with screen
resizes. Also, the "shown" indicator is broken.
2025-05-26 21:52:31 +01:00
Harvey Tindall
3299398806 config: add user cache async/sync timeout options
previously were constants in usercache.go, now app.userCache is
instantiated in main.go with NewUserCache(time.Duration, time.Duration).
2025-05-26 21:52:31 +01:00
Harvey Tindall
b53120f271 usercache: cleanup, also elsewhere
removed some old FIXMEs and documented usercache nicely for once,
renaming some things too.
2025-05-26 21:52:31 +01:00
Harvey Tindall
1dfe13951f activities: fix slow load w/ lots of users
Added ID/Name indexing to mediabrowser, and cleaned it up a little bit.
Was taking ~2s with 5000 users and firing tons of requests at Jellyfin,
now take ~160ms from cold boot, ~1ms with cache.
2025-05-26 21:52:31 +01:00
Harvey Tindall
732ce1bc57 search: more server-search refinement
fixed bugs, added extra text on "no results found" to suggest server
searching, and conditionally disable the button based on search content
and current sort. Activities page still broken. Also fixed up cache
generation, only one should ever run now, as should sorting. Two time
thresholds exist, one to trigger a re-sync but do it in the background
(i.e. send the old one to the requester), and one to re-sync and wait
for it.
2025-05-26 21:52:31 +01:00
Harvey Tindall
94e076401e accounts: pagination, server-side search
Pagination fully factored out, and both Activities and Accounts now use
a PaginatedList superclass. Server-side search is done by pressing enter
after typing a search, or by pressing the search button. Works on
accounts, soon will on activities if it doesn't already.
2025-05-26 21:52:31 +01:00
Harvey Tindall
fb83094532 search: factor out date and bool comparison 2025-05-26 21:52:31 +01:00
Harvey Tindall
dec5197bfd usercache: we'll do it ourselves
we don't need expr or anything like that, cmp.Less and vim macros exist.
2025-05-26 21:52:31 +01:00
Harvey Tindall
ebff016b5d accounts: add "record count", start searchable user cache
RecordCounter class created from that in activityList, and put in
accountsList. PageCount-type route standardized and made for /users
(/users/count). Created userCache, which regularly generates the
respUser list returned by /users. Added a currently dumb POST /users for
searching/pagination, GET /users is now just for getting -all- users.
go-getted expr, an expression language that seems like it'll be useful
for evaluating local searches. We don't store this data in the badger
    DB, so we can't use the nice query form provided by badgerhold.
2025-05-26 21:52:31 +01:00
Harvey Tindall
da0dc7f1c0 email: Allow no SMTP encryption
added a "none" option.
2025-05-26 21:44:38 +01:00
Harvey Tindall
f6044578c0 list: fix activities sort, css improvements
fixed activities sorting.
less oddities from me not knowing how to use flex boxes. Also kinda
standardised a button-inside-input thing, so added a .gap-<n> >
.inside-input selector which works on gap-1 and gap-2.
2025-05-26 21:42:37 +01:00
Harvey Tindall
699cbee240 search: fix "search all" button disabling logic, more
just a few more general fixes. Also changed the "Search all" button to
say "Search/sort all".
2025-05-26 17:28:09 +01:00
Harvey Tindall
ef253de56b search: add localOnly to web app queries, fix string+bool queries
localOnly: true in a queryType means it won't be sent to the server, but
will be evaluated by the web app on the returned search results.
2025-05-26 16:06:41 +01:00
Harvey Tindall
9715f90a48 search: fix server-side dates, add mentionedUsers, referrer, time
QueryDTO.Value being classed as "any" meant DateAttempts would be
unmarshaled as map[string]any, so a custom UnmarshalJSON checks the data
type and unmarshals into a DateAttempt if needed. mentionedUers,
referrer and time matching implemented for activity search. Also, fixed
multi-class queries (e.g. date -and- bool for last-active).
2025-05-26 15:09:40 +01:00
18 changed files with 631 additions and 332 deletions

View File

@@ -3,7 +3,9 @@ package main
import (
"fmt"
"strings"
"time"
"github.com/hrfee/mediabrowser"
"github.com/timshannon/badgerhold/v4"
)
@@ -41,7 +43,6 @@ func activityDTONameToField(field string) string {
return "IP"
}
return "unknown"
// Only these query types actually search the ActivityDTO data.
}
func activityTypeGetterNameToType(getter string) ActivityType {
@@ -70,17 +71,22 @@ func activityTypeGetterNameToType(getter string) ActivityType {
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 {
if query == nil {
query = &badgerhold.Query{}
}
// Special case for activity type:
// In the app, there isn't an "activity:<fieldname>" 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 := query.And("Type")
criterion := andField(query, "Type")
if q.Operator != EqualOperator {
panic(fmt.Errorf("impossible operator for activity type: %v", q.Operator))
}
@@ -93,12 +99,13 @@ func (q QueryDTO) AsDBQuery(query *badgerhold.Query) *badgerhold.Query {
}
fieldName := activityDTONameToField(q.Field)
if fieldName == "unknown" {
panic("FIXME: Support all the weird queries of the web UI!")
// 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 := query.And(fieldName)
criterion := andField(query, fieldName)
// FIXME: Deal with dates like we do in usercache.go
switch q.Operator {
case LesserOperator:
query = criterion.Lt(q.Value)
@@ -112,12 +119,13 @@ func (q QueryDTO) AsDBQuery(query *badgerhold.Query) *badgerhold.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 {
subQuery := &badgerhold.Query{}
var baseQuery *badgerhold.Query = nil
// I don't believe you can just do Where("*"), so instead run for each field.
for _, fieldName := range []string{"ID", "Type", "UserID", "Username", "SourceType", "Source", "SourceUsername", "InviteCode", "Value", "IP"} {
// 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
subQuery = subQuery.Or(criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) {
f := criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) {
field := ra.Field()
// _, ok := field.(string)
// if !ok {
@@ -130,8 +138,118 @@ func ActivityMatchesSearchAsDBBaseQuery(terms []string) *badgerhold.Query {
}
}
return false, nil
}))
})
if baseQuery == nil {
baseQuery = f
} else {
baseQuery = baseQuery.Or(f)
}
}
return subQuery
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
}

View File

@@ -106,11 +106,19 @@ func (app *appContext) GetActivities(gc *gin.Context) {
if len(req.SearchTerms) != 0 {
query = ActivityMatchesSearchAsDBBaseQuery(req.SearchTerms)
} else {
query = &badgerhold.Query{}
query = nil
}
for _, q := range req.Queries {
query = q.AsDBQuery(query)
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)
@@ -132,28 +140,21 @@ func (app *appContext) GetActivities(gc *gin.Context) {
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
}
}
}

View File

@@ -805,6 +805,7 @@ sections:
options:
- ["ssl_tls", "SSL/TLS"]
- ["starttls", "STARTTLS"]
- ["none", "None (only use locally!)"]
value: starttls
description: Your email provider should provide different ports for each encryption
method. Generally 465 for ssl_tls, 587 for starttls.

View File

@@ -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));
}

View File

@@ -81,9 +81,14 @@ func NewEmailer(app *appContext) *Emailer {
}
method := app.config.Section("email").Key("method").String()
if method == "smtp" {
sslTLS := false
if app.config.Section("smtp").Key("encryption").String() == "ssl_tls" {
sslTLS = true
enc := sMail.EncryptionSTARTTLS
switch app.config.Section("smtp").Key("encryption").String() {
case "ssl_tls":
enc = sMail.EncryptionSSLTLS
case "starttls":
enc = sMail.EncryptionSTARTTLS
case "none":
enc = sMail.EncryptionNone
}
username := app.config.Section("smtp").Key("username").MustString("")
password := app.config.Section("smtp").Key("password").String()
@@ -95,7 +100,7 @@ func NewEmailer(app *appContext) *Emailer {
proxyConf = &app.proxyConfig
}
authType := sMail.AuthType(app.config.Section("smtp").Key("auth_type").MustInt(4))
err := emailer.NewSMTP(app.config.Section("smtp").Key("server").String(), app.config.Section("smtp").Key("port").MustInt(465), username, password, sslTLS, app.config.Section("smtp").Key("ssl_cert").MustString(""), app.config.Section("smtp").Key("hello_hostname").String(), app.config.Section("smtp").Key("cert_validation").MustBool(true), authType, proxyConf)
err := emailer.NewSMTP(app.config.Section("smtp").Key("server").String(), app.config.Section("smtp").Key("port").MustInt(465), username, password, enc, app.config.Section("smtp").Key("ssl_cert").MustString(""), app.config.Section("smtp").Key("hello_hostname").String(), app.config.Section("smtp").Key("cert_validation").MustBool(true), authType, proxyConf)
if err != nil {
app.err.Printf(lm.FailedInitSMTP, err)
}
@@ -121,14 +126,10 @@ type SMTP struct {
}
// NewSMTP returns an SMTP emailClient.
func (emailer *Emailer) NewSMTP(server string, port int, username, password string, sslTLS bool, certPath string, helloHostname string, validateCertificate bool, authType sMail.AuthType, proxy *easyproxy.ProxyConfig) (err error) {
func (emailer *Emailer) NewSMTP(server string, port int, username, password string, encryption sMail.Encryption, certPath string, helloHostname string, validateCertificate bool, authType sMail.AuthType, proxy *easyproxy.ProxyConfig) (err error) {
sender := &SMTP{}
sender.Client = sMail.NewSMTPClient()
if sslTLS {
sender.Client.Encryption = sMail.EncryptionSSLTLS
} else {
sender.Client.Encryption = sMail.EncryptionSTARTTLS
}
sender.Client.Encryption = encryption
if username != "" || password != "" {
sender.Client.Authentication = authType
sender.Client.Username = username

View File

@@ -715,7 +715,7 @@
</div>
</div>
<div id="tab-accounts" class="flex flex-col gap-4 unfocused">
<div class="card @low dark:~d_neutral accounts mb-4 overflow-visible">
<div class="card @low dark:~d_neutral accounts mb-4 overflow-visible flex flex-col gap-2">
<div id="accounts-filter-dropdown" class="dropdown manual z-10 w-full">
<div class="flex flex-col md:flex-row align-middle gap-2">
<div class="flex flex-row align-middle justify-between md:justify-normal">
@@ -724,7 +724,7 @@
</div>
<div class="flex flex-row align-middle w-full gap-2">
<input type="search" class="field ~neutral @low input search mr-2" id="accounts-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none accounts-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<span class="button ~neutral @low center inside-input rounded-s-none accounts-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<div class="tooltip left">
<button class="button ~info @low center h-full accounts-search-server flex flex-row gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
<i class="ri-search-line"></i>
@@ -741,14 +741,16 @@
</div>
</div>
</div>
<div class="supra py-1 sm hidden" id="accounts-search-options-header">{{ .strings.searchOptions }}</div>
<div class="row -mx-2 mb-2">
<button type="button" class="button ~neutral @low center mx-2 hidden"><span id="accounts-sort-by-field"></span> <i class="ri-close-line ml-2 text-2xl"></i></button>
<span id="accounts-filter-area"></span>
<div class="flex flex-row justify-between">
<div class="supra sm hidden" id="accounts-search-options-header">{{ .strings.searchOptions }}</div>
<div class="supra sm flex flex-row gap-2" id="accounts-record-counter"></div>
</div>
<div class="supra sm flex flex-row gap-2" id="accounts-record-counter"></div>
<div class="supra pt-1 pb-2 sm">{{ .strings.actions }}</div>
<div class="flex flex-row flex-wrap gap-3 mb-4">
<div class="flex flex-row gap-2 flex-wrap">
<div id="accounts-sort-by-field"></div>
<span id="accounts-filter-area" class="flex flex-row gap-2 flex-wrap"></span>
</div>
<div class="supra sm">{{ .strings.actions }}</div>
<div class="flex flex-row flex-wrap gap-3">
<span class="button ~neutral @low center " id="accounts-add-user">{{ .quantityStrings.addUser.Singular }}</span>
<div id="accounts-announce-dropdown" class="dropdown pb-0i " tabindex="0">
<span class="w-full button ~info @low center items-baseline" id="accounts-announce">{{ .strings.announce }}</span>
@@ -783,7 +785,7 @@
<span class="button ~info @low center unfocused " id="accounts-send-pwr">{{ .strings.sendPWR }}</span>
<span class="button ~critical @low center " id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span>
</div>
<div class="card @low accounts-header table-responsive mt-2">
<div class="card @low accounts-header table-responsive">
<table class="table text-base leading-5">
<thead>
<tr>
@@ -817,13 +819,13 @@
<span class="text-2xl font-medium italic text-center">{{ .strings.noResultsFound }}</span>
<span class="text-sm font-light italic unfocused text-center" id="accounts-no-local-results">{{ .strings.noResultsFoundLocally }}</span>
<div class="flex flex-row">
<button class="button ~neutral @low accounts-search-clear">
<span class="mr-2">{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
<button class="button ~neutral @low accounts-search-clear flex flex-row gap-2">
<span>{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
</button>
</div>
</div>
</div>
<div class="flex flex-row gap-2 m-2 justify-center">
<div class="flex flex-row gap-2 justify-center">
<button class="button ~neutral @low" id="accounts-load-more">{{ .strings.loadMore }}</button>
<button class="button ~neutral @low" id="accounts-load-all">{{ .strings.loadAll }}</button>
<button class="button ~info @low center accounts-search-server flex flex-row gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
@@ -835,7 +837,7 @@
</div>
</div>
<div id="tab-activity" class="flex flex-col gap-4 unfocused">
<div class="card @low dark:~d_neutral activity mb-4 overflow-visible">
<div class="card @low dark:~d_neutral activity mb-4 overflow-visible flex flex-col gap-2">
<div id="activity-filter-dropdown" class="dropdown manual z-10 w-full" tabindex="0">
<div class="flex flex-col md:flex-row align-middle gap-2">
<div class="flex flex-row align-middle justify-between md:justify-normal">
@@ -847,9 +849,12 @@
</div>
<div class="flex flex-row align-middle w-full gap-2">
<input type="search" class="field ~neutral @low input search mr-2" id="activity-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none activity-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<span class="button ~neutral @low center inside-input rounded-s-none activity-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<div class="tooltip left">
<button class="button ~info @low center h-full activity-search-server" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}"><i class="ri-search-line"></i></button>
<button class="button ~info @low center h-full activity-search-server flex flex-row gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
<i class="ri-search-line"></i>
<span>{{ .strings.searchAll }}</span>
</button>
<span class="content sm">{{ .strings.searchAllRecords }}</span>
</div>
<button class="button ~info @low" id="activity-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
@@ -861,37 +866,34 @@
</div>
</div>
</div>
<div class="flex flex-row justify-between pt-3 pb-2">
<div class="flex flex-row justify-between">
<div class="supra sm hidden" id="activity-search-options-header">{{ .strings.searchOptions }}</div>
<div class="supra sm flex flex-row gap-2" id="activity-record-counter"></div>
</div>
<div class="row -mx-2 mb-2">
<button type="button" class="button ~neutral @low center mx-2 hidden"><span id="activity-sort-by-field"></span> <i class="ri-close-line ml-2 text-2xl"></i></button>
<span id="activity-filter-area"></span>
<div class="flex flex-row gap-2 flex-wrap">
<span id="activity-filter-area" class="flex flex-row gap-2 flex-wrap"></span>
</div>
<div class="my-2">
<div id="activity-card-list"></div>
<div id="activity-loader"></div>
<div class="unfocused h-[100%] my-3" id="activity-not-found">
<div class="flex flex-col gap-2 h-[100%] justify-center items-center">
<span class="text-2xl font-medium italic mb-3 text-center">{{ .strings.noResultsFound }}</span>
<span class="text-sm font-light italic unfocused text-center" id="activity-no-local-results">{{ .strings.noResultsFoundLocally }}</span>
<div class="flex flex-row">
<button class="button ~neutral @low activity-search-clear">
<span class="mr-2">{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
</button>
<button class="button ~neutral @low unfocused" id="activity-keep-searching">{{ .strings.keepSearching }}</button>
</div>
<div class="unfocused h-[100%]" id="activity-not-found">
<div class="flex flex-col gap-2 h-[100%] justify-center items-center">
<span class="text-2xl font-medium italic text-center">{{ .strings.noResultsFound }}</span>
<span class="text-sm font-light italic unfocused text-center" id="activity-no-local-results">{{ .strings.noResultsFoundLocally }}</span>
<div class="flex flex-row">
<button class="button ~neutral @low activity-search-clear flex flex-row gap-2">
<span>{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
</button>
<button class="button ~neutral @low unfocused" id="activity-keep-searching">{{ .strings.keepSearching }}</button>
</div>
</div>
<div class="flex flex-row gap-2 m-2 justify-center">
<button class="button ~neutral @low" id="activity-load-more">{{ .strings.loadMore }}</button>
<button class="button ~neutral @low" id="activity-load-all">{{ .strings.loadAll }}</button>
<button class="button ~info @low center activity-search-server flex flex-row gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
<i class="ri-search-line"></i>
<span>{{ .strings.searchAllRecords }}</span>
</button>
</div>
</div>
<div id="activity-card-list"></div>
<div id="activity-loader"></div>
<div class="flex flex-row gap-2 justify-center">
<button class="button ~neutral @low" id="activity-load-more">{{ .strings.loadMore }}</button>
<button class="button ~neutral @low" id="activity-load-all">{{ .strings.loadAll }}</button>
<button class="button ~info @low center activity-search-server flex flex-row gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
<i class="ri-search-line"></i>
<span>{{ .strings.searchAllRecords }}</span>
</button>
</div>
</div>
</div>

View File

@@ -137,8 +137,8 @@
"filters": "Filters",
"clickToRemoveFilter": "Click to remove this filter.",
"clearSearch": "Clear search",
"searchAll": "Search all",
"searchAllRecords": "Search all records (on server)",
"searchAll": "Search/sort all",
"searchAllRecords": "Search/sort all records (on server)",
"actions": "Actions",
"searchOptions": "Search Options",
"matchText": "Match Text",

View File

@@ -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");

View File

@@ -7,8 +7,6 @@ import { SearchConfiguration, QueryType, SearchableItem, SearchableItemDataAttri
import { HiddenInputField } from "./ui"
import { PaginatedList } from "./list"
// FIXME: Find and define a threshold after which searches are no longer performed on input (or just in general by the browser).
declare var window: GlobalWindow;
const USER_DEFAULT_SORT_FIELD = "name";
@@ -976,9 +974,6 @@ export class accountsList extends PaginatedList {
});
this._populateNumbers();
// FIXME: Remove!
(window as any).acc = this;
let searchConfig: SearchConfiguration = {
filterArea: this._c.filterArea,
sortingByButton: this._sortingByButton,
@@ -1062,7 +1057,7 @@ export class accountsList extends PaginatedList {
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);
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")
@@ -1188,7 +1183,7 @@ export class accountsList extends PaginatedList {
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;
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);
}
@@ -1199,11 +1194,11 @@ export class accountsList extends PaginatedList {
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.parentElement.classList.add("hidden");
this._sortingByButton.classList.add("hidden");
this._search.showHideSearchOptionsHeader();
};
this._sortingByButton.parentElement.addEventListener("click", defaultSort);
this._sortingByButton.addEventListener("click", defaultSort);
document.addEventListener("header-click", (event: CustomEvent) => {
this._search.setOrdering(
@@ -1211,18 +1206,17 @@ export class accountsList extends PaginatedList {
event.detail,
this._columns[event.detail].ascending
);
this._sortingByButton.innerHTML = this._columns[event.detail].buttonContent;
this._sortingByButton.parentElement.classList.remove("hidden");
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.setServerSearchButtonsDisabled(
event.detail == this._c.defaultSortField && this._columns[event.detail].ascending == this._c.defaultSortAscending
);
}
this._search.inServerSearch = false;
this.autoSetServerSearchButtonsDisabled();
this._search.showHideSearchOptionsHeader();
});
@@ -1233,25 +1227,15 @@ export class accountsList extends PaginatedList {
this.registerURLListener();
}
reload = (callback?: () => void) => {
this._reload(
(req: XMLHttpRequest) => {
if (req.readyState != 4) return;
if (req.status != 200) return;
if (callback) callback();
}
);
reload = (callback?: (resp: paginatedDTO) => void) => {
this._reload(callback);
this.loadTemplates();
}
loadMore = (callback?: () => void, loadAll: boolean = false) => {
loadMore = (loadAll: boolean = false, callback?: () => void) => {
this._loadMore(
loadAll,
(req: XMLHttpRequest) => {
if (req.readyState != 4) return;
if (req.status != 200) return;
if (callback) callback();
}
callback
);
};
@@ -1437,7 +1421,7 @@ export class accountsList extends PaginatedList {
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']}"`);
@@ -1448,7 +1432,7 @@ export class accountsList extends PaginatedList {
window.notifications.customError("addUser", msg);
}
if (req.response["error"] as String) {
console.log(req.response["error"]);
console.error(req.response["error"]);
}
this.reload();
@@ -2082,7 +2066,7 @@ export class accountsList extends PaginatedList {
}
focusAccount = (userID: string) => {
console.log("focusing user", userID);
console.debug("focusing user", userID);
this._c.searchBox.value = `id:"${userID}"`;
this._search.onSearchBoxChange();
if (userID in this.users) this.users[userID].focus();
@@ -2116,14 +2100,16 @@ 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, name: string, getter: Getter) {
constructor(header: HTMLTableCellElement, name: string, getter: Getter) {
this._header = header;
this._name = name;
this._headerContent = this._header.textContent;
@@ -2134,10 +2120,8 @@ 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);
@@ -2151,6 +2135,15 @@ class Column {
this.hideIcon();
}
});
this._card = document.createElement("button");
this._card.classList.add("button", "~neutral", "@low", "center", "flex", "flex-row", "gap-1");
this._card.innerHTML = `
<i class="sorting-by-direction ri-arrow-up-s-line"></i>
<span class="font-bold">${window.lang.strings("sortingBy")}: </span><span>${this._headerContent}</span>
<i class="ri-close-line text-2xl"></i>
`;
this._cardSortingByIcon = this._card.querySelector(".sorting-by-direction");
}
hideIcon = () => {
@@ -2164,14 +2157,18 @@ class Column {
`;
}
// Returns the inner HTML to show in the "Sorting By" button.
get buttonContent() {
return `<i class="ri-arrow-${this.ascending ? "up" : "down"}-s-line mr-2"></i><span class="font-bold">` + window.lang.strings("sortingBy") + ": " + `</span>` + 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);

View File

@@ -50,7 +50,8 @@ const queries = (): { [field: string]: QueryType } => { return {
getter: "title",
bool: false,
string: true,
date: false
date: false,
localOnly: true
},
"user": {
name: window.lang.strings("usersMentioned"),
@@ -481,7 +482,6 @@ interface ActivitiesDTO extends paginatedDTO {
export class activityList extends PaginatedList {
protected _container: HTMLElement;
// protected _sortingByButton = document.getElementById("activity-sort-by-field") as HTMLButtonElement;
protected _sortDirection = document.getElementById("activity-sort-direction") as HTMLButtonElement;
protected _ascending: boolean;
@@ -533,11 +533,8 @@ export class activityList extends PaginatedList {
}
});
// FIXME: Remove!
(window as any).act = this;
this._container = document.getElementById("activity-card-list")
document.addEventListener("activity-reload", this.reload);
document.addEventListener("activity-reload", () => this.reload());
let searchConfig: SearchConfiguration = {
filterArea: this._c.filterArea,
@@ -564,29 +561,34 @@ export class activityList extends PaginatedList {
this._sortDirection.addEventListener("click", () => this.ascending = !this.ascending);
}
reload = () => {
this._reload();
reload = (callback?: (resp: paginatedDTO) => void) => {
this._reload(callback);
}
loadMore = (callback?: () => void, loadAll: boolean = false) => {
loadMore = (loadAll: boolean = false, callback?: () => void) => {
this._loadMore(
loadAll,
(req: XMLHttpRequest) => {
if (req.readyState != 4) return;
if (req.status != 200) return;
if (callback) callback();
}
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")} <i class="ri-arrow-${v ? "up" : "down"}-s-line ml-2"></i>`;
// FIXME?: We don't actually re-sort the list here, instead just use setOrdering to apply this.ascending before a reload.
// 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();
}
}
}

View File

@@ -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; }

View File

@@ -4,6 +4,10 @@ import "@af-utils/scrollend-polyfill";
declare var window: GlobalWindow;
export interface ListItem {
asElement: () => HTMLElement;
};
export class RecordCounter {
private _container: HTMLElement;
private _totalRecords: HTMLElement;
@@ -128,14 +132,13 @@ export abstract class PaginatedList {
if (v) {
this._c.loadAllButton.classList.add("unfocused");
this._c.loadMoreButton.textContent = window.lang.strings("noMoreResults");
this._search.setServerSearchButtonsDisabled(this._search.inServerSearch);
this._c.loadMoreButton.disabled = true;
} else {
this._c.loadMoreButton.textContent = window.lang.strings("loadMore");
this._c.loadMoreButton.disabled = false;
this._search.setServerSearchButtonsDisabled(false);
this._c.loadAllButton.classList.remove("unfocused");
}
this.autoSetServerSearchButtonsDisabled();
}
protected _previousVisibleItemCount = 0;
@@ -158,10 +161,10 @@ export abstract class PaginatedList {
this._counter = new RecordCounter(this._c.recordCounter);
this._hasLoaded = false;
this._c.loadMoreButton.onclick = () => this.loadMore(null, false);
this._c.loadMoreButton.onclick = () => this.loadMore(false);
this._c.loadAllButton.onclick = () => {
addLoader(this._c.loadAllButton, true);
this.loadMore(null, true);
this.loadMore(true);
};
/* this._keepSearchingButton.onclick = () => {
addLoader(this._keepSearchingButton, true);
@@ -171,17 +174,30 @@ export abstract class PaginatedList {
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");
if (this._search.sortField == this._c.defaultSortField && this._search.ascending == this._c.defaultSortAscending) {
this._search.setServerSearchButtonsDisabled(!this._search.inSearch)
} else {
this._search.setServerSearchButtonsDisabled(false)
}
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) {
@@ -190,7 +206,7 @@ export abstract class PaginatedList {
(this._visible.length == 0 && !this.lastPage) ||
loadAll
) {
this.loadMore(() => {}, loadAll);
this.loadMore(loadAll);
}
}
this._previousVisibleItemCount = this._visible.length;
@@ -200,12 +216,12 @@ export abstract class PaginatedList {
searchConfig.searchServer = (params: PaginatedReqDTO, newSearch: boolean) => {
this._searchParams = params;
if (newSearch) this.reload();
else this.loadMore(null, false);
else this.loadMore(false);
if (previousServerSearch) previousServerSearch(params, newSearch);
};
searchConfig.clearServerSearch = () => {
console.log("Clearing server search");
console.trace("Clearing server search");
this._page = 0;
this.reload();
}
@@ -274,7 +290,7 @@ export abstract class PaginatedList {
if (this._search.timeSearches) {
const totalTime = performance.now() - timer;
console.log(`setVisibility took ${totalTime}ms`);
console.debug(`setVisibility took ${totalTime}ms`);
}
}
@@ -298,27 +314,18 @@ export abstract class PaginatedList {
return bottomIdx;
}
// Removes all elements, and reloads the first page.
// FIXME: Share more code between reload and loadMore, and go over the logic, it's messy.
public abstract reload: () => void;
protected _reload = (
callback?: (req: XMLHttpRequest) => void
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();
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;
}
let params = this._search.inServerSearch ? this._searchParams : this.defaultParams();
params.limit = limit;
params.page = 0;
params.limit = itemLimit;
params.page = page;
if (params.sortByField == "") {
params.sortByField = this._c.defaultSortField;
params.ascending = this._c.defaultSortAscending;
@@ -328,100 +335,102 @@ export abstract class PaginatedList {
if (req.readyState != 4) return;
if (req.status != 200) {
if (this._c.pageLoadCallback) this._c.pageLoadCallback(req);
if (callback) callback(req);
if (failCallback) failCallback(req);
return;
}
this._hasLoaded = true;
// Allow refreshes every 15s
this._c.refreshButton.disabled = true;
setTimeout(() => this._c.refreshButton.disabled = false, 15000);
let resp = req.response as paginatedDTO;
if (pre) pre(resp);
this.lastPage = resp.last_page;
this._c.replaceWithNewItems(resp);
appendFunc(resp);
this._counter.loaded = this._search.ordering.length;
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 (post) post(resp);
if (this._c.pageLoadCallback) this._c.pageLoadCallback(req);
if (callback) callback(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: (callback: () => void, loadAll: boolean) => void;
protected _loadMore = (
loadAll: boolean = false,
callback?: (req: XMLHttpRequest) => void
) => {
this._lastLoad = Date.now();
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;
let params = this._search.inServerSearch ? this._searchParams : this.defaultParams();
params.limit = this._c.itemsPerPage;
params.page = this._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 (callback) callback(req);
return;
}
let resp = req.response as paginatedDTO;
// Check before setting this.lastPage so we have a chance to cancel the timeout.
if (resp.last_page) {
clearTimeout(timeout);
removeLoader(this._c.loadAllButton);
}
this.lastPage = resp.last_page;
this._c.appendNewItems(resp);
this._counter.loaded = this._search.ordering.length;
if (this._search.inSearch || loadAll) {
if (this.lastPage) {
loadAll = false;
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);
}
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 (this._c.pageLoadCallback) this._c.pageLoadCallback(req);
if (callback) callback(req);
}, true)
},
(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(cb, false);
this.loadMore(false, cb);
}
cb();
}
@@ -465,7 +474,7 @@ export abstract class PaginatedList {
const cb = () => {
if (this._visible.length < endIdx && !this.lastPage) {
// FIXME: This causes scroll-to-top when in search.
this.loadMore(cb, false)
this.loadMore(false, cb);
return;
}

View File

@@ -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);
}
});

View File

@@ -1,4 +1,5 @@
const dateParser = require("any-date-parser");
import { ListItem } from "./list";
import { parseDateString } from "./common";
declare var window: GlobalWindow;
@@ -28,6 +29,7 @@ export interface QueryType {
date: boolean;
dependsOnElement?: string; // Format for querySelector
show?: boolean;
localOnly?: boolean // Indicates can't be performed server-side.
}
export interface SearchConfiguration {
@@ -66,6 +68,7 @@ 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;
@@ -84,7 +87,8 @@ export abstract class Query {
public abstract compare(subjectValue: any): boolean;
asDTO(): QueryDTO {
asDTO(): QueryDTO | null {
if (this.localOnly) return null;
let out = {} as QueryDTO;
out.field = this._subject.getter;
out.operator = this._operator;
@@ -100,14 +104,17 @@ export abstract class Query {
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", "mx-2", "h-full");
this._card.classList.add("button", "~" + (this._value ? "positive" : "critical"), "@high", "center");
this._card.innerHTML = `
<span class="font-bold mr-2">${subject.name}</span>
<i class="text-2xl ri-${this._value? "checkbox" : "close"}-circle-fill"></i>
@@ -134,8 +141,9 @@ export class BoolQuery extends Query {
return ((subjectBool && this._value) || (!subjectBool && !this._value))
}
asDTO(): QueryDTO {
asDTO(): QueryDTO | null {
let out = super.asDTO();
if (out === null) return null;
out.class = "bool";
out.value = this._value;
return out;
@@ -146,8 +154,9 @@ 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", "mx-2", "h-full");
this._card.classList.add("button", "~neutral", "@low", "center");
this._card.innerHTML = `
<span class="font-bold mr-2">${subject.name}:</span> "${this._value}"
`;
@@ -159,28 +168,15 @@ export class StringQuery extends Query {
return subjectString.toLowerCase().includes(this._value);
}
asDTO(): QueryDTO {
asDTO(): QueryDTO | null {
let out = super.asDTO();
if (out === null) return null;
out.class = "string";
out.value = this._value;
return out;
}
}
export interface DateAttempt {
year?: number;
month?: number;
day?: number;
hour?: number;
minute?: number
}
export interface ParsedDate {
attempt: DateAttempt;
date: Date;
text: string;
};
const dateGetters: Map<string, () => number> = (() => {
let m = new Map<string, () => number>();
m.set("year", Date.prototype.getFullYear);
@@ -205,33 +201,25 @@ export class DateQuery extends Query {
constructor(subject: QueryType, operator: QueryOperator, value: ParsedDate) {
super(subject, operator);
this.type = "date";
this._value = value;
this._card.classList.add("button", "~neutral", "@low", "center", "m-2", "h-full");
this._card.classList.add("button", "~neutral", "@low", "center");
let dateText = QueryOperatorToDateText(operator);
this._card.innerHTML = `
<span class="font-bold mr-2">${subject.name}:</span> ${dateText != "" ? dateText+" " : ""}${value.text}
`;
}
public static paramsFromString(valueString: string): [ParsedDate, QueryOperator, boolean] {
// FIXME: Validate this!
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: ParsedDate = {
text: valueString,
// Used just to tell use what fields the user passed.
attempt: dateParser.attempt(valueString),
// note Date.fromString is also provided by dateParser.
date: (Date as any).fromString(valueString) as Date
};
// Month in Date objects is 0-based, so make our parsed date that way too
if ("month" in out.attempt) out.attempt.month -= 1;
let out = parseDateString(valueString);
let isValid = true;
if ("invalid" in (out.date as any)) { isValid = false; };
if (out.invalid) isValid = false;
return [out, op, isValid];
}
@@ -259,18 +247,17 @@ export class DateQuery extends Query {
return subjectDate > temp;
}
asDTO(): QueryDTO {
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 {
export interface SearchableItem extends ListItem {
matchesSearch: (query: string) => boolean;
// FIXME: SearchableItem should really be ListItem or something, this isn't for search!
asElement: () => HTMLElement;
}
export const SearchableItemDataAttribute = "data-search-item";
@@ -372,8 +359,11 @@ export class Search {
}
this._c.search.oninput((null as Event));
};
queries.push(q);
continue;
}
} else if (queryFormat.string) {
}
if (queryFormat.string) {
q = new StringQuery(queryFormat, split[1]);
q.onclick = () => {
@@ -383,7 +373,10 @@ export class Search {
}
this._c.search.oninput((null as Event));
}
} else if (queryFormat.date) {
queries.push(q);
continue;
}
if (queryFormat.date) {
let [parsedDate, op, isDate] = DateQuery.paramsFromString(split[1]);
if (!isDate) continue;
q = new DateQuery(queryFormat, op, parsedDate);
@@ -396,39 +389,48 @@ export class Search {
this._c.search.oninput((null as Event));
}
queries.push(q);
continue;
}
if (q != null) queries.push(q);
// if (q != null) queries.push(q);
}
return [searchTerms, queries];
}
// Returns a list of identifiers (used as keys in items, values in ordering).
search = (query: string): string[] => {
let timer = this.timeSearches ? performance.now() : null;
this._c.filterArea.textContent = "";
searchParsed = (searchTerms: string[], queries: Query[]): string[] => {
let result: string[] = [...this._ordering];
// If we're in a server search already, the results are already correct.
if (this.inServerSearch) return result;
// 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;
// }
// }
// }
const [searchTerms, queries] = this.parseTokens(Search.tokenizeSearch(query));
query = "";
for (let term of searchTerms) {
let cachedResult = [...result];
for (let id of cachedResult) {
const u = this.items[id];
if (!u.matchesSearch(term)) {
result.splice(result.indexOf(id), 1);
// 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];
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.subject.bool) {
if (q.type == "bool") {
for (let id of cachedResult) {
const u = this.items[id];
// Remove from result if not matching query
@@ -437,7 +439,7 @@ export class Search {
result.splice(result.indexOf(id), 1);
}
}
} else if (q.subject.string) {
} 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,
@@ -447,7 +449,7 @@ export class Search {
result.splice(result.indexOf(id), 1);
}
}
} else if(q.subject.date) {
} 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.
@@ -464,20 +466,36 @@ export class Search {
}
}
}
return result;
}
// Returns a list of identifiers (used as keys in items, values in ordering).
search = (query: string): string[] => {
let timer = this.timeSearches ? performance.now() : null;
this._c.filterArea.textContent = "";
const [searchTerms, queries] = this.parseTokens(Search.tokenizeSearch(query));
let result = this.searchParsed(searchTerms, queries);
this._queries = queries;
this._searchTerms = searchTerms;
if (this.timeSearches) {
const totalTime = performance.now() - timer;
console.log(`Search took ${totalTime}ms`);
console.debug(`Search took ${totalTime}ms`);
}
return result;
}
// postServerSearch performs local-only queries after a server search if necessary.
postServerSearch = () => {
this.searchParsed(this._searchTerms, this._queries);
};
showHideSearchOptionsHeader = () => {
let sortingBy = false;
if (this._c.sortingByButton) sortingBy = !(this._c.sortingByButton.parentElement.classList.contains("hidden"));
if (this._c.sortingByButton) sortingBy = !(this._c.sortingByButton.classList.contains("hidden"));
const hasFilters = this._c.filterArea.textContent != "";
if (sortingBy || hasFilters) {
this._c.searchOptionsHeader.classList.remove("hidden");
@@ -556,7 +574,6 @@ export class Search {
this._c.search.oninput(null as any);
};
// FIXME: Make XQuery classes less specifically for in-progress searches, and include this code for making info button things.
generateFilterList = () => {
// Generate filter buttons
for (let queryName of Object.keys(this._c.queries)) {
@@ -634,18 +651,27 @@ export class Search {
onServerSearch = () => {
const newServerSearch = !this.inServerSearch;
this.inServerSearch = true;
this.searchServer(newServerSearch);
}
searchServer = (newServerSearch: boolean) => {
this._c.searchServer(this.serverSearchParams(this._searchTerms, this._queries), newServerSearch);
}
serverSearchParams = (searchTerms: string[], queries: Query[]): PaginatedReqDTO => {
let req: ServerSearchReqDTO = {
searchTerms: searchTerms,
queries: queries.map((q: Query) => q.asDTO()),
queries: [], // queries.map((q: Query) => q.asDTO()) won't work as localOnly queries return null
limit: -1,
page: 0,
sortByField: this.sortField,
ascending: this.ascending
};
for (const q of queries) {
const dto = q.asDTO();
if (dto !== null) req.queries.push(dto);
}
return req;
}

View File

@@ -161,5 +161,21 @@ interface PaginatedReqDTO {
ascending: boolean;
};
interface DateAttempt {
year?: number;
month?: number;
day?: number;
hour?: number;
minute?: number;
offsetMinutesFromUTC?: number;
}
interface ParsedDate {
attempt: DateAttempt;
date: Date;
text: string;
invalid?: boolean;
};
declare var config: Object;
declare var modifiedConfig: Object;

View File

@@ -749,7 +749,7 @@ const setCardOrder = (messageCard: HTMLElement) => {
// addValue += side.length;
}
console.log("Shortest order:", minHeightPerm);
console.debug("Shortest order:", minHeightPerm);
};
const login = new Login(window.modals.login as Modal, "/my/", "opaque");

View File

@@ -2,6 +2,7 @@ package main
import (
"cmp"
"encoding/json"
"fmt"
"slices"
"strings"
@@ -234,20 +235,44 @@ const (
}*/
type DateAttempt struct {
Year *int `json:"year,omitempty"`
Month *int `json:"month,omitempty"`
Day *int `json:"day,omitempty"`
Hour *int `json:"hour,omitempty"`
Minute *int `json:"minute,omitempty"`
Year *int `json:"year,omitempty"`
Month *int `json:"month,omitempty"`
Day *int `json:"day,omitempty"`
Hour *int `json:"hour,omitempty"`
Minute *int `json:"minute,omitempty"`
OffsetMinutesFromUTC *int `json:"offsetMinutesFromUTC,omitempty"`
}
// Compares a Unix timestamp.
// CompareWithOperator roughly compares a time.Time to a DateAttempt according to the given operator.
// **Considers zero-dates as invalid!** (i.e. any comparison to a subject.IsZero() will be false).
func (d DateAttempt) CompareWithOperator(subject time.Time, operator CompareResult) bool {
if subject.IsZero() {
return false
}
return d.Compare(subject) == int(operator)
}
// CompareUnixWithOperator roughly compares a unix timestamp to a DateAttempt according to the given operator.
// **Considers zero-dates as invalid!** (i.e. any comparison to a time.Unix(subject, 0).IsZero() or (subject == 0) will be false).
func (d DateAttempt) CompareUnixWithOperator(subject int64, operator CompareResult) bool {
if subject == 0 {
return false
}
subjectTime := time.Unix(subject, 0)
if subjectTime.IsZero() {
return false
}
return d.Compare(subjectTime) == int(operator)
}
// Compare roughly compares a time.Time to a DateAttempt.
// We want to compare only the fields given in DateAttempt,
// so we copy subjectDate and apply on those fields from this._value.
func (d DateAttempt) Compare(subject int64) int {
subjectTime := time.Unix(subject, 0)
yy, mo, dd := subjectTime.Date()
hh, mm, _ := subjectTime.Clock()
func (d DateAttempt) Compare(subject time.Time) int {
// Remove anything more precise than a second
subject = subject.Truncate(time.Minute)
yy, mo, dd := subject.Date()
hh, mm, _ := subject.Clock()
if d.Year != nil {
yy = *d.Year
}
@@ -264,7 +289,22 @@ func (d DateAttempt) Compare(subject int64) int {
if d.Minute != nil {
mm = *d.Minute
}
return subjectTime.Compare(time.Date(yy, mo, dd, hh, mm, 0, 0, nil))
location := time.UTC
if d.OffsetMinutesFromUTC != nil {
location = time.FixedZone("", 60*(*d.OffsetMinutesFromUTC))
}
// FIXME: Transmit timezone in request maybe?
daAsTime := time.Date(yy, mo, dd, hh, mm, 0, 0, location)
comp := subject.Compare(daAsTime)
return comp
}
// CompareUnix roughly compares a unix timestamp to a DateAttempt.
func (d DateAttempt) CompareUnix(subject int64) int {
return d.Compare(time.Unix(subject, 0))
}
// Filter returns true if a specific field in the passed respUser matches some internally defined value.
@@ -300,16 +340,38 @@ func (q QueryDTO) AsFilter() Filter {
return cmp.Compare(bool2int(a.NotifyThroughEmail), bool2int(q.Value.(bool))) == int(operator)
}
case "last_active":
return func(a *respUser) bool {
return q.Value.(DateAttempt).Compare(a.LastActive) == int(operator)
switch q.Class {
case DateQuery:
return func(a *respUser) bool {
return q.Value.(DateAttempt).CompareUnixWithOperator(a.LastActive, operator)
}
case BoolQuery:
return func(a *respUser) bool {
val := a.LastActive != 0
if q.Value.(bool) == false {
val = !val
}
return val
}
}
case "admin":
return func(a *respUser) bool {
return cmp.Compare(bool2int(a.Admin), bool2int(q.Value.(bool))) == int(operator)
}
case "expiry":
return func(a *respUser) bool {
return q.Value.(DateAttempt).Compare(a.Expiry) == int(operator)
switch q.Class {
case DateQuery:
return func(a *respUser) bool {
return q.Value.(DateAttempt).CompareUnixWithOperator(a.Expiry, operator)
}
case BoolQuery:
return func(a *respUser) bool {
val := a.Expiry != 0
if q.Value.(bool) == false {
val = !val
}
return val
}
}
case "disabled":
return func(a *respUser) bool {
@@ -397,6 +459,30 @@ type QueryDTO struct {
Value any `json:"value"`
}
// UnmarshalJSON allows unmarshaling QueryDTO.Value into a DateAttempt type, rather than just a map.
func (q *QueryDTO) UnmarshalJSON(data []byte) error {
type _QueryDTO QueryDTO
var temp _QueryDTO
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
*q = QueryDTO(temp)
switch q.Value.(type) {
case string:
case bool:
return nil
case map[string]any:
var do struct {
Value DateAttempt `json:"value"`
}
if err := json.Unmarshal(data, &do); err != nil {
return err
}
q.Value = do.Value
}
return nil
}
// ServerSearchReqDTO is a usual PaginatedReqDTO with added fields for searching and filtering.
type ServerSearchReqDTO struct {
PaginatedReqDTO

View File

@@ -67,9 +67,10 @@ func (app *appContext) pushResources(gc *gin.Context, page Page) {
default:
toPush = []string{}
}
urlBase := app.getURLBase(gc)
if pusher := gc.Writer.Pusher(); pusher != nil {
for _, f := range toPush {
if err := pusher.Push(PAGES.Base+f, nil); err != nil {
if err := pusher.Push(urlBase+f, nil); err != nil {
app.debug.Printf(lm.FailedServerPush, err)
}
}
@@ -172,6 +173,10 @@ func (app *appContext) getLang(gc *gin.Context, page Page, chosen string) string
func (app *appContext) AdminPage(gc *gin.Context) {
app.pushResources(gc, AdminPage)
// Pre-emptively (maybe) generate user cache
go app.userCache.MaybeSync(app)
lang := app.getLang(gc, AdminPage, app.storage.lang.chosenAdminLang)
jfAdminOnly := app.config.Section("ui").Key("admin_only").MustBool(true)
jfAllowAll := app.config.Section("ui").Key("allow_all").MustBool(false)