mirror of
https://github.com/hrfee/jfa-go.git
synced 2026-01-18 16:47:42 +01:00
Compare commits
30 Commits
792296e3bc
...
user-pagin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d7d82b793 | ||
|
|
b40abafb95 | ||
|
|
18f8921eba | ||
|
|
285215cf4b | ||
|
|
fe4097a724 | ||
|
|
364b010ceb | ||
|
|
37bdf50bb0 | ||
|
|
70e35b8bd7 | ||
|
|
2657e74803 | ||
|
|
372514709d | ||
|
|
c922dc5b50 | ||
|
|
6fff8a887e | ||
|
|
0a7093a3b4 | ||
|
|
d8fe593323 | ||
|
|
4dcec4b9c7 | ||
|
|
ac56ad1400 | ||
|
|
d09ee59a1a | ||
|
|
3299398806 | ||
|
|
b53120f271 | ||
|
|
1dfe13951f | ||
|
|
732ce1bc57 | ||
|
|
94e076401e | ||
|
|
fb83094532 | ||
|
|
dec5197bfd | ||
|
|
ebff016b5d | ||
|
|
da0dc7f1c0 | ||
|
|
f6044578c0 | ||
|
|
699cbee240 | ||
|
|
ef253de56b | ||
|
|
9715f90a48 |
146
activitysort.go
146
activitysort.go
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
16
css/base.css
16
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));
|
||||
}
|
||||
|
||||
21
email.go
21
email.go
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
116
usercache.go
116
usercache.go
@@ -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
|
||||
|
||||
7
views.go
7
views.go
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user