mirror of
https://github.com/hrfee/jfa-go.git
synced 2024-09-07 21:10:11 +00:00
Compare commits
38 Commits
08db2bad6a
...
ab0b796053
Author | SHA1 | Date | |
---|---|---|---|
|
ab0b796053 | ||
|
d0de1142ae | ||
|
8d6ad7e3c8 | ||
|
8ae5dd97b2 | ||
|
cf747c1ddb | ||
|
8cb53d1c6f | ||
|
bd8ecebf89 | ||
|
09158b5bb5 | ||
|
aa30f1c392 | ||
|
4a2fc6d418 | ||
|
1846e31bf5 | ||
1be20d471d | |||
3739634b63 | |||
3951116bdc | |||
a288ba4461 | |||
f34ba5df18 | |||
44d7e173e3 | |||
663389693f | |||
591b843148 | |||
de3c06129d | |||
0238c6778c | |||
d00f3fcfbc | |||
47ce8a9ec4 | |||
2d83718f81 | |||
a0db685af2 | |||
4fa0630aef | |||
3cad30a8e5 | |||
44172074b9 | |||
1032e4e747 | |||
a73dfddd3f | |||
274324557c | |||
5a0677bac8 | |||
df1581d48e | |||
9d1c7bba6f | |||
b620c0d9ae | |||
2c787b4d46 | |||
69dcaf3797 | |||
|
a7e05c5943 |
186
api-activities.go
Normal file
186
api-activities.go
Normal file
@ -0,0 +1,186 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/timshannon/badgerhold/v4"
|
||||
)
|
||||
|
||||
func stringToActivityType(v string) ActivityType {
|
||||
switch v {
|
||||
case "creation":
|
||||
return ActivityCreation
|
||||
case "deletion":
|
||||
return ActivityDeletion
|
||||
case "disabled":
|
||||
return ActivityDisabled
|
||||
case "enabled":
|
||||
return ActivityEnabled
|
||||
case "contactLinked":
|
||||
return ActivityContactLinked
|
||||
case "contactUnlinked":
|
||||
return ActivityContactUnlinked
|
||||
case "changePassword":
|
||||
return ActivityChangePassword
|
||||
case "resetPassword":
|
||||
return ActivityResetPassword
|
||||
case "createInvite":
|
||||
return ActivityCreateInvite
|
||||
case "deleteInvite":
|
||||
return ActivityDeleteInvite
|
||||
}
|
||||
return ActivityUnknown
|
||||
}
|
||||
|
||||
func activityTypeToString(v ActivityType) string {
|
||||
switch v {
|
||||
case ActivityCreation:
|
||||
return "creation"
|
||||
case ActivityDeletion:
|
||||
return "deletion"
|
||||
case ActivityDisabled:
|
||||
return "disabled"
|
||||
case ActivityEnabled:
|
||||
return "enabled"
|
||||
case ActivityContactLinked:
|
||||
return "contactLinked"
|
||||
case ActivityContactUnlinked:
|
||||
return "contactUnlinked"
|
||||
case ActivityChangePassword:
|
||||
return "changePassword"
|
||||
case ActivityResetPassword:
|
||||
return "resetPassword"
|
||||
case ActivityCreateInvite:
|
||||
return "createInvite"
|
||||
case ActivityDeleteInvite:
|
||||
return "deleteInvite"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func stringToActivitySource(v string) ActivitySource {
|
||||
switch v {
|
||||
case "user":
|
||||
return ActivityUser
|
||||
case "admin":
|
||||
return ActivityAdmin
|
||||
case "anon":
|
||||
return ActivityAnon
|
||||
case "daemon":
|
||||
return ActivityDaemon
|
||||
}
|
||||
return ActivityAnon
|
||||
}
|
||||
|
||||
func activitySourceToString(v ActivitySource) string {
|
||||
switch v {
|
||||
case ActivityUser:
|
||||
return "user"
|
||||
case ActivityAdmin:
|
||||
return "admin"
|
||||
case ActivityAnon:
|
||||
return "anon"
|
||||
case ActivityDaemon:
|
||||
return "daemon"
|
||||
}
|
||||
return "anon"
|
||||
}
|
||||
|
||||
// @Summary Get the requested set of activities, Paginated, filtered and sorted.
|
||||
// @Produce json
|
||||
// @Param GetActivitiesDTO body GetActivitiesDTO true "search parameters"
|
||||
// @Success 200 {object} GetActivitiesRespDTO
|
||||
// @Router /activity [post]
|
||||
// @Security Bearer
|
||||
// @tags Activity
|
||||
func (app *appContext) GetActivities(gc *gin.Context) {
|
||||
req := GetActivitiesDTO{}
|
||||
gc.BindJSON(&req)
|
||||
query := &badgerhold.Query{}
|
||||
activityTypes := make([]interface{}, len(req.Type))
|
||||
for i, v := range req.Type {
|
||||
activityTypes[i] = stringToActivityType(v)
|
||||
}
|
||||
if len(activityTypes) != 0 {
|
||||
query = badgerhold.Where("Type").In(activityTypes...)
|
||||
}
|
||||
|
||||
if !req.Ascending {
|
||||
query = query.Reverse()
|
||||
}
|
||||
|
||||
query = query.SortBy("Time")
|
||||
|
||||
if req.Limit == 0 {
|
||||
req.Limit = 10
|
||||
}
|
||||
|
||||
query = query.Skip(req.Page * req.Limit).Limit(req.Limit)
|
||||
|
||||
var results []Activity
|
||||
err := app.storage.db.Find(&results, query)
|
||||
|
||||
if err != nil {
|
||||
app.err.Printf("Failed to read activities from DB: %v\n", err)
|
||||
}
|
||||
|
||||
resp := GetActivitiesRespDTO{
|
||||
Activities: make([]ActivityDTO, len(results)),
|
||||
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(),
|
||||
}
|
||||
if act.Type == ActivityDeletion || act.Type == ActivityCreation {
|
||||
resp.Activities[i].Username = act.Value
|
||||
resp.Activities[i].Value = ""
|
||||
} else if user, status, err := app.jf.UserByID(act.UserID, false); status == 200 && err == nil {
|
||||
resp.Activities[i].Username = user.Name
|
||||
}
|
||||
|
||||
if (act.SourceType == ActivityUser || act.SourceType == ActivityAdmin) && act.Source != "" {
|
||||
user, status, err := app.jf.UserByID(act.Source, false)
|
||||
if status == 200 && err == nil {
|
||||
resp.Activities[i].SourceUsername = user.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gc.JSON(200, resp)
|
||||
}
|
||||
|
||||
// @Summary Delete the activity with the given ID. No-op if non-existent, always succeeds.
|
||||
// @Produce json
|
||||
// @Param id path string true "ID of activity to delete"
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Router /activity/{id} [delete]
|
||||
// @Security Bearer
|
||||
// @tags Activity
|
||||
func (app *appContext) DeleteActivity(gc *gin.Context) {
|
||||
app.storage.DeleteActivityKey(gc.Param("id"))
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @Summary Returns the total number of activities stored in the database.
|
||||
// @Produce json
|
||||
// @Success 200 {object} GetActivityCountDTO
|
||||
// @Router /activity/count [get]
|
||||
// @Security Bearer
|
||||
// @tags Activity
|
||||
func (app *appContext) GetActivityCount(gc *gin.Context) {
|
||||
resp := GetActivityCountDTO{}
|
||||
var err error
|
||||
resp.Count, err = app.storage.db.Count(&Activity{}, &badgerhold.Query{})
|
||||
if err != nil {
|
||||
resp.Count = 0
|
||||
}
|
||||
gc.JSON(200, resp)
|
||||
}
|
@ -85,6 +85,14 @@ func (app *appContext) checkInvites() {
|
||||
wait.Wait()
|
||||
}
|
||||
app.storage.DeleteInvitesKey(data.Code)
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityDeleteInvite,
|
||||
SourceType: ActivityDaemon,
|
||||
InviteCode: data.Code,
|
||||
Value: data.Label,
|
||||
Time: time.Now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -130,12 +138,26 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
|
||||
}
|
||||
match = false
|
||||
app.storage.DeleteInvitesKey(code)
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityDeleteInvite,
|
||||
SourceType: ActivityDaemon,
|
||||
InviteCode: code,
|
||||
Value: inv.Label,
|
||||
Time: time.Now(),
|
||||
})
|
||||
} else if used {
|
||||
del := false
|
||||
newInv := inv
|
||||
if newInv.RemainingUses == 1 {
|
||||
del = true
|
||||
app.storage.DeleteInvitesKey(code)
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityDeleteInvite,
|
||||
SourceType: ActivityDaemon,
|
||||
InviteCode: code,
|
||||
Value: inv.Label,
|
||||
Time: time.Now(),
|
||||
})
|
||||
} else if newInv.RemainingUses != 0 {
|
||||
// 0 means infinite i guess?
|
||||
newInv.RemainingUses--
|
||||
@ -192,7 +214,7 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
|
||||
addressValid := false
|
||||
discord := ""
|
||||
app.debug.Printf("%s: Sending invite message", invite.Code)
|
||||
if discordEnabled && !strings.Contains(req.SendTo, "@") {
|
||||
if discordEnabled && (!strings.Contains(req.SendTo, "@") || strings.HasPrefix(req.SendTo, "@")) {
|
||||
users := app.discord.GetUsers(req.SendTo)
|
||||
if len(users) == 0 {
|
||||
invite.SendTo = fmt.Sprintf("Failed: User not found: \"%s\"", req.SendTo)
|
||||
@ -236,6 +258,18 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
|
||||
}
|
||||
}
|
||||
app.storage.SetInvitesKey(invite.Code, invite)
|
||||
|
||||
// Record activity
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityCreateInvite,
|
||||
UserID: "",
|
||||
SourceType: ActivityAdmin,
|
||||
Source: gc.GetString("jfId"),
|
||||
InviteCode: invite.Code,
|
||||
Value: invite.Label,
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@ -429,10 +463,20 @@ func (app *appContext) DeleteInvite(gc *gin.Context) {
|
||||
var req deleteInviteDTO
|
||||
gc.BindJSON(&req)
|
||||
app.debug.Printf("%s: Deletion requested", req.Code)
|
||||
var ok bool
|
||||
_, ok = app.storage.GetInvitesKey(req.Code)
|
||||
inv, ok := app.storage.GetInvitesKey(req.Code)
|
||||
if ok {
|
||||
app.storage.DeleteInvitesKey(req.Code)
|
||||
|
||||
// Record activity
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityDeleteInvite,
|
||||
SourceType: ActivityAdmin,
|
||||
Source: gc.GetString("jfId"),
|
||||
InviteCode: req.Code,
|
||||
Value: inv.Label,
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
app.info.Printf("%s: Invite deleted", req.Code)
|
||||
respondBool(200, true, gc)
|
||||
return
|
||||
|
@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/lithammer/shortuuid/v3"
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
@ -677,7 +678,18 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
|
||||
app.storage.SetDiscordKey(req.JellyfinID, user)
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactLinked,
|
||||
UserID: req.JellyfinID,
|
||||
SourceType: ActivityAdmin,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "discord",
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
linkExistingOmbiDiscordTelegram(app)
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
@ -697,6 +709,16 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
|
||||
return
|
||||
} */
|
||||
app.storage.DeleteDiscordKey(req.ID)
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactUnlinked,
|
||||
UserID: req.ID,
|
||||
SourceType: ActivityAdmin,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "discord",
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@ -715,6 +737,16 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
|
||||
return
|
||||
} */
|
||||
app.storage.DeleteTelegramKey(req.ID)
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactUnlinked,
|
||||
UserID: req.ID,
|
||||
SourceType: ActivityAdmin,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "telegram",
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@ -733,5 +765,15 @@ func (app *appContext) UnlinkMatrix(gc *gin.Context) {
|
||||
return
|
||||
} */
|
||||
app.storage.DeleteMatrixKey(req.ID)
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactUnlinked,
|
||||
UserID: req.ID,
|
||||
SourceType: ActivityAdmin,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "matrix",
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/lithammer/shortuuid/v3"
|
||||
"github.com/timshannon/badgerhold/v4"
|
||||
)
|
||||
|
||||
@ -207,6 +208,16 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
|
||||
}
|
||||
emailStore.Addr = claims["email"].(string)
|
||||
app.storage.SetEmailsKey(id, emailStore)
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactLinked,
|
||||
UserID: gc.GetString("jfId"),
|
||||
SourceType: ActivityUser,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "email",
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
if app.config.Section("ombi").Key("enabled").MustBool(false) {
|
||||
ombiUser, code, err := app.getOmbiUser(id)
|
||||
if code == 200 && err == nil {
|
||||
@ -359,6 +370,16 @@ func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) {
|
||||
dcUser.Contact = existingUser.Contact
|
||||
}
|
||||
app.storage.SetDiscordKey(gc.GetString("jfId"), dcUser)
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactLinked,
|
||||
UserID: gc.GetString("jfId"),
|
||||
SourceType: ActivityUser,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "discord",
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@ -397,6 +418,16 @@ func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) {
|
||||
tgUser.Contact = existingUser.Contact
|
||||
}
|
||||
app.storage.SetTelegramKey(gc.GetString("jfId"), tgUser)
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactLinked,
|
||||
UserID: gc.GetString("jfId"),
|
||||
SourceType: ActivityUser,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "telegram",
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@ -468,6 +499,16 @@ func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
|
||||
}
|
||||
|
||||
app.storage.SetMatrixKey(gc.GetString("jfId"), mxUser)
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactLinked,
|
||||
UserID: gc.GetString("jfId"),
|
||||
SourceType: ActivityUser,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "matrix",
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
delete(app.matrix.tokens, pin)
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
@ -480,6 +521,16 @@ func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
|
||||
// @Tags User Page
|
||||
func (app *appContext) UnlinkMyDiscord(gc *gin.Context) {
|
||||
app.storage.DeleteDiscordKey(gc.GetString("jfId"))
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactUnlinked,
|
||||
UserID: gc.GetString("jfId"),
|
||||
SourceType: ActivityUser,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "discord",
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@ -491,6 +542,16 @@ func (app *appContext) UnlinkMyDiscord(gc *gin.Context) {
|
||||
// @Tags User Page
|
||||
func (app *appContext) UnlinkMyTelegram(gc *gin.Context) {
|
||||
app.storage.DeleteTelegramKey(gc.GetString("jfId"))
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactUnlinked,
|
||||
UserID: gc.GetString("jfId"),
|
||||
SourceType: ActivityUser,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "telegram",
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@ -502,6 +563,16 @@ func (app *appContext) UnlinkMyTelegram(gc *gin.Context) {
|
||||
// @Tags User Page
|
||||
func (app *appContext) UnlinkMyMatrix(gc *gin.Context) {
|
||||
app.storage.DeleteMatrixKey(gc.GetString("jfId"))
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactUnlinked,
|
||||
UserID: gc.GetString("jfId"),
|
||||
SourceType: ActivityUser,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "matrix",
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@ -620,6 +691,15 @@ func (app *appContext) ChangeMyPassword(gc *gin.Context) {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityChangePassword,
|
||||
UserID: user.ID,
|
||||
SourceType: ActivityUser,
|
||||
Source: user.ID,
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
if app.config.Section("ombi").Key("enabled").MustBool(false) {
|
||||
func() {
|
||||
ombiUser, status, err := app.getOmbiUser(gc.GetString("jfId"))
|
||||
|
76
api-users.go
76
api-users.go
@ -9,6 +9,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/hrfee/mediabrowser"
|
||||
"github.com/lithammer/shortuuid/v3"
|
||||
"github.com/timshannon/badgerhold/v4"
|
||||
)
|
||||
|
||||
@ -45,6 +46,17 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) {
|
||||
return
|
||||
}
|
||||
id := user.ID
|
||||
|
||||
// Record activity
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityCreation,
|
||||
UserID: id,
|
||||
SourceType: ActivityAdmin,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: user.Name,
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
profile := app.storage.GetDefaultProfile()
|
||||
if req.Profile != "" && req.Profile != "none" {
|
||||
if p, ok := app.storage.GetProfileKey(req.Profile); ok {
|
||||
@ -303,6 +315,24 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
|
||||
}
|
||||
id := user.ID
|
||||
|
||||
// Record activity
|
||||
sourceType := ActivityAnon
|
||||
source := ""
|
||||
if invite.ReferrerJellyfinID != "" {
|
||||
sourceType = ActivityUser
|
||||
source = invite.ReferrerJellyfinID
|
||||
}
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityCreation,
|
||||
UserID: id,
|
||||
SourceType: sourceType,
|
||||
Source: source,
|
||||
InviteCode: invite.Code,
|
||||
Value: user.Name,
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
emailStore := EmailAddress{
|
||||
Addr: req.Email,
|
||||
Contact: (req.Email != ""),
|
||||
@ -353,6 +383,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
|
||||
if app.storage.deprecatedDiscord == nil {
|
||||
app.storage.deprecatedDiscord = discordStore{}
|
||||
}
|
||||
// Note we don't log an activity here, since it's part of creating a user.
|
||||
app.storage.SetDiscordKey(user.ID, discordUser)
|
||||
delete(app.discord.verifiedTokens, req.DiscordPIN)
|
||||
}
|
||||
@ -539,6 +570,10 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) {
|
||||
sendMail = false
|
||||
}
|
||||
}
|
||||
activityType := ActivityDisabled
|
||||
if req.Enabled {
|
||||
activityType = ActivityEnabled
|
||||
}
|
||||
for _, userID := range req.Users {
|
||||
user, status, err := app.jf.UserByID(userID, false)
|
||||
if status != 200 || err != nil {
|
||||
@ -553,6 +588,16 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) {
|
||||
app.err.Printf("Failed to set policy for user \"%s\" (%d): %v", userID, status, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Record activity
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: activityType,
|
||||
UserID: userID,
|
||||
SourceType: ActivityAdmin,
|
||||
Source: gc.GetString("jfId"),
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
if sendMail && req.Notify {
|
||||
if err := app.sendByID(msg, userID); err != nil {
|
||||
app.err.Printf("Failed to send account enabled/disabled email: %v", err)
|
||||
@ -605,6 +650,12 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
username := ""
|
||||
if user, status, err := app.jf.UserByID(userID, false); status == 200 && err == nil {
|
||||
username = user.Name
|
||||
}
|
||||
|
||||
status, err := app.jf.DeleteUser(userID)
|
||||
if !(status == 200 || status == 204) || err != nil {
|
||||
msg := fmt.Sprintf("%d: %v", status, err)
|
||||
@ -614,6 +665,17 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
|
||||
errors[userID] += msg
|
||||
}
|
||||
}
|
||||
|
||||
// Record activity
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityDeletion,
|
||||
UserID: userID,
|
||||
SourceType: ActivityAdmin,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: username,
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
if sendMail && req.Notify {
|
||||
if err := app.sendByID(msg, userID); err != nil {
|
||||
app.err.Printf("Failed to send account deletion email: %v", err)
|
||||
@ -1097,6 +1159,20 @@ func (app *appContext) ModifyEmails(gc *gin.Context) {
|
||||
|
||||
emailStore.Addr = address
|
||||
app.storage.SetEmailsKey(id, emailStore)
|
||||
|
||||
activityType := ActivityContactLinked
|
||||
if address == "" {
|
||||
activityType = ActivityContactUnlinked
|
||||
}
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: activityType,
|
||||
UserID: id,
|
||||
SourceType: ActivityAdmin,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "email",
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
if ombiEnabled {
|
||||
ombiUser, code, err := app.getOmbiUser(id)
|
||||
if code == 200 && err == nil {
|
||||
|
11
api.go
11
api.go
@ -7,6 +7,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/hrfee/mediabrowser"
|
||||
"github.com/itchyny/timefmt-go"
|
||||
"github.com/lithammer/shortuuid/v3"
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
@ -157,6 +158,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
|
||||
}
|
||||
username = resp.UsersReset[0]
|
||||
}
|
||||
|
||||
var user mediabrowser.User
|
||||
var status int
|
||||
var err error
|
||||
@ -170,6 +172,15 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityResetPassword,
|
||||
UserID: user.ID,
|
||||
SourceType: ActivityUser,
|
||||
Source: user.ID,
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
prevPassword := req.PIN
|
||||
if isInternal {
|
||||
prevPassword = ""
|
||||
|
@ -78,6 +78,9 @@ func (app *appContext) loadConfig() error {
|
||||
app.MustSetValue("smtp", "cert_validation", "true")
|
||||
app.MustSetValue("smtp", "auth_type", "4")
|
||||
|
||||
app.MustSetValue("activity_log", "keep_n_records", "1000")
|
||||
app.MustSetValue("activity_log", "delete_after_days", "90")
|
||||
|
||||
sc := app.config.Section("discord").Key("start_command").MustString("start")
|
||||
app.config.Section("discord").Key("start_command").SetValue(strings.TrimPrefix(strings.TrimPrefix(sc, "/"), "!"))
|
||||
|
||||
|
@ -515,6 +515,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"activity_log": {
|
||||
"order": [],
|
||||
"meta": {
|
||||
"name": "Activity Log",
|
||||
"description": "Settings for data retention of the activity log."
|
||||
},
|
||||
"settings": {
|
||||
"keep_n_records": {
|
||||
"name": "Number of records to keep",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "number",
|
||||
"value": 1000,
|
||||
"description": "How many of the most recent activities to keep. Set to 0 to disable."
|
||||
},
|
||||
"delete_after_days": {
|
||||
"name": "Delete activities older than (days):",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "number",
|
||||
"value": 90,
|
||||
"description": "If an activity was created this many days ago, it will be deleted. Set to 0 to disable."
|
||||
}
|
||||
}
|
||||
},
|
||||
"captcha": {
|
||||
"order": [],
|
||||
"meta": {
|
||||
|
@ -3,6 +3,10 @@
|
||||
color: rgba(0, 0, 0, 0) !important;
|
||||
}
|
||||
|
||||
.loader.rel {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.loader .dot {
|
||||
--diameter: 0.5rem;
|
||||
--radius: calc(var(--diameter) / 2);
|
||||
@ -15,6 +19,12 @@
|
||||
left: calc(50% - var(--radius));
|
||||
animation: osc 1s cubic-bezier(.72,.16,.31,.97) infinite;
|
||||
}
|
||||
|
||||
.loader.rel .dot {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.loader.loader-sm .dot {
|
||||
--deviation: 10%;
|
||||
}
|
||||
|
44
daemon.go
44
daemon.go
@ -3,7 +3,9 @@ package main
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/dgraph-io/badger/v3"
|
||||
"github.com/hrfee/mediabrowser"
|
||||
"github.com/timshannon/badgerhold/v4"
|
||||
)
|
||||
|
||||
// clearEmails removes stored emails for users which no longer exist.
|
||||
@ -72,6 +74,37 @@ func (app *appContext) clearTelegram() {
|
||||
}
|
||||
}
|
||||
|
||||
func (app *appContext) clearActivities() {
|
||||
app.debug.Println("Husekeeping: Cleaning up Activity log...")
|
||||
keepCount := app.config.Section("activity_log").Key("keep_n_records").MustInt(1000)
|
||||
maxAgeDays := app.config.Section("activity_log").Key("delete_after_days").MustInt(90)
|
||||
minAge := time.Now().AddDate(0, 0, -maxAgeDays)
|
||||
err := error(nil)
|
||||
errorSource := 0
|
||||
if maxAgeDays != 0 {
|
||||
err = app.storage.db.DeleteMatching(&Activity{}, badgerhold.Where("Time").Lt(minAge))
|
||||
}
|
||||
if err == nil && keepCount != 0 {
|
||||
// app.debug.Printf("Keeping %d records", keepCount)
|
||||
err = app.storage.db.DeleteMatching(&Activity{}, (&badgerhold.Query{}).Reverse().SortBy("Time").Skip(keepCount))
|
||||
if err != nil {
|
||||
errorSource = 1
|
||||
}
|
||||
}
|
||||
if err == badger.ErrTxnTooBig {
|
||||
app.debug.Printf("Activities: Delete txn was too big, doing it manually.")
|
||||
list := []Activity{}
|
||||
if errorSource == 0 {
|
||||
app.storage.db.Find(&list, badgerhold.Where("Time").Lt(minAge))
|
||||
} else {
|
||||
app.storage.db.Find(&list, (&badgerhold.Query{}).Reverse().SortBy("Time").Skip(keepCount))
|
||||
}
|
||||
for _, record := range list {
|
||||
app.storage.DeleteActivityKey(record.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS
|
||||
|
||||
type housekeepingDaemon struct {
|
||||
@ -91,10 +124,13 @@ func newInviteDaemon(interval time.Duration, app *appContext) *housekeepingDaemo
|
||||
period: interval,
|
||||
app: app,
|
||||
}
|
||||
daemon.jobs = []func(app *appContext){func(app *appContext) {
|
||||
app.debug.Println("Housekeeping: Checking for expired invites")
|
||||
app.checkInvites()
|
||||
}}
|
||||
daemon.jobs = []func(app *appContext){
|
||||
func(app *appContext) {
|
||||
app.debug.Println("Housekeeping: Checking for expired invites")
|
||||
app.checkInvites()
|
||||
},
|
||||
func(app *appContext) { app.clearActivities() },
|
||||
}
|
||||
|
||||
clearEmail := app.config.Section("email").Key("require_unique").MustBool(false)
|
||||
clearDiscord := app.config.Section("discord").Key("require_unique").MustBool(false)
|
||||
|
@ -475,6 +475,7 @@
|
||||
<div>
|
||||
<span id="button-tab-invites" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.invites }}</span>
|
||||
<span id="button-tab-accounts" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.accounts }}</span>
|
||||
<span id="button-tab-activity" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.activity }}</span>
|
||||
<span id="button-tab-settings" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.settings }}</span>
|
||||
</div>
|
||||
</header>
|
||||
@ -719,6 +720,57 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-activity" class="unfocused">
|
||||
<div class="card @low dark:~d_neutral activity mb-4 overflow-visible">
|
||||
<div class="flex-expand align-middle">
|
||||
<span class="text-3xl font-bold mr-4">{{ .strings.activity }}</span>
|
||||
<div id="activity-filter-dropdown" class="dropdown z-10" tabindex="0">
|
||||
<span class="h-100 button ~neutral @low center" id="activity-filter-button">{{ .strings.filters }}</span>
|
||||
<div class="dropdown-display">
|
||||
<div class="card ~neutral @low mt-2" id="activity-filter-list">
|
||||
<p class="supra pb-2">{{ .strings.filters }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="button ~neutral @low ml-2" id="activity-sort-direction">{{ .strings.sortDirection }}</button>
|
||||
<input type="search" class="field ~neutral @low input search ml-2 mr-2" id="activity-search" placeholder="{{ .strings.search }}">
|
||||
<span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none activity-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
|
||||
<button class="button ~info @low ml-2" id="activity-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
|
||||
</div>
|
||||
<div class="flex flex-row justify-between py-2">
|
||||
<div class="supra sm hidden" id="activity-search-options-header">{{ .strings.searchOptions }}</div>
|
||||
<div class="supra sm">
|
||||
<span id="activity-total-records" class="mx-2"></span>
|
||||
<span id="activity-loaded-records" class="mx-2"></span>
|
||||
<span id="activity-shown-records" class="mx-2"></span>
|
||||
</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>
|
||||
<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 h-[100%] justify-center items-center">
|
||||
<span class="text-2xl font-medium italic mb-3">{{ .strings.noResultsFound }}</span>
|
||||
<span class="text-xl font-medium italic mb-3 unfocused" id="activity-keep-searching-description">{{ .strings.keepSearchingDescription }}</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>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<button class="button m-2 ~neutral @low" id="activity-load-more">{{ .strings.loadMore }}</button>
|
||||
<button class="button m-2 ~neutral @low" id="activity-load-all">{{ .strings.loadAll }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-settings" class="unfocused">
|
||||
<div class="card @low dark:~d_neutral settings overflow">
|
||||
<div class="flex-expand">
|
||||
|
@ -37,9 +37,9 @@
|
||||
"profile": "Profil",
|
||||
"unknown": "Ukendt",
|
||||
"label": "Etiket",
|
||||
"announce": "Annoncere",
|
||||
"announce": "Meddelelse",
|
||||
"subject": "Emne",
|
||||
"message": "Meddelelse",
|
||||
"message": "Besked",
|
||||
"variables": "Variabler",
|
||||
"conditionals": "Betingelser",
|
||||
"preview": "Eksempel",
|
||||
@ -47,13 +47,13 @@
|
||||
"donate": "Doner",
|
||||
"contactThrough": "Kontakt gennem:",
|
||||
"extendExpiry": "Forlæng udløb",
|
||||
"customizeMessages": "Tilpas Meddelelser",
|
||||
"customizeMessagesDescription": "Hvis du ikke vil bruge jfa-go's meddelelses skabeloner, kan du oprette din egen ved hjælp af Markdown.",
|
||||
"customizeMessages": "Tilpas Beskeder",
|
||||
"customizeMessagesDescription": "Hvis du ikke vil bruge jfa-go's besked skabeloner, kan du oprette din egen ved hjælp af Markdown.",
|
||||
"markdownSupported": "Markdown understøttes.",
|
||||
"modifySettings": "Rediger indstillinger",
|
||||
"modifySettingsDescription": "Anvend indstillinger fra en eksisterende profil, eller hent dem direkte fra en bruger.",
|
||||
"applyHomescreenLayout": "Anvend startskærmens layout",
|
||||
"sendDeleteNotificationEmail": "Send notifikations meddelelse",
|
||||
"sendDeleteNotificationEmail": "Send notifikations besked",
|
||||
"sendDeleteNotifiationExample": "Din konto er blevet slettet.",
|
||||
"settingsRestart": "Genstart",
|
||||
"settingsRestarting": "Genstarter…",
|
||||
@ -102,7 +102,35 @@
|
||||
"sendPWRSuccessManual": "Hvis brugeren ikke er modtaget den, så tryk på kopier for manuelt at sende et link til dem.",
|
||||
"sendPWRValidFor": "Dette link er gyldigt i 30m.",
|
||||
"accessJFA": "Få adgang til jfa-go",
|
||||
"accessJFASettings": "Kan ikke ændres, da enten \"Kun administrator\" eller \"Tillad alle\" er blevet indstillet i Indstillinger > Generelt."
|
||||
"accessJFASettings": "Kan ikke ændres, da enten \"Kun administrator\" eller \"Tillad alle\" er blevet indstillet i Indstillinger > Generelt.",
|
||||
"after": "Efter",
|
||||
"settingsHiddenDependency": "Matchende indstillinger er skjult, fordi de afhænger af værdien af en anden indstilling:",
|
||||
"userPageLogin": "Brugerside: Login",
|
||||
"buildTime": "Bygnings Tid",
|
||||
"invite": "inviter",
|
||||
"loginNotAdmin": "Ikke en Admin?",
|
||||
"userLabel": "Brugeretiket",
|
||||
"userLabelDescription": "Etiket, der skal anvendes på brugere, der er oprettet med denne invitation.",
|
||||
"sortingBy": "Sortering Efter",
|
||||
"clickToRemoveFilter": "Klik for at fjerne dette filter.",
|
||||
"clearSearch": "Ryd søgning",
|
||||
"actions": "Handlinger",
|
||||
"unlink": "Fjern linket til konto",
|
||||
"enableReferrals": "Aktiver henvisninger",
|
||||
"disableReferrals": "Deaktiver henvisninger",
|
||||
"enableReferralsDescription": "Giv brugerne et personligt henvisningslink, der ligner en invitation, til at sende til venner/familie. Kan hentes fra en henvisningsskabelon i en profil eller fra en eksisterende invitation.",
|
||||
"enableReferralsProfileDescription": "Giv brugere oprettet med denne profil et personligt henvisningslink, der ligner en invitation, til at sende til venner/familie. Opret en invitation med de ønskede indstillinger, og vælg den her. Hver henvisning vil så være baseret på denne invitation. Du kan slette invitationen, når den er fuldført.",
|
||||
"before": "Før",
|
||||
"noResultsFound": "Ingen Resultater Fundet",
|
||||
"settingsDependsOn": "{setting}: afhænger af {dependency}",
|
||||
"settingsMaybeUnderAdvanced": "Tip: Du finder muligvis det du leder efter, ved at aktivere Avancerede indstillinger.",
|
||||
"settingsAdvancedMode": "{setting}: Avanceret Indstillinger skal være aktiveret",
|
||||
"filters": "Filtre",
|
||||
"searchOptions": "Søge Indstillinger",
|
||||
"matchText": "Match Tekst",
|
||||
"jellyfinID": "Jellyfin ID",
|
||||
"userPagePage": "Brugerside: Side",
|
||||
"builtBy": "Bygget Af"
|
||||
},
|
||||
"notifications": {
|
||||
"changedEmailAddress": "Ændret e-mail adresse på {n}.",
|
||||
@ -133,14 +161,16 @@
|
||||
"errorFailureCheckLogs": "Mislykkedes (tjek konsol/logfiler)",
|
||||
"errorPartialFailureCheckLogs": "Delvis fejl (tjek konsol/logfiler)",
|
||||
"errorUserCreated": "Kunne ikke oprette bruger {n}.",
|
||||
"errorSendWelcomeEmail": "Kunne ikke sende velkomst meddelelse (tjek konsol/logfiler",
|
||||
"errorSendWelcomeEmail": "Kunne ikke sende velkomst besked (tjek konsol/logfiler",
|
||||
"errorApplyUpdate": "Kunne ikke anvende opdateringen, prøv manuelt.",
|
||||
"errorCheckUpdate": "Kunne ikke kontrollere for opdatering.",
|
||||
"updateAvailable": "En ny opdatering er tilgængelig, tjek indstillingerne.",
|
||||
"noUpdatesAvailable": "Ingen nye opdateringer tilgængelige.",
|
||||
"savedAnnouncement": "Meddelelse gemt.",
|
||||
"setOmbiProfile": "Gemt i ombi profilen.",
|
||||
"errorSetOmbiProfile": "Ombi profilen kunne ikke gemmes."
|
||||
"errorSetOmbiProfile": "Ombi profilen kunne ikke gemmes.",
|
||||
"referralsEnabled": "Henvisninger aktiveret.",
|
||||
"errorNoReferralTemplate": "Profilen indeholder ikke en henvisningsskabelon. Tilføj en i indstillingerne."
|
||||
},
|
||||
"quantityStrings": {
|
||||
"modifySettingsFor": {
|
||||
@ -180,8 +210,8 @@
|
||||
"plural": "Aktiveret {n} brugere."
|
||||
},
|
||||
"announceTo": {
|
||||
"singular": "Annoncer til {n} bruger",
|
||||
"plural": "Annoncer til {n} brugere"
|
||||
"singular": "Send Meddelelse til {n} bruger",
|
||||
"plural": "Send Meddelelse til {n} brugere"
|
||||
},
|
||||
"appliedSettings": {
|
||||
"singular": "Anvendte indstillinger til {n} bruger.",
|
||||
@ -198,6 +228,10 @@
|
||||
"setExpiry": {
|
||||
"singular": "Indstil udløb for {n} bruger",
|
||||
"plural": "Indstil udløb for {n} brugere"
|
||||
},
|
||||
"enableReferralsFor": {
|
||||
"singular": "Aktiver Henvisninger for {n} bruger",
|
||||
"plural": "Aktiver Henvisninger for {n} brugere"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,7 @@
|
||||
"invites": "Invites",
|
||||
"invite": "Invite",
|
||||
"accounts": "Accounts",
|
||||
"activity": "Activity",
|
||||
"settings": "Settings",
|
||||
"inviteMonths": "Months",
|
||||
"inviteDays": "Days",
|
||||
@ -54,8 +55,12 @@
|
||||
"reset": "Reset",
|
||||
"donate": "Donate",
|
||||
"unlink": "Unlink Account",
|
||||
"deleted": "Deleted",
|
||||
"disabled": "Disabled",
|
||||
"sendPWR": "Send Password Reset",
|
||||
"noResultsFound": "No Results Found",
|
||||
"keepSearching": "Keep Searching",
|
||||
"keepSearchingDescription": "Only the current loaded activities were searched. Click below if you wish to search all activities.",
|
||||
"contactThrough": "Contact through:",
|
||||
"extendExpiry": "Extend expiry",
|
||||
"sendPWRManual": "User {n} has no method of contact, press copy to get a link to send to them.",
|
||||
@ -118,6 +123,7 @@
|
||||
"accessJFA": "Access jfa-go",
|
||||
"accessJFASettings": "Cannot be changed as either \"Admin Only\" or \"Allow All\" has been set in Settings > General.",
|
||||
"sortingBy": "Sorting By",
|
||||
"sortDirection": "Sort Direction",
|
||||
"filters": "Filters",
|
||||
"clickToRemoveFilter": "Click to remove this filter.",
|
||||
"clearSearch": "Clear search",
|
||||
@ -129,7 +135,47 @@
|
||||
"userPagePage": "User Page: Page",
|
||||
"buildTime": "Build Time",
|
||||
"builtBy": "Built By",
|
||||
"loginNotAdmin": "Not an Admin?"
|
||||
"loginNotAdmin": "Not an Admin?",
|
||||
"referrer": "Referrer",
|
||||
"accountLinked": "{contactMethod} linked: {user}",
|
||||
"accountUnlinked": "{contactMethod} removed: {user}",
|
||||
"accountResetPassword": "{user} reset their password",
|
||||
"accountChangedPassword": "{user} changed their password",
|
||||
"accountCreated": "Account created: {user}",
|
||||
"accountDeleted": "Account deleted: {user}",
|
||||
"accountDisabled": "Account disabled: {user}",
|
||||
"accountReEnabled": "Account re-enabled: {user}",
|
||||
"accountExpired": "Account expired: {user}",
|
||||
"userDeleted": "User was deleted.",
|
||||
"userDisabled": "User was disabled",
|
||||
"inviteCreated": "Invite created: {invite}",
|
||||
"inviteDeleted": "Invite deleted: {invite}",
|
||||
"inviteExpired": "Invite expired: {invite}",
|
||||
"fromInvite": "From Invite",
|
||||
"byAdmin": "By Admin",
|
||||
"byUser": "By User",
|
||||
"byJfaGo": "By jfa-go",
|
||||
"activityID": "Activity ID",
|
||||
"title": "Title",
|
||||
"usersMentioned": "User mentioned",
|
||||
"actor": "Actor",
|
||||
"actorDescription": "The thing that caused this action. \"user\"/\"admin\"/\"daemon\" or a username.",
|
||||
"accountCreationFilter": "Account Creation",
|
||||
"accountDeletionFilter": "Account Deletion",
|
||||
"accountDisabledFilter": "Account Disabled",
|
||||
"accountEnabledFilter": "Account Enabled",
|
||||
"contactLinkedFilter": "Contact Linked",
|
||||
"contactUnlinkedFilter": "Contact Unlinked",
|
||||
"passwordChangeFilter": "Password Changed",
|
||||
"passwordResetFilter": "Password Reset",
|
||||
"inviteCreatedFilter": "Invite Created",
|
||||
"inviteDeletedFilter": "Invite Deleted/Expired",
|
||||
"loadMore": "Load More",
|
||||
"loadAll": "Load All",
|
||||
"noMoreResults": "No more results.",
|
||||
"totalRecords": "{n} Total Records",
|
||||
"loadedRecords": "{n} Loaded",
|
||||
"shownRecords": "{n} Shown"
|
||||
},
|
||||
"notifications": {
|
||||
"changedEmailAddress": "Changed email address of {n}.",
|
||||
@ -145,6 +191,9 @@
|
||||
"telegramVerified": "Telegram account verified.",
|
||||
"accountConnected": "Account connected.",
|
||||
"referralsEnabled": "Referrals enabled.",
|
||||
"activityDeleted": "Activity Deleted.",
|
||||
"errorInviteNoLongerExists": "Invite no longer exists.",
|
||||
"errorInviteNotFound": "Invite not found.",
|
||||
"errorSettingsAppliedNoHomescreenLayout": "Settings were applied, but applying homescreen layout may have failed.",
|
||||
"errorHomescreenAppliedNoSettings": "Homescreen layout was applied, but applying settings may have failed.",
|
||||
"errorSettingsFailed": "Application failed.",
|
||||
@ -166,6 +215,7 @@
|
||||
"errorApplyUpdate": "Failed to apply update, try manually.",
|
||||
"errorCheckUpdate": "Failed to check for update.",
|
||||
"errorNoReferralTemplate": "Profile doesn't contain referral template, add one in settings.",
|
||||
"errorLoadActivities": "Failed to load activities.",
|
||||
"updateAvailable": "A new update is available, check settings.",
|
||||
"noUpdatesAvailable": "No new updates available."
|
||||
},
|
||||
|
@ -102,7 +102,35 @@
|
||||
"ombiProfile": "Ombi gebruikersprofiel",
|
||||
"logs": "Logs",
|
||||
"accessJFA": "Toegang tot jfa-go",
|
||||
"accessJFASettings": "Kan niet worden aangepast, omdat \"Alleen beheerders\" of \"Laat alle Jellyfin-gebruikers inloggen\" is aangevinkt in Instellingen > Algemeen."
|
||||
"accessJFASettings": "Kan niet worden aangepast, omdat \"Alleen beheerders\" of \"Laat alle Jellyfin-gebruikers inloggen\" is aangevinkt in Instellingen > Algemeen.",
|
||||
"noResultsFound": "Geen resultaten gevonden",
|
||||
"settingsHiddenDependency": "Overeenkomende instellingen zijn verborgen, omdat ze afhangen van een andere instelling:",
|
||||
"settingsAdvancedMode": "{setting}: Geavanceerde instellingen moet ingeschakeld zijn",
|
||||
"builtBy": "Build door",
|
||||
"buildTime": "Build moment",
|
||||
"userPageLogin": "Gebruikerspagina: Inloggen",
|
||||
"loginNotAdmin": "Geen beheerder?",
|
||||
"before": "Voor",
|
||||
"unlink": "Ontkoppel account",
|
||||
"after": "Na",
|
||||
"invite": "Uitnodiging",
|
||||
"userLabel": "Gebruikerslabel",
|
||||
"userLabelDescription": "Label om toe te wijzen aan gebruikers aangemaakt met deze uitnodiging.",
|
||||
"enableReferrals": "Verwijzingen inschakelen",
|
||||
"disableReferrals": "Verwijzingen uitschakelen",
|
||||
"enableReferralsDescription": "Geef gebruikers een persoonlijke verwijslink gelijkend op een uitnodiging, om naar vrienden/familie te sturen. Kan opgebouwd worden aan de hand van een verwijssjabloon in een profiel, of een bestaande uitnodiging.",
|
||||
"enableReferralsProfileDescription": "Geef gebruikers aangemaakt met dit profiel een persoonlijke verwijslink gelijkend op een uitnodiging, om naar vrienden/familie te sturen. Maak een uitnodiging aan met de gewenste instellingen, en selecteer die hier. Elke verwijzing wordt gebaseerd op die uitnodiging. Je kunt de uitnodiging daarna verwijderen.",
|
||||
"settingsDependsOn": "{setting}: hangt af van {dependency}",
|
||||
"settingsMaybeUnderAdvanced": "Tip: je vindt misschien wat je zoekt door Geavanceerde instellingen in te schakelen.",
|
||||
"sortingBy": "Sorteren naar",
|
||||
"filters": "Filters",
|
||||
"clickToRemoveFilter": "Klik om dit filter te verwijderen.",
|
||||
"clearSearch": "Zoekopdracht verwijderen",
|
||||
"actions": "Acties",
|
||||
"searchOptions": "Zoekopties",
|
||||
"matchText": "Tekstovereenkomst",
|
||||
"jellyfinID": "Jellyfin ID",
|
||||
"userPagePage": "Gebruikerspagina: Pagina"
|
||||
},
|
||||
"notifications": {
|
||||
"changedEmailAddress": "E-mailadres van {n} gewijzigd.",
|
||||
@ -140,7 +168,9 @@
|
||||
"accountConnected": "Account gekoppeld.",
|
||||
"savedAnnouncement": "Aankondiging opgeslagen.",
|
||||
"setOmbiProfile": "Opgeslagen ombi-profiel.",
|
||||
"errorSetOmbiProfile": "Opslaan van ombi-profiel mislukt."
|
||||
"errorSetOmbiProfile": "Opslaan van ombi-profiel mislukt.",
|
||||
"errorNoReferralTemplate": "Profiel bevat geen verwijzingssjabloon, voeg er een toe bij instellingen.",
|
||||
"referralsEnabled": "Verwijzingen actief."
|
||||
},
|
||||
"quantityStrings": {
|
||||
"modifySettingsFor": {
|
||||
@ -198,6 +228,10 @@
|
||||
"setExpiry": {
|
||||
"singular": "Stel verloop in voor {n} gebruiker",
|
||||
"plural": "Stel verloop in voor {n} gebruikers"
|
||||
},
|
||||
"enableReferralsFor": {
|
||||
"plural": "Verwijzingen activeren voor {1} gebruikers",
|
||||
"singular": "Verwijzingen activeren voor {1} gebruiker"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -5,7 +5,7 @@
|
||||
"strings": {
|
||||
"username": "Brugernavn",
|
||||
"password": "Adgangskode",
|
||||
"emailAddress": "E-mail Adresse",
|
||||
"emailAddress": "Email adresse",
|
||||
"name": "Navn",
|
||||
"submit": "Indsend",
|
||||
"send": "Send",
|
||||
@ -36,7 +36,12 @@
|
||||
"add": "Tilføj",
|
||||
"edit": "Rediger",
|
||||
"delete": "Slet",
|
||||
"inviteRemainingUses": "Resterende anvendelser"
|
||||
"inviteRemainingUses": "Resterende anvendelser",
|
||||
"referrals": "Henvisninger",
|
||||
"contactMethods": "Kontakt Metoder",
|
||||
"accountStatus": "Kontostatus",
|
||||
"notSet": "Ikke sat",
|
||||
"myAccount": "Min Konto"
|
||||
},
|
||||
"notifications": {
|
||||
"errorLoginBlank": "Brugernavnet og/eller adgangskoden blev efterladt tomme.",
|
||||
@ -45,5 +50,18 @@
|
||||
"error401Unauthorized": "Adgang nægtet. Prøv at genindlæse siden.",
|
||||
"errorSaveSettings": "Kunne ikke gemme indstillingerne."
|
||||
},
|
||||
"quantityStrings": {}
|
||||
}
|
||||
"quantityStrings": {
|
||||
"year": {
|
||||
"singular": "{n} År",
|
||||
"plural": "{n} År"
|
||||
},
|
||||
"month": {
|
||||
"singular": "{n} Månede",
|
||||
"plural": "{n} Måneder"
|
||||
},
|
||||
"day": {
|
||||
"singular": "{n} Dag",
|
||||
"plural": "{n} Dage"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -36,7 +36,12 @@
|
||||
"add": "Voeg toe",
|
||||
"edit": "Bewerken",
|
||||
"delete": "Verwijderen",
|
||||
"inviteRemainingUses": "Resterend aantal keer te gebruiken"
|
||||
"inviteRemainingUses": "Resterend aantal keer te gebruiken",
|
||||
"referrals": "Verwijzingen",
|
||||
"contactMethods": "Contactmethodes",
|
||||
"accountStatus": "Account status",
|
||||
"notSet": "Niet ingesteld",
|
||||
"myAccount": "Mijn account"
|
||||
},
|
||||
"notifications": {
|
||||
"errorLoginBlank": "De gebruikersnaam en/of wachtwoord is leeg.",
|
||||
@ -45,5 +50,18 @@
|
||||
"error401Unauthorized": "Geen toegang. Probeer de pagina te vernieuwen.",
|
||||
"errorSaveSettings": "Opslaan van instellingen mislukt."
|
||||
},
|
||||
"quantityStrings": {}
|
||||
}
|
||||
"quantityStrings": {
|
||||
"year": {
|
||||
"singular": "{n} jaar",
|
||||
"plural": "{n} jaar"
|
||||
},
|
||||
"month": {
|
||||
"singular": "{n} maand",
|
||||
"plural": "{n} maanden"
|
||||
},
|
||||
"day": {
|
||||
"singular": "{n} dag",
|
||||
"plural": "{n} dagen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -19,7 +19,25 @@
|
||||
"yourAccountIsValidUntil": "Din konto er gyldig indtil {date}.",
|
||||
"sendPIN": "Send nedenstående pinkode til botten, og kom derefter tilbage her for at sammenkoble din konto.",
|
||||
"sendPINDiscord": "Skriv {command} i {server_channel} på Discord, og send PIN-koden nedenfor.",
|
||||
"matrixEnterUser": "Skriv dit Bruger ID, tryk Indsend, og en PIN-kode vil blive sendt til dig. Skriv den her efter, for at fortsætte."
|
||||
"matrixEnterUser": "Skriv dit Bruger ID, tryk Indsend, og en PIN-kode vil blive sendt til dig. Skriv den her efter, for at fortsætte.",
|
||||
"referralsDescription": "Inviter venner og familie til Jellyfin med dette link. Kom tilbage her for et nyt, hvis det udløber.",
|
||||
"oldPassword": "Gammelt Kodeord",
|
||||
"newPassword": "Nyt Kodeord",
|
||||
"welcomeUser": "Velkommen, {user}!",
|
||||
"addContactMethod": "Tilføj Kontakt Metode",
|
||||
"editContactMethod": "Rediger Kontakt Metode",
|
||||
"joinTheServer": "Tilslut dig serveren:",
|
||||
"customMessagePlaceholderHeader": "Tilpas dette kort",
|
||||
"customMessagePlaceholderContent": "Klik på knappen Rediger brugersiden i indstillinger for at tilpasse dette kort, eller vis et på login-skærmen, og bare rolig, brugeren kan ikke se dette.",
|
||||
"userPageSuccessMessage": "Du kan se og ændre detaljer om din konto senere på {myAccount} siden.",
|
||||
"resetPassword": "Nulstille Kodeord",
|
||||
"resetPasswordThroughJellyfin": "For at nulstille din adgangskode skal du besøge {jfLink} og trykke på knappen \"Glemt adgangskode\".",
|
||||
"resetPasswordThroughLink": "For at nulstille din adgangskode skal du indtaste dit brugernavn, din e-mail adresse eller et linket brugernavn til en kontakt metode og indsende. Et link vil blive sendt for at nulstille din adgangskode.",
|
||||
"resetSent": "Nulstilling Sendt.",
|
||||
"resetSentDescription": "Hvis der findes en konto med det givne brugernavn/kontakt metode, er et link til nulstilling af adgangskode blevet sendt via alle tilgængelige kontakt metoder. Koden udløber om 30 minutter.",
|
||||
"changePassword": "Skift Kodeord",
|
||||
"copyReferral": "Kopier Link",
|
||||
"invitedBy": "Du blev inviteret af brugeren {user}."
|
||||
},
|
||||
"notifications": {
|
||||
"errorUserExists": "Brugeren eksistere allerede.",
|
||||
@ -33,7 +51,11 @@
|
||||
"errorNoEmail": "E-mail er påkrævet.",
|
||||
"errorCaptcha": "Forkert Captcha.",
|
||||
"errorPassword": "Tjek krav til adgangskode.",
|
||||
"errorNoMatch": "Adgangskoder stemmer ikke overens."
|
||||
"errorNoMatch": "Adgangskoder stemmer ikke overens.",
|
||||
"errorEmailLinked": "E-mail er allerede i brug.",
|
||||
"errorAccountLinked": "Kontoen er allerede i brug.",
|
||||
"errorOldPassword": "Den gamle adgangskode er forkert.",
|
||||
"passwordChanged": "Adgangskode Ændret."
|
||||
},
|
||||
"validationStrings": {
|
||||
"length": {
|
||||
@ -57,4 +79,4 @@
|
||||
"plural": "Skal mindst have {n} specialtegn"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -27,7 +27,17 @@
|
||||
"editContactMethod": "Wijzig contact methode",
|
||||
"joinTheServer": "Word lid van de server:",
|
||||
"resetPassword": "Wachtwoord opnieuw instellen",
|
||||
"changePassword": "Wachtwoord wijzigen"
|
||||
"changePassword": "Wachtwoord wijzigen",
|
||||
"resetSentDescription": "Als er een account met de opgegeven gebruikersnaam/contactmethode bestaat, is er een wachtwoordreset-link verstuurd via alle bekende contactmethodes. De link is 30 minuten geldig.",
|
||||
"customMessagePlaceholderHeader": "Kaart aanpassen",
|
||||
"customMessagePlaceholderContent": "Klik op de gebruikerspagina aanpassen knop in instellingen om deze kaart aan te passen, of om op het loginscherm te tonen. En wees maar niet bang: de gebruiker kan dit niet zien.",
|
||||
"userPageSuccessMessage": "Je kunt details van je account later bekijken en aanpassen op de {myAccount} pagina.",
|
||||
"resetPasswordThroughJellyfin": "Om je wachtwoord te resetten, ga naar {jfLink} en druk op de \"Wachtwoord vergeten\" knop.",
|
||||
"resetPasswordThroughLink": "Om je wachtwoord te resetten, vul je gebruikersnaam, e-mailadres of gebruikersnaam van een gelinkte contactmethode in, en verstuur. Er wordt een wachtwoord-reset link gestuurd.",
|
||||
"resetSent": "Reset-link verstuurd.",
|
||||
"referralsDescription": "Nodig vrienden en familie uit met deze link. Kom hier terug voor een nieuwe als hij verloopt.",
|
||||
"copyReferral": "Kopieer link",
|
||||
"invitedBy": "Je bent uitgenodigd door gebruiker {user}."
|
||||
},
|
||||
"validationStrings": {
|
||||
"length": {
|
||||
|
@ -11,7 +11,7 @@
|
||||
"disabled": "Deaktiveret",
|
||||
"enabled": "Aktiveret",
|
||||
"port": "Port",
|
||||
"message": "Meddelelse",
|
||||
"message": "Besked",
|
||||
"serverAddress": "Serveradresse",
|
||||
"emailSubject": "E-mail emne",
|
||||
"URL": "URL",
|
||||
@ -20,7 +20,9 @@
|
||||
"errorInvalidUserPass": "Ugyldigt brugernavn/adgangskode.",
|
||||
"errorUserDisabled": "Bruger kan være deaktiveret.",
|
||||
"error404": "404, tjek den interne URL.",
|
||||
"errorConnectionRefused": "Tilslutning afvist."
|
||||
"errorConnectionRefused": "Tilslutning afvist.",
|
||||
"error": "Fejl",
|
||||
"errorUnknown": "Ukendt fejl, tjek app logfiler."
|
||||
},
|
||||
"startPage": {
|
||||
"welcome": "Velkommen!",
|
||||
@ -30,7 +32,7 @@
|
||||
},
|
||||
"endPage": {
|
||||
"finished": "Færdig!",
|
||||
"restartMessage": "Du kan konfigurere Discord/Telegram/Matrix bots, tilpasse dine meddelelser og mere i Indstillinger. Klik herunder for at genstarte, og opdater siden.",
|
||||
"restartMessage": "Funktioner som Discord/Telegram/Matrix bots, brugerdefinerede Markdown beskeder og en brugertilgængelig side \"Min konto\" kan findes i Indstillinger, så sørg for at gennemse den. Klik nedenfor for at genstarte, og opdater derefter siden.",
|
||||
"refreshPage": "Opdater"
|
||||
},
|
||||
"language": {
|
||||
@ -68,7 +70,8 @@
|
||||
"adminOnly": "Kun administratorbrugere (anbefalet)",
|
||||
"emailNotice": "Din e-mail adresse kan bruges til at modtage underretninger.",
|
||||
"allowAll": "Tillad alle Jellyfin brugere at logge ind",
|
||||
"allowAllDescription": "Det anbefales ikke, du bør tillade individuelle brugere at logge ind, når de er konfigureret."
|
||||
"allowAllDescription": "Det anbefales ikke, du bør tillade individuelle brugere at logge ind, når de er konfigureret.",
|
||||
"authorizeManualUserPageNotice": "Brug af dette vil deaktivere \"Brugerside\" funktionen."
|
||||
},
|
||||
"jellyfinEmby": {
|
||||
"title": "Jellyfin/Emby",
|
||||
@ -113,14 +116,15 @@
|
||||
},
|
||||
"passwordResets": {
|
||||
"title": "Nulstilling af Adgangskoder",
|
||||
"description": "Når en bruger forsøger at nulstille deres adgangskode, opretter Jellyfin en fil med navnet 'passwordreset - *. Json', som indeholder en PIN-kode. jfa-go læser filen og sender PIN-koden til brugeren.",
|
||||
"description": "Når en bruger forsøger at nulstille deres adgangskode, opretter Jellyfin en fil med navnet 'passwordreset - *. Json', som indeholder en Pinkode. jfa-go læser filen og sender Pinkoden til brugeren. jfa-go læser filen og sender Pinkoden til brugeren. Hvis du aktiverede funktionen \"Brugerside\", kan en nulstilling også udføres der, givet et brugernavn, email eller kontaktmetode.",
|
||||
"pathToJellyfin": "Sti til Jellyfin's konfigurations mappe",
|
||||
"pathToJellyfinNotice": "Hvis du ikke ved hvor dette er, kan du prøve at nulstille din adgangskode i Jellyfin. En popup med '<sti til jellyfin>/passwordreset - *. Json' vises.",
|
||||
"pathToJellyfinNotice": "Hvis du ikke ved hvor dette er, kan du prøve at nulstille din adgangskode i Jellyfin. En popup med '<sti til jellyfin>/passwordreset - *. Json' vises. Dette er ikke nødvendigt, hvis du kun ønsker at bruge selvbetjenings nulstilling af adgangskode via \"Brugersiden\".",
|
||||
"resetLinks": "Send et link i stedet for en PIN-kode",
|
||||
"resetLinksNotice": "Hvis Ombi integration er aktiveret, skal du bruge denne til at synkronisere nulstilling af Jellyfin's adgangskode med Ombi.",
|
||||
"resetLinksLanguage": "Standard sprog til nulstillings link",
|
||||
"setPassword": "Angiv adgangskode gennem link",
|
||||
"setPasswordNotice": "Aktivering af dette betyder at brugeren ikke behøver at ændre sin adgangskode væk fra pinkoden efter nulstillingen. Adgangskodevalidering håndhæves også."
|
||||
"setPasswordNotice": "Aktivering af dette betyder at brugeren ikke behøver at ændre sin adgangskode væk fra pinkoden efter nulstillingen. Adgangskodevalidering håndhæves også.",
|
||||
"resetLinksRequiredForUserPage": "Nødvendig for nulstilling af selvbetjenings adgangskode på brugersiden."
|
||||
},
|
||||
"passwordValidation": {
|
||||
"title": "Validering af adgangskode",
|
||||
@ -146,5 +150,11 @@
|
||||
"messages": {
|
||||
"title": "Beskeder",
|
||||
"description": "jfa-go kan sende nulstilling af adgangskoder og forskellige meddelelser via E-mail, Discord, Telegram og/eller Matrix. Du kan konfigurere E-mail herunder, og de andre kan konfigureres i Indstillinger senere. Instruktioner kan findes på {n}. Hvis du ikke har brug for dette, kan du deaktivere disse funktioner her."
|
||||
},
|
||||
"userPage": {
|
||||
"description": "Brugersiden (vist som \"Min konto\") giver brugerne adgang til oplysninger om deres konto, såsom deres kontaktmetoder og kontoudløb. De kan også ændre deres adgangskode, starte en nulstilling af adgangskode og linke/ændre kontaktmetoder uden at skulle spørge dig. Derudover kan tilpassede Markdown beskeder vises til brugerne før og efter login.",
|
||||
"title": "Brugerside",
|
||||
"customizeMessages": "Klik på redigeringsknappen ved siden af \"Brugerside\" i indstillingerne for at indstille dem senere.",
|
||||
"requiredSettings": "Log ind på jfa-go via Jellyfin skal indstilles. Sørg for, at \"nulstil adgangskode via link\" er valgt senere for selvbetjenings nulstilling af adgangskode."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -20,7 +20,9 @@
|
||||
"errorUserDisabled": "De gebruiker kan uitgeschakeld zijn.",
|
||||
"error404": "404, controleer de interne URL.",
|
||||
"errorNotAdmin": "Gebruiker heeft geen beheersrechten.",
|
||||
"errorConnectionRefused": "Verbinding geweigerd."
|
||||
"errorConnectionRefused": "Verbinding geweigerd.",
|
||||
"errorUnknown": "Onbekende fout, bekijk de logs.",
|
||||
"error": "Fout"
|
||||
},
|
||||
"startPage": {
|
||||
"welcome": "Welkom!",
|
||||
@ -30,7 +32,7 @@
|
||||
},
|
||||
"endPage": {
|
||||
"finished": "Klaar!",
|
||||
"restartMessage": "Je kunt Discord/Telegram/Matrix bots instellen, berichten aanpassen en meer bij Instellingen. Klik hieronder om te herstarten, en ververs de pagina.",
|
||||
"restartMessage": "Instellingen als Discord/Telegram/Matrix bots, aangepaste Markdown berichten, en een gebruiker-toegankelijke \"Mijn Account\" pagina zijn te vinden onder \"Instellingen\", dus kijk daar even rond. Klik hieronder om te herstarten, en ververs de pagina.",
|
||||
"refreshPage": "Verversen"
|
||||
},
|
||||
"language": {
|
||||
@ -61,7 +63,8 @@
|
||||
"adminOnly": "Alleen beheerders (aanbevolen)",
|
||||
"emailNotice": "Je e-mailadres kan gebruikt worden om meldingen te ontvangen.",
|
||||
"allowAll": "Laat alle Jellyfin-gebruikers inloggen",
|
||||
"allowAllDescription": "Afgeraden, je kunt beter individuele gebruikers toegang geven na de setup."
|
||||
"allowAllDescription": "Afgeraden, je kunt beter individuele gebruikers toegang geven na de setup.",
|
||||
"authorizeManualUserPageNotice": "Gebruik hiervan schakelt de \"Gebruikerspagina\" functie uit."
|
||||
},
|
||||
"jellyfinEmby": {
|
||||
"title": "Jellyfin/Emby",
|
||||
@ -106,14 +109,15 @@
|
||||
},
|
||||
"passwordResets": {
|
||||
"title": "Wachtwoordresets",
|
||||
"description": "Wanneer een gebruiker een wachtwoordreset aanvraagt, maakt Jellyfin een bestand aan dat 'passwordreset-*.json' heet en een pincode bevat. jfa-go leest dit bestand uit en stuurt de pincode naar de gebruiker.",
|
||||
"description": "Wanneer een gebruiker een wachtwoordreset aanvraagt, maakt Jellyfin een bestand aan dat 'passwordreset-*.json' heet en een pincode bevat. jfa-go leest dit bestand uit en stuurt de pincode naar de gebruiker. Als je de \"Gebruikerspagina\" functionaliteit inschakelt, kan een reset ook daar worden gedaan aan de hand van een gebruikersnaam, e-mail, of contactmethode.",
|
||||
"pathToJellyfin": "Pad naar Jellyfin configuratiemap",
|
||||
"pathToJellyfinNotice": "Als je niet weet waar dit is, probeer de je wachtwoord te resetten in Jellyfin. Er verschijnt dan een popup met '<path to jellyfin>/passwordreset-*.json'.",
|
||||
"pathToJellyfinNotice": "Als je niet weet waar dit is, probeer de je wachtwoord te resetten in Jellyfin. Er verschijnt dan een popup met '<path to jellyfin>/passwordreset-*.json'. Dit is niet nodig als je alleen zelfzervice wachtwoordresets via de \"Gebruikerspagina\" wilt gebruiken.",
|
||||
"resetLinks": "Stuur een link in plaats van een pincode",
|
||||
"resetLinksNotice": "Als Ombi-integratie is ingeschakeld, gebruik dan dit om Jellyfin wachtwoordresets te synchroniseren met Ombi.",
|
||||
"resetLinksLanguage": "Standaard reset-link taal",
|
||||
"setPassword": "Stel wachtwoord in via link",
|
||||
"setPasswordNotice": "Als dit aanstaat hoeft de gebruiker het wachtwoord niet te wijzigen van de PINcode na de reset. Wachtwoordvalidatie wordt ook afgedwongen."
|
||||
"setPasswordNotice": "Als dit aanstaat hoeft de gebruiker het wachtwoord niet te wijzigen van de PINcode na de reset. Wachtwoordvalidatie wordt ook afgedwongen.",
|
||||
"resetLinksRequiredForUserPage": "Nodig voor zelfservice wachtwoordreset op de gebruikerspagina."
|
||||
},
|
||||
"passwordValidation": {
|
||||
"title": "Wachtwoordvalidatie",
|
||||
@ -146,5 +150,11 @@
|
||||
"messages": {
|
||||
"title": "Berichten",
|
||||
"description": "jfa-go kan wachtwoordresets en verschillende berichten sturen via E-mail, Discord, Telegram, en/of Matrix. Je kunt e-mail hieronder instellen, en de rest kan later bij Instellingen aangepast worden. Instructies staan op de {n}. Als je dit niet nodig hebt, kun je deze onderdelen hier uitschakelen."
|
||||
},
|
||||
"userPage": {
|
||||
"description": "De gebruikerspagina (getoond als \"Mijn account\") geeft gebruikers toegang tot informatie over hun account, zoals contactmethodes en verloopdatum. Ze kunnen ook hun wachtwoord wijzigen, een wachtwoord-reset starten, en contactmethodes koppelen/wijzigen zonder het jou te hoeven vragen. Bovendien kunnen aanpasbare MArkdown berichten worden getoond aan gebruikers voor en na het inloggen.",
|
||||
"title": "Gebruikerspagina",
|
||||
"customizeMessages": "Gebruik de bewerken knop naast \"Gebruikerspagina\" in de instellingen om dit later in te stellen.",
|
||||
"requiredSettings": "Inloggen bij jfa-go via Jellyfin moet ingesteld zijn. Controleer dat \"reset wachtwoord via link\" later wordt gekozen voor zelfservice wachtwoord-resets."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -9,8 +9,10 @@
|
||||
"pinSuccess": "Sådan! Du kan nu gå tilbage til tilmeldingssiden.",
|
||||
"languageMessage": "Note: Se tilgængelige sprog med {command}, og vælg sprog med {command} <sprog kode>.",
|
||||
"discordStartMessage": "Hej!\n Indtast din pinkode med `/pin <PIN>` for at bekræfte din konto.",
|
||||
"languageMessageDiscord": "Bemærk: Indstil dit sprog med /lang <sprognavn>.",
|
||||
"languageSet": "Sprog indstillet til {language}.",
|
||||
"discordDMs": "Tjek venligst dine DM's for et svar."
|
||||
"languageMessageDiscord": "Note: Sæt dit sprog med /lang <sprog navn>.",
|
||||
"languageSet": "Sprog sat til {language}.",
|
||||
"discordDMs": "Tjek venligst dine DMs for et svar.",
|
||||
"sentInvite": "Invitation Sendt.",
|
||||
"sentInviteFailure": "Kunne ikke sende invitation, tjek logfilerne."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -11,6 +11,8 @@
|
||||
"discordStartMessage": "Hallo!\nVoer je pincode in met `/pin <PINCODE>` om je account te verifiëren.",
|
||||
"languageMessageDiscord": "Opmerking: stel je taal in met /lang <taal>.",
|
||||
"languageSet": "Taal ingesteld als {language}.",
|
||||
"discordDMs": "Bekijk alsjeblieft je DMs voor een antwoord."
|
||||
"discordDMs": "Bekijk alsjeblieft je DMs voor een antwoord.",
|
||||
"sentInviteFailure": "Fout bij versturen uitnodiging, bekijk de logs.",
|
||||
"sentInvite": "Uitnodiging verstuurd."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
3
main.go
3
main.go
@ -638,6 +638,9 @@ func flagPassed(name string) (found bool) {
|
||||
// @tag.name Profiles & Settings
|
||||
// @tag.description Profile and settings related operations.
|
||||
|
||||
// @tag.name Activity
|
||||
// @tag.description Routes related to the activity log.
|
||||
|
||||
// @tag.name Configuration
|
||||
// @tag.description jfa-go settings.
|
||||
|
||||
|
29
models.go
29
models.go
@ -430,3 +430,32 @@ type GetMyReferralRespDTO struct {
|
||||
type EnableDisableReferralDTO struct {
|
||||
Users []string `json:"users"`
|
||||
}
|
||||
|
||||
type ActivityDTO struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
SourceType string `json:"source_type"`
|
||||
Source string `json:"source"`
|
||||
SourceUsername string `json:"source_username"`
|
||||
InviteCode string `json:"invite_code"`
|
||||
Value string `json:"value"`
|
||||
Time int64 `json:"time"`
|
||||
}
|
||||
|
||||
type GetActivitiesDTO struct {
|
||||
Type []string `json:"type"` // Types of activity to get. Leave blank for all.
|
||||
Limit int `json:"limit"`
|
||||
Page int `json:"page"` // zero-indexed
|
||||
Ascending bool `json:"ascending"`
|
||||
}
|
||||
|
||||
type GetActivitiesRespDTO struct {
|
||||
Activities []ActivityDTO `json:"activities"`
|
||||
LastPage bool `json:"last_page"`
|
||||
}
|
||||
|
||||
type GetActivityCountDTO struct {
|
||||
Count uint64 `json:"count"`
|
||||
}
|
||||
|
@ -118,6 +118,9 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
|
||||
|
||||
router.GET(p+"/accounts", app.AdminPage)
|
||||
router.GET(p+"/settings", app.AdminPage)
|
||||
router.GET(p+"/activity", app.AdminPage)
|
||||
router.GET(p+"/accounts/user/:userID", app.AdminPage)
|
||||
router.GET(p+"/invites/:code", app.AdminPage)
|
||||
router.GET(p+"/lang/:page/:file", app.ServeLang)
|
||||
router.GET(p+"/token/login", app.getTokenLogin)
|
||||
router.GET(p+"/token/refresh", app.getTokenRefresh)
|
||||
@ -232,6 +235,10 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
|
||||
api.DELETE(p+"/profiles/referral/:profile", app.DisableReferralForProfile)
|
||||
}
|
||||
|
||||
api.POST(p+"/activity", app.GetActivities)
|
||||
api.DELETE(p+"/activity/:id", app.DeleteActivity)
|
||||
api.GET(p+"/activity/count", app.GetActivityCount)
|
||||
|
||||
if userPageEnabled {
|
||||
user.GET("/details", app.MyDetails)
|
||||
user.POST("/contact", app.SetMyContactMethods)
|
||||
|
12
site/package-lock.json
generated
12
site/package-lock.json
generated
@ -4462,9 +4462,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
|
||||
"integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz",
|
||||
"integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@ -7828,9 +7828,9 @@
|
||||
}
|
||||
},
|
||||
"word-wrap": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
|
||||
"integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ=="
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz",
|
||||
"integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA=="
|
||||
},
|
||||
"wrappy": {
|
||||
"version": "1.0.2",
|
||||
|
62
storage.go
62
storage.go
@ -21,6 +21,42 @@ type telegramStore map[string]TelegramUser
|
||||
type matrixStore map[string]MatrixUser
|
||||
type emailStore map[string]EmailAddress
|
||||
|
||||
type ActivityType int
|
||||
|
||||
const (
|
||||
ActivityCreation ActivityType = iota
|
||||
ActivityDeletion
|
||||
ActivityDisabled
|
||||
ActivityEnabled
|
||||
ActivityContactLinked
|
||||
ActivityContactUnlinked
|
||||
ActivityChangePassword
|
||||
ActivityResetPassword
|
||||
ActivityCreateInvite
|
||||
ActivityDeleteInvite
|
||||
ActivityUnknown
|
||||
)
|
||||
|
||||
type ActivitySource int
|
||||
|
||||
const (
|
||||
ActivityUser ActivitySource = iota // Source = UserID. For ActivityCreation, this would mean the referrer.
|
||||
ActivityAdmin // Source = Admin's UserID, or blank if jellyfin login isn't on.
|
||||
ActivityAnon // Source = Blank, or potentially browser info. For ActivityCreation, this would be via an invite
|
||||
ActivityDaemon // Source = Blank, was deleted/disabled due to expiry by daemon
|
||||
)
|
||||
|
||||
type Activity struct {
|
||||
ID string `badgerhold:"key"`
|
||||
Type ActivityType `badgerhold:"index"`
|
||||
UserID string // ID of target user. For account creation, this will be the newly created account
|
||||
SourceType ActivitySource
|
||||
Source string
|
||||
InviteCode string // Set for ActivityCreation, create/deleteInvite
|
||||
Value string // Used for ActivityContactLinked where it's "email/discord/telegram/matrix", Create/DeleteInvite, where it's the label, and Creation/Deletion, where it's the Username.
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
type UserExpiry struct {
|
||||
JellyfinID string `badgerhold:"key"`
|
||||
Expiry time.Time
|
||||
@ -514,6 +550,32 @@ func (st *Storage) DeleteCustomContentKey(k string) {
|
||||
st.db.Delete(k, CustomContent{})
|
||||
}
|
||||
|
||||
// GetActivityKey returns the value stored in the store's key.
|
||||
func (st *Storage) GetActivityKey(k string) (Activity, bool) {
|
||||
result := Activity{}
|
||||
err := st.db.Get(k, &result)
|
||||
ok := true
|
||||
if err != nil {
|
||||
// fmt.Printf("Failed to find custom content: %v\n", err)
|
||||
ok = false
|
||||
}
|
||||
return result, ok
|
||||
}
|
||||
|
||||
// SetActivityKey stores value v in key k.
|
||||
func (st *Storage) SetActivityKey(k string, v Activity) {
|
||||
v.ID = k
|
||||
err := st.db.Upsert(k, v)
|
||||
if err != nil {
|
||||
// fmt.Printf("Failed to set custom content: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteActivityKey deletes value at key k.
|
||||
func (st *Storage) DeleteActivityKey(k string) {
|
||||
st.db.Delete(k, Activity{})
|
||||
}
|
||||
|
||||
type TelegramUser struct {
|
||||
JellyfinID string `badgerhold:"key"`
|
||||
ChatID int64 `badgerhold:"index"`
|
||||
|
32
ts/admin.ts
32
ts/admin.ts
@ -5,6 +5,7 @@ import { Tabs } from "./modules/tabs.js";
|
||||
import { inviteList, createInvite } from "./modules/invites.js";
|
||||
import { accountsList } from "./modules/accounts.js";
|
||||
import { settingsList } from "./modules/settings.js";
|
||||
import { activityList } from "./modules/activity.js";
|
||||
import { ProfileEditor } from "./modules/profiles.js";
|
||||
import { _get, _post, notificationBox, whichAnimationEvent } from "./modules/common.js";
|
||||
import { Updater } from "./modules/update.js";
|
||||
@ -89,6 +90,8 @@ var inviteCreator = new createInvite();
|
||||
|
||||
var accounts = new accountsList();
|
||||
|
||||
var activity = new activityList();
|
||||
|
||||
window.invites = new inviteList();
|
||||
|
||||
var settings = new settingsList();
|
||||
@ -120,6 +123,10 @@ const tabs: { url: string, reloader: () => void }[] = [
|
||||
url: "accounts",
|
||||
reloader: accounts.reload
|
||||
},
|
||||
{
|
||||
url: "activity",
|
||||
reloader: activity.reload
|
||||
},
|
||||
{
|
||||
url: "settings",
|
||||
reloader: settings.reload
|
||||
@ -137,6 +144,9 @@ for (let tab of tabs) {
|
||||
}
|
||||
}
|
||||
|
||||
let isInviteURL = window.invites.isInviteURL();
|
||||
let isAccountURL = accounts.isAccountURL();
|
||||
|
||||
// Default tab
|
||||
if ((window.URLBase + "/").includes(window.location.pathname)) {
|
||||
window.tabs.switch(defaultTab.url, true);
|
||||
@ -146,7 +156,9 @@ document.addEventListener("tab-change", (event: CustomEvent) => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const lang = urlParams.get('lang');
|
||||
let tab = window.URLBase + "/" + event.detail;
|
||||
if (tab == window.URLBase + "/invites") {
|
||||
if (event.detail == "") {
|
||||
tab = window.location.pathname;
|
||||
} else if (tab == window.URLBase + "/invites") {
|
||||
if (window.location.pathname == window.URLBase + "/") {
|
||||
tab = window.URLBase + "/";
|
||||
} else if (window.URLBase) { tab = window.URLBase; }
|
||||
@ -167,6 +179,7 @@ const login = new Login(window.modals.login as Modal, "/", window.loginAppearanc
|
||||
login.onLogin = () => {
|
||||
console.log("Logged in.");
|
||||
window.updater = new Updater();
|
||||
// FIXME: Decide whether to autoload activity or not
|
||||
setInterval(() => { window.invites.reload(); accounts.reload(); }, 30*1000);
|
||||
const currentTab = window.tabs.current;
|
||||
switch (currentTab) {
|
||||
@ -179,6 +192,23 @@ login.onLogin = () => {
|
||||
case "settings":
|
||||
settings.reload();
|
||||
break;
|
||||
case "activity": // FIXME: fix URL clash with route
|
||||
activity.reload();
|
||||
break;
|
||||
default:
|
||||
console.log(isAccountURL, isInviteURL);
|
||||
if (isInviteURL) {
|
||||
window.invites.reload(() => {
|
||||
window.invites.loadInviteURL();
|
||||
window.tabs.switch("invites", false, true);
|
||||
});
|
||||
} else if (isAccountURL) {
|
||||
accounts.reload(() => {
|
||||
accounts.loadAccountURL();
|
||||
window.tabs.switch("accounts", false, true);
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -3,6 +3,7 @@ import { templateEmail } from "../modules/settings.js";
|
||||
import { Marked } from "@ts-stack/markdown";
|
||||
import { stripMarkdown } from "../modules/stripmd.js";
|
||||
import { DiscordUser, newDiscordSearch } from "../modules/discord.js";
|
||||
import { Search, SearchConfiguration, QueryType, SearchableItem } from "../modules/search.js";
|
||||
const dateParser = require("any-date-parser");
|
||||
|
||||
interface User {
|
||||
@ -39,7 +40,7 @@ interface announcementTemplate {
|
||||
|
||||
var addDiscord: (passData: string) => void;
|
||||
|
||||
class user implements User {
|
||||
class user implements User, SearchableItem {
|
||||
private _id = "";
|
||||
private _row: HTMLTableRowElement;
|
||||
private _check: HTMLInputElement;
|
||||
@ -73,6 +74,8 @@ class user implements User {
|
||||
private _referralsEnabled: boolean;
|
||||
private _referralsEnabledCheck: HTMLElement;
|
||||
|
||||
focus = () => this._row.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
|
||||
lastNotifyMethod = (): string => {
|
||||
// Telegram, Matrix, Discord
|
||||
const telegram = window.telegramEnabled && this._telegramUsername && this._telegramUsername != "";
|
||||
@ -269,7 +272,7 @@ class user implements User {
|
||||
<span class="chip btn @low"><i class="ri-link" alt="${window.lang.strings("add")}"></i></span>
|
||||
<input type="text" class="input ~neutral @low stealth-input unfocused" placeholder="@user:riot.im">
|
||||
</div>
|
||||
`;
|
||||
`;
|
||||
(this._matrix.querySelector("span") as HTMLSpanElement).onclick = this._addMatrix;
|
||||
} else {
|
||||
this._notifyDropdown.querySelector(".accounts-area-matrix").classList.remove("unfocused");
|
||||
@ -780,13 +783,13 @@ export class accountsList {
|
||||
private _userSelect = document.getElementById("modify-user-users") as HTMLSelectElement;
|
||||
private _referralsProfileSelect = document.getElementById("enable-referrals-user-profiles") as HTMLSelectElement;
|
||||
private _referralsInviteSelect = document.getElementById("enable-referrals-user-invites") as HTMLSelectElement;
|
||||
private _search = document.getElementById("accounts-search") as HTMLInputElement;
|
||||
private _searchBox = document.getElementById("accounts-search") as HTMLInputElement;
|
||||
private _search: Search;
|
||||
|
||||
private _selectAll = document.getElementById("accounts-select-all") as HTMLInputElement;
|
||||
private _users: { [id: string]: user };
|
||||
private _ordering: string[] = [];
|
||||
private _checkCount: number = 0;
|
||||
private _inSearch = false;
|
||||
// Whether the enable/disable button should enable or not.
|
||||
private _shouldEnable = false;
|
||||
|
||||
@ -836,7 +839,7 @@ export class accountsList {
|
||||
}
|
||||
}
|
||||
|
||||
private _queries: { [field: string]: { name: string, getter: string, bool: boolean, string: boolean, date: boolean, dependsOnTableHeader?: string, show?: boolean }} = {
|
||||
private _queries: { [field: string]: QueryType } = {
|
||||
"id": {
|
||||
// We don't use a translation here to circumvent the name substitution feature.
|
||||
name: "Jellyfin/Emby ID",
|
||||
@ -887,7 +890,7 @@ export class accountsList {
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false,
|
||||
dependsOnTableHeader: "accounts-header-access-jfa"
|
||||
dependsOnElement: ".accounts-header-access-jfa"
|
||||
},
|
||||
"email": {
|
||||
name: window.lang.strings("emailAddress"),
|
||||
@ -895,7 +898,7 @@ export class accountsList {
|
||||
bool: true,
|
||||
string: true,
|
||||
date: false,
|
||||
dependsOnTableHeader: "accounts-header-email"
|
||||
dependsOnElement: ".accounts-header-email"
|
||||
},
|
||||
"telegram": {
|
||||
name: "Telegram",
|
||||
@ -903,7 +906,7 @@ export class accountsList {
|
||||
bool: true,
|
||||
string: true,
|
||||
date: false,
|
||||
dependsOnTableHeader: "accounts-header-telegram"
|
||||
dependsOnElement: ".accounts-header-telegram"
|
||||
},
|
||||
"matrix": {
|
||||
name: "Matrix",
|
||||
@ -911,7 +914,7 @@ export class accountsList {
|
||||
bool: true,
|
||||
string: true,
|
||||
date: false,
|
||||
dependsOnTableHeader: "accounts-header-matrix"
|
||||
dependsOnElement: ".accounts-header-matrix"
|
||||
},
|
||||
"discord": {
|
||||
name: "Discord",
|
||||
@ -919,7 +922,7 @@ export class accountsList {
|
||||
bool: true,
|
||||
string: true,
|
||||
date: false,
|
||||
dependsOnTableHeader: "accounts-header-discord"
|
||||
dependsOnElement: ".accounts-header-discord"
|
||||
},
|
||||
"expiry": {
|
||||
name: window.lang.strings("expiry"),
|
||||
@ -927,7 +930,7 @@ export class accountsList {
|
||||
bool: true,
|
||||
string: false,
|
||||
date: true,
|
||||
dependsOnTableHeader: "accounts-header-expiry"
|
||||
dependsOnElement: ".accounts-header-expiry"
|
||||
},
|
||||
"last-active": {
|
||||
name: window.lang.strings("lastActiveTime"),
|
||||
@ -942,229 +945,12 @@ export class accountsList {
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false,
|
||||
dependsOnTableHeader: "accounts-header-referrals"
|
||||
dependsOnElement: ".accounts-header-referrals"
|
||||
}
|
||||
}
|
||||
|
||||
private _notFoundPanel: HTMLElement = document.getElementById("accounts-not-found");
|
||||
|
||||
search = (query: String): string[] => {
|
||||
console.log(this._queries);
|
||||
this._filterArea.textContent = "";
|
||||
|
||||
query = query.toLowerCase();
|
||||
let result: string[] = [...this._ordering];
|
||||
// console.log("initial:", result);
|
||||
|
||||
// const words = query.split(" ");
|
||||
let words: string[] = [];
|
||||
|
||||
let quoteSymbol = ``;
|
||||
let queryStart = -1;
|
||||
let lastQuote = -1;
|
||||
for (let i = 0; i < query.length; i++) {
|
||||
if (queryStart == -1 && query[i] != " " && query[i] != `"` && query[i] != `'`) {
|
||||
queryStart = i;
|
||||
}
|
||||
if ((query[i] == `"` || query[i] == `'`) && (quoteSymbol == `` || query[i] == quoteSymbol)) {
|
||||
if (lastQuote != -1) {
|
||||
lastQuote = -1;
|
||||
quoteSymbol = ``;
|
||||
} else {
|
||||
lastQuote = i;
|
||||
quoteSymbol = query[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (query[i] == " " || i == query.length-1) {
|
||||
if (lastQuote != -1) {
|
||||
continue;
|
||||
} else {
|
||||
let end = i+1;
|
||||
if (query[i] == " ") {
|
||||
end = i;
|
||||
while (i+1 < query.length && query[i+1] == " ") {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
words.push(query.substring(queryStart, end).replace(/['"]/g, ""));
|
||||
console.log("pushed", words);
|
||||
queryStart = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query = "";
|
||||
for (let word of words) {
|
||||
if (!word.includes(":")) {
|
||||
let cachedResult = [...result];
|
||||
for (let id of cachedResult) {
|
||||
const u = this._users[id];
|
||||
if (!u.matchesSearch(word)) {
|
||||
result.splice(result.indexOf(id), 1);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const split = [word.substring(0, word.indexOf(":")), word.substring(word.indexOf(":")+1)];
|
||||
|
||||
if (!(split[0] in this._queries)) continue;
|
||||
|
||||
const queryFormat = this._queries[split[0]];
|
||||
|
||||
if (queryFormat.bool) {
|
||||
let isBool = false;
|
||||
let boolState = false;
|
||||
if (split[1] == "true" || split[1] == "yes" || split[1] == "t" || split[1] == "y") {
|
||||
isBool = true;
|
||||
boolState = true;
|
||||
} else if (split[1] == "false" || split[1] == "no" || split[1] == "f" || split[1] == "n") {
|
||||
isBool = true;
|
||||
boolState = false;
|
||||
}
|
||||
if (isBool) {
|
||||
const filterCard = document.createElement("span");
|
||||
filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter");
|
||||
filterCard.classList.add("button", "~" + (boolState ? "positive" : "critical"), "@high", "center", "mx-2", "h-full");
|
||||
filterCard.innerHTML = `
|
||||
<span class="font-bold mr-2">${queryFormat.name}</span>
|
||||
<i class="text-2xl ri-${boolState? "checkbox" : "close"}-circle-fill"></i>
|
||||
`;
|
||||
|
||||
filterCard.addEventListener("click", () => {
|
||||
for (let quote of [`"`, `'`, ``]) {
|
||||
this._search.value = this._search.value.replace(split[0] + ":" + quote + split[1] + quote, "");
|
||||
}
|
||||
this._search.oninput((null as Event));
|
||||
})
|
||||
|
||||
this._filterArea.appendChild(filterCard);
|
||||
|
||||
// console.log("is bool, state", boolState);
|
||||
// So removing elements doesn't affect us
|
||||
let cachedResult = [...result];
|
||||
for (let id of cachedResult) {
|
||||
const u = this._users[id];
|
||||
const value = Object.getOwnPropertyDescriptor(user.prototype, queryFormat.getter).get.call(u);
|
||||
// console.log("got", queryFormat.getter + ":", value);
|
||||
// Remove from result if not matching query
|
||||
if (!((value && boolState) || (!value && !boolState))) {
|
||||
// console.log("not matching, result is", result);
|
||||
result.splice(result.indexOf(id), 1);
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (queryFormat.string) {
|
||||
const filterCard = document.createElement("span");
|
||||
filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter");
|
||||
filterCard.classList.add("button", "~neutral", "@low", "center", "mx-2", "h-full");
|
||||
filterCard.innerHTML = `
|
||||
<span class="font-bold mr-2">${queryFormat.name}:</span> "${split[1]}"
|
||||
`;
|
||||
|
||||
filterCard.addEventListener("click", () => {
|
||||
for (let quote of [`"`, `'`, ``]) {
|
||||
let regex = new RegExp(split[0] + ":" + quote + split[1] + quote, "ig");
|
||||
this._search.value = this._search.value.replace(regex, "");
|
||||
}
|
||||
this._search.oninput((null as Event));
|
||||
})
|
||||
|
||||
this._filterArea.appendChild(filterCard);
|
||||
|
||||
let cachedResult = [...result];
|
||||
for (let id of cachedResult) {
|
||||
const u = this._users[id];
|
||||
const value = Object.getOwnPropertyDescriptor(user.prototype, queryFormat.getter).get.call(u);
|
||||
if (!(value.includes(split[1]))) {
|
||||
result.splice(result.indexOf(id), 1);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (queryFormat.date) {
|
||||
// -1 = Before, 0 = On, 1 = After, 2 = No symbol, assume 0
|
||||
let compareType = (split[1][0] == ">") ? 1 : ((split[1][0] == "<") ? -1 : ((split[1][0] == "=") ? 0 : 2));
|
||||
let unmodifiedValue = split[1];
|
||||
if (compareType != 2) {
|
||||
split[1] = split[1].substring(1);
|
||||
}
|
||||
if (compareType == 2) compareType = 0;
|
||||
|
||||
let attempt: { year?: number, month?: number, day?: number, hour?: number, minute?: number } = dateParser.attempt(split[1]);
|
||||
// Month in Date objects is 0-based, so make our parsed date that way too
|
||||
if ("month" in attempt) attempt.month -= 1;
|
||||
|
||||
let date: Date = (Date as any).fromString(split[1]) as Date;
|
||||
console.log("Read", attempt, "and", date);
|
||||
if ("invalid" in (date as any)) continue;
|
||||
|
||||
const filterCard = document.createElement("span");
|
||||
filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter");
|
||||
filterCard.classList.add("button", "~neutral", "@low", "center", "m-2", "h-full");
|
||||
filterCard.innerHTML = `
|
||||
<span class="font-bold mr-2">${queryFormat.name}:</span> ${(compareType == 1) ? window.lang.strings("after")+" " : ((compareType == -1) ? window.lang.strings("before")+" " : "")}${split[1]}
|
||||
`;
|
||||
|
||||
filterCard.addEventListener("click", () => {
|
||||
for (let quote of [`"`, `'`, ``]) {
|
||||
let regex = new RegExp(split[0] + ":" + quote + unmodifiedValue + quote, "ig");
|
||||
this._search.value = this._search.value.replace(regex, "");
|
||||
}
|
||||
|
||||
this._search.oninput((null as Event));
|
||||
})
|
||||
|
||||
this._filterArea.appendChild(filterCard);
|
||||
|
||||
let cachedResult = [...result];
|
||||
for (let id of cachedResult) {
|
||||
const u = this._users[id];
|
||||
const unixValue = Object.getOwnPropertyDescriptor(user.prototype, queryFormat.getter).get.call(u);
|
||||
if (unixValue == 0) {
|
||||
result.splice(result.indexOf(id), 1);
|
||||
continue;
|
||||
}
|
||||
let value = new Date(unixValue*1000);
|
||||
|
||||
const getterPairs: [string, () => number][] = [["year", Date.prototype.getFullYear], ["month", Date.prototype.getMonth], ["day", Date.prototype.getDate], ["hour", Date.prototype.getHours], ["minute", Date.prototype.getMinutes]];
|
||||
|
||||
// When doing > or < <time> with no date, we need to ignore the rest of the Date object
|
||||
if (compareType != 0 && Object.keys(attempt).length == 2 && "hour" in attempt && "minute" in attempt) {
|
||||
const temp = new Date(date.valueOf());
|
||||
temp.setHours(value.getHours(), value.getMinutes());
|
||||
value = temp;
|
||||
console.log("just hours/minutes workaround, value set to", value);
|
||||
}
|
||||
|
||||
|
||||
let match = true;
|
||||
if (compareType == 0) {
|
||||
for (let pair of getterPairs) {
|
||||
if (pair[0] in attempt) {
|
||||
if (compareType == 0 && attempt[pair[0]] != pair[1].call(value)) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (compareType == -1) {
|
||||
match = (value < date);
|
||||
} else if (compareType == 1) {
|
||||
match = (value > date);
|
||||
}
|
||||
if (!match) {
|
||||
result.splice(result.indexOf(id), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
|
||||
get selectAll(): boolean { return this._selectAll.checked; }
|
||||
set selectAll(state: boolean) {
|
||||
let count = 0;
|
||||
@ -1894,6 +1680,25 @@ export class accountsList {
|
||||
this._addUserProfile.innerHTML = innerHTML;
|
||||
}
|
||||
|
||||
focusAccount = (userID: string) => {
|
||||
console.log("focusing user", userID);
|
||||
this._searchBox.value = `id:"${userID}"`;
|
||||
this._search.onSearchBoxChange();
|
||||
if (userID in this._users) this._users[userID].focus();
|
||||
}
|
||||
|
||||
public static readonly _accountURLEvent = "account-url";
|
||||
registerURLListener = () => document.addEventListener(accountsList._accountURLEvent, (event: CustomEvent) => {
|
||||
this.focusAccount(event.detail);
|
||||
});
|
||||
|
||||
isAccountURL = () => { return window.location.pathname.startsWith(window.URLBase + "/accounts/user/"); }
|
||||
|
||||
loadAccountURL = () => {
|
||||
let userID = window.location.pathname.split(window.URLBase + "/accounts/user/")[1].split("?lang")[0];
|
||||
this.focusAccount(userID);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this._populateNumbers();
|
||||
this._users = {};
|
||||
@ -1901,7 +1706,7 @@ export class accountsList {
|
||||
this._selectAll.onchange = () => {
|
||||
this.selectAll = this._selectAll.checked;
|
||||
};
|
||||
document.addEventListener("accounts-reload", this.reload);
|
||||
document.addEventListener("accounts-reload", () => this.reload());
|
||||
document.addEventListener("accountCheckEvent", () => { this._checkCount++; this._checkCheckCount(); });
|
||||
document.addEventListener("accountUncheckEvent", () => { this._checkCount--; this._checkCheckCount(); });
|
||||
this._addUserButton.onclick = () => {
|
||||
@ -2014,34 +1819,23 @@ export class accountsList {
|
||||
this._deleteNotify.checked = false;
|
||||
}*/
|
||||
|
||||
const onchange = () => {
|
||||
const query = this._search.value;
|
||||
if (!query) {
|
||||
// this.setVisibility(this._ordering, true);
|
||||
this._inSearch = false;
|
||||
} else {
|
||||
this._inSearch = true;
|
||||
// this.setVisibility(this.search(query), true);
|
||||
}
|
||||
const results = this.search(query);
|
||||
this.setVisibility(results, true);
|
||||
this._checkCheckCount();
|
||||
this.showHideSearchOptionsHeader();
|
||||
if (results.length == 0) {
|
||||
this._notFoundPanel.classList.remove("unfocused");
|
||||
} else {
|
||||
this._notFoundPanel.classList.add("unfocused");
|
||||
let conf: SearchConfiguration = {
|
||||
filterArea: this._filterArea,
|
||||
sortingByButton: this._sortingByButton,
|
||||
searchOptionsHeader: this._searchOptionsHeader,
|
||||
notFoundPanel: this._notFoundPanel,
|
||||
filterList: document.getElementById("accounts-filter-list"),
|
||||
search: this._searchBox,
|
||||
queries: this._queries,
|
||||
setVisibility: this.setVisibility,
|
||||
clearSearchButtonSelector: ".accounts-search-clear",
|
||||
onSearchCallback: (_0: number, _1: boolean, _2: boolean) => {
|
||||
this._checkCheckCount();
|
||||
}
|
||||
};
|
||||
this._search.oninput = onchange;
|
||||
|
||||
const clearSearchButtons = Array.from(document.getElementsByClassName("accounts-search-clear")) as Array<HTMLSpanElement>;
|
||||
for (let b of clearSearchButtons) {
|
||||
b.addEventListener("click", () => {
|
||||
this._search.value = "";
|
||||
onchange();
|
||||
});
|
||||
}
|
||||
this._search = new Search(conf);
|
||||
this._search.items = this._users;
|
||||
|
||||
|
||||
this._announceTextarea.onkeyup = this.loadPreview;
|
||||
addDiscord = newDiscordSearch(window.lang.strings("linkDiscord"), window.lang.strings("searchDiscordUser"), window.lang.strings("add"), (user: DiscordUser, id: string) => {
|
||||
@ -2088,15 +1882,16 @@ export class accountsList {
|
||||
|
||||
document.addEventListener("header-click", (event: CustomEvent) => {
|
||||
this._ordering = this._columns[event.detail].sort(this._users);
|
||||
this._search.ordering = this._ordering;
|
||||
this._activeSortColumn = event.detail;
|
||||
this._sortingByButton.innerHTML = this._columns[event.detail].buttonContent;
|
||||
this._sortingByButton.parentElement.classList.remove("hidden");
|
||||
// console.log("ordering by", event.detail, ": ", this._ordering);
|
||||
if (!(this._inSearch)) {
|
||||
if (!(this._search.inSearch)) {
|
||||
this.setVisibility(this._ordering, true);
|
||||
this._notFoundPanel.classList.add("unfocused");
|
||||
} else {
|
||||
const results = this.search(this._search.value);
|
||||
const results = this._search.search(this._searchBox.value);
|
||||
this.setVisibility(results, true);
|
||||
if (results.length == 0) {
|
||||
this._notFoundPanel.classList.remove("unfocused");
|
||||
@ -2110,87 +1905,12 @@ export class accountsList {
|
||||
defaultSort();
|
||||
this.showHideSearchOptionsHeader();
|
||||
|
||||
const filterList = document.getElementById("accounts-filter-list");
|
||||
this._search.generateFilterList();
|
||||
|
||||
const fillInFilter = (name: string, value: string, offset?: number) => {
|
||||
this._search.value = name + ":" + value + " " + this._search.value;
|
||||
this._search.focus();
|
||||
let newPos = name.length + 1 + value.length;
|
||||
if (typeof offset !== 'undefined')
|
||||
newPos += offset;
|
||||
this._search.setSelectionRange(newPos, newPos);
|
||||
this._search.oninput(null as any);
|
||||
};
|
||||
|
||||
// Generate filter buttons
|
||||
for (let queryName of Object.keys(this._queries)) {
|
||||
const query = this._queries[queryName];
|
||||
if ("show" in query && !query.show) continue;
|
||||
if ("dependsOnTableHeader" in query && query.dependsOnTableHeader) {
|
||||
const el = document.querySelector("."+query.dependsOnTableHeader);
|
||||
if (el === null) continue;
|
||||
}
|
||||
|
||||
const container = document.createElement("span") as HTMLSpanElement;
|
||||
container.classList.add("button", "button-xl", "~neutral", "@low", "mb-1", "mr-2");
|
||||
container.innerHTML = `<span class="mr-2">${query.name}</span>`;
|
||||
if (query.bool) {
|
||||
const pos = document.createElement("button") as HTMLButtonElement;
|
||||
pos.type = "button";
|
||||
pos.ariaLabel = `Filter by "${query.name}": True`;
|
||||
pos.classList.add("button", "~positive", "ml-2");
|
||||
pos.innerHTML = `<i class="ri-checkbox-circle-fill"></i>`;
|
||||
pos.addEventListener("click", () => fillInFilter(queryName, "true"));
|
||||
const neg = document.createElement("button") as HTMLButtonElement;
|
||||
neg.type = "button";
|
||||
neg.ariaLabel = `Filter by "${query.name}": False`;
|
||||
neg.classList.add("button", "~critical", "ml-2");
|
||||
neg.innerHTML = `<i class="ri-close-circle-fill"></i>`;
|
||||
neg.addEventListener("click", () => fillInFilter(queryName, "false"));
|
||||
|
||||
container.appendChild(pos);
|
||||
container.appendChild(neg);
|
||||
}
|
||||
if (query.string) {
|
||||
const button = document.createElement("button") as HTMLButtonElement;
|
||||
button.type = "button";
|
||||
button.classList.add("button", "~urge", "ml-2");
|
||||
button.innerHTML = `<i class="ri-equal-line mr-2"></i>${window.lang.strings("matchText")}`;
|
||||
|
||||
// Position cursor between quotes
|
||||
button.addEventListener("click", () => fillInFilter(queryName, `""`, -1));
|
||||
|
||||
container.appendChild(button);
|
||||
}
|
||||
if (query.date) {
|
||||
const onDate = document.createElement("button") as HTMLButtonElement;
|
||||
onDate.type = "button";
|
||||
onDate.classList.add("button", "~urge", "ml-2");
|
||||
onDate.innerHTML = `<i class="ri-calendar-check-line mr-2"></i>On Date`;
|
||||
onDate.addEventListener("click", () => fillInFilter(queryName, `"="`, -1));
|
||||
|
||||
const beforeDate = document.createElement("button") as HTMLButtonElement;
|
||||
beforeDate.type = "button";
|
||||
beforeDate.classList.add("button", "~urge", "ml-2");
|
||||
beforeDate.innerHTML = `<i class="ri-calendar-check-line mr-2"></i>Before Date`;
|
||||
beforeDate.addEventListener("click", () => fillInFilter(queryName, `"<"`, -1));
|
||||
|
||||
const afterDate = document.createElement("button") as HTMLButtonElement;
|
||||
afterDate.type = "button";
|
||||
afterDate.classList.add("button", "~urge", "ml-2");
|
||||
afterDate.innerHTML = `<i class="ri-calendar-check-line mr-2"></i>After Date`;
|
||||
afterDate.addEventListener("click", () => fillInFilter(queryName, `">"`, -1));
|
||||
|
||||
container.appendChild(onDate);
|
||||
container.appendChild(beforeDate);
|
||||
container.appendChild(afterDate);
|
||||
}
|
||||
|
||||
filterList.appendChild(container);
|
||||
}
|
||||
this.registerURLListener();
|
||||
}
|
||||
|
||||
reload = () => {
|
||||
reload = (callback?: () => void) => {
|
||||
_get("/users", null, (req: XMLHttpRequest) => {
|
||||
if (req.readyState == 4 && req.status == 200) {
|
||||
// same method as inviteList.reload()
|
||||
@ -2210,11 +1930,12 @@ export class accountsList {
|
||||
}
|
||||
// console.log("reload, so sorting by", this._activeSortColumn);
|
||||
this._ordering = this._columns[this._activeSortColumn].sort(this._users);
|
||||
if (!(this._inSearch)) {
|
||||
this._search.ordering = this._ordering;
|
||||
if (!(this._search.inSearch)) {
|
||||
this.setVisibility(this._ordering, true);
|
||||
this._notFoundPanel.classList.add("unfocused");
|
||||
} else {
|
||||
const results = this.search(this._search.value);
|
||||
const results = this._search.search(this._searchBox.value);
|
||||
if (results.length == 0) {
|
||||
this._notFoundPanel.classList.remove("unfocused");
|
||||
} else {
|
||||
@ -2223,12 +1944,16 @@ export class accountsList {
|
||||
this.setVisibility(results, true);
|
||||
}
|
||||
this._checkCheckCount();
|
||||
|
||||
if (callback) callback();
|
||||
}
|
||||
});
|
||||
this.loadTemplates();
|
||||
}
|
||||
}
|
||||
|
||||
export const accountURLEvent = (id: string) => { return new CustomEvent(accountsList._accountURLEvent, {"detail": id}) };
|
||||
|
||||
type GetterReturnType = Boolean | boolean | String | Number | number;
|
||||
type Getter = () => GetterReturnType;
|
||||
|
||||
|
736
ts/modules/activity.ts
Normal file
736
ts/modules/activity.ts
Normal file
@ -0,0 +1,736 @@
|
||||
import { _get, _post, _delete, toDateString, addLoader, removeLoader } from "../modules/common.js";
|
||||
import { Search, SearchConfiguration, QueryType, SearchableItem } from "../modules/search.js";
|
||||
import { accountURLEvent } from "../modules/accounts.js";
|
||||
import { inviteURLEvent } from "../modules/invites.js";
|
||||
|
||||
export interface activity {
|
||||
id: string;
|
||||
type: string;
|
||||
user_id: string;
|
||||
source_type: string;
|
||||
source: string;
|
||||
invite_code: string;
|
||||
value: string;
|
||||
time: number;
|
||||
username: string;
|
||||
source_username: string;
|
||||
}
|
||||
|
||||
var activityTypeMoods = {
|
||||
"creation": 1,
|
||||
"deletion": -1,
|
||||
"disabled": -1,
|
||||
"enabled": 1,
|
||||
"contactLinked": 1,
|
||||
"contactUnlinked": -1,
|
||||
"changePassword": 0,
|
||||
"resetPassword": 0,
|
||||
"createInvite": 1,
|
||||
"deleteInvite": -1
|
||||
};
|
||||
|
||||
// var moodColours = ["~warning", "~neutral", "~urge"];
|
||||
|
||||
export var activityReload = new CustomEvent("activity-reload");
|
||||
|
||||
export class Activity implements activity, SearchableItem {
|
||||
private _card: HTMLElement;
|
||||
private _title: HTMLElement;
|
||||
private _time: HTMLElement;
|
||||
private _timeUnix: number;
|
||||
private _sourceType: HTMLElement;
|
||||
private _source: HTMLElement;
|
||||
private _referrer: HTMLElement;
|
||||
private _expiryTypeBadge: HTMLElement;
|
||||
private _delete: HTMLElement;
|
||||
private _act: activity;
|
||||
private _urlBase: string = ((): string => {
|
||||
let link = window.location.href;
|
||||
for (let split of ["#", "?", "/activity"]) {
|
||||
link = link.split(split)[0];
|
||||
}
|
||||
if (link.slice(-1) != "/") { link += "/"; }
|
||||
return link;
|
||||
})();
|
||||
|
||||
_genUserText = (): string => {
|
||||
return `<span class="font-medium">${this._act.username || this._act.user_id.substring(0, 5)}</span>`;
|
||||
}
|
||||
|
||||
_genSrcUserText = (): string => {
|
||||
return `<span class="font-medium">${this._act.source_username || this._act.source.substring(0, 5)}</span>`;
|
||||
}
|
||||
|
||||
_genUserLink = (): string => {
|
||||
return `<span role="link" tabindex="0" class="hover:underline cursor-pointer activity-pseudo-link-user" data-id="${this._act.user_id}" data-href="${this._urlBase}accounts/user/${this._act.user_id}">${this._genUserText()}</span>`;
|
||||
}
|
||||
|
||||
_genSrcUserLink = (): string => {
|
||||
return `<span role="link" tabindex="0" class="hover:underline cursor-pointer activity-pseudo-link-user" data-id="${this._act.user_id}" data-href="${this._urlBase}accounts/user/${this._act.source}">${this._genSrcUserText()}</span>`;
|
||||
}
|
||||
|
||||
private _renderInvText = (): string => { return `<span class="font-medium font-mono">${this.value || this.invite_code || "???"}</span>`; }
|
||||
|
||||
private _genInvLink = (): string => {
|
||||
return `<span role="link" tabindex="0" class="hover:underline cursor-pointer activity-pseudo-link-invite" data-id="${this.invite_code}" data-href="${this._urlBase}invites/${this.invite_code}">${this._renderInvText()}</span>`;
|
||||
}
|
||||
|
||||
|
||||
get accountCreation(): boolean { return this.type == "creation"; }
|
||||
get accountDeletion(): boolean { return this.type == "deletion"; }
|
||||
get accountDisabled(): boolean { return this.type == "disabled"; }
|
||||
get accountEnabled(): boolean { return this.type == "enabled"; }
|
||||
get contactLinked(): boolean { return this.type == "contactLinked"; }
|
||||
get contactUnlinked(): boolean { return this.type == "contactUnlinked"; }
|
||||
get passwordChange(): boolean { return this.type == "changePassword"; }
|
||||
get passwordReset(): boolean { return this.type == "resetPassword"; }
|
||||
get inviteCreated(): boolean { return this.type == "createInvite"; }
|
||||
get inviteDeleted(): boolean { return this.type == "deleteInvite"; }
|
||||
|
||||
get mentionedUsers(): string {
|
||||
return (this.username + " " + this.source_username).toLowerCase();
|
||||
}
|
||||
|
||||
get actor(): string {
|
||||
let out = this.source_type + " ";
|
||||
if (this.source_type == "admin" || this.source_type == "user") out += this.source_username;
|
||||
return out.toLowerCase();
|
||||
}
|
||||
|
||||
get referrer(): string {
|
||||
if (this.type != "creation" || this.source_type != "user") return "";
|
||||
return this.source_username.toLowerCase();
|
||||
}
|
||||
|
||||
get type(): string { return this._act.type; }
|
||||
set type(v: string) {
|
||||
this._act.type = v;
|
||||
|
||||
let mood = activityTypeMoods[v]; // 1 = positive, 0 = neutral, -1 = negative
|
||||
for (let el of [this._card, this._delete]) {
|
||||
el.classList.remove("~warning");
|
||||
el.classList.remove("~neutral");
|
||||
el.classList.remove("~urge");
|
||||
|
||||
if (mood == -1) {
|
||||
el.classList.add("~warning");
|
||||
} else if (mood == 0) {
|
||||
el.classList.add("~neutral");
|
||||
} else if (mood == 1) {
|
||||
el.classList.add("~urge");
|
||||
}
|
||||
}
|
||||
|
||||
/* for (let i = 0; i < moodColours.length; i++) {
|
||||
if (i-1 == mood) this._card.classList.add(moodColours[i]);
|
||||
else this._card.classList.remove(moodColours[i]);
|
||||
} */
|
||||
|
||||
if (this.type == "changePassword" || this.type == "resetPassword") {
|
||||
let innerHTML = ``;
|
||||
if (this.type == "changePassword") innerHTML = window.lang.strings("accountChangedPassword");
|
||||
else innerHTML = window.lang.strings("accountResetPassword");
|
||||
innerHTML = innerHTML.replace("{user}", this._genUserLink());
|
||||
this._title.innerHTML = innerHTML;
|
||||
} else if (this.type == "contactLinked" || this.type == "contactUnlinked") {
|
||||
let platform = this.value;
|
||||
if (platform == "email") {
|
||||
platform = window.lang.strings("emailAddress");
|
||||
} else {
|
||||
platform = platform.charAt(0).toUpperCase() + platform.slice(1);
|
||||
}
|
||||
let innerHTML = ``;
|
||||
if (this.type == "contactLinked") innerHTML = window.lang.strings("accountLinked");
|
||||
else innerHTML = window.lang.strings("accountUnlinked");
|
||||
innerHTML = innerHTML.replace("{user}", this._genUserLink()).replace("{contactMethod}", platform);
|
||||
this._title.innerHTML = innerHTML;
|
||||
} else if (this.type == "creation") {
|
||||
this._title.innerHTML = window.lang.strings("accountCreated").replace("{user}", this._genUserLink());
|
||||
if (this.source_type == "user") {
|
||||
this._referrer.innerHTML = `<span class="supra mr-2">${window.lang.strings("referrer")}</span>${this._genSrcUserLink()}`;
|
||||
} else {
|
||||
this._referrer.textContent = ``;
|
||||
}
|
||||
} else if (this.type == "deletion") {
|
||||
if (this.source_type == "daemon") {
|
||||
this._title.innerHTML = window.lang.strings("accountExpired").replace("{user}", this._genUserText());
|
||||
this._expiryTypeBadge.classList.add("~critical");
|
||||
this._expiryTypeBadge.classList.remove("~info");
|
||||
this._expiryTypeBadge.textContent = window.lang.strings("deleted");
|
||||
} else {
|
||||
this._title.innerHTML = window.lang.strings("accountDeleted").replace("{user}", this._genUserText());
|
||||
}
|
||||
} else if (this.type == "enabled") {
|
||||
this._title.innerHTML = window.lang.strings("accountReEnabled").replace("{user}", this._genUserLink());
|
||||
} else if (this.type == "disabled") {
|
||||
if (this.source_type == "daemon") {
|
||||
this._title.innerHTML = window.lang.strings("accountExpired").replace("{user}", this._genUserLink());
|
||||
this._expiryTypeBadge.classList.add("~info");
|
||||
this._expiryTypeBadge.classList.remove("~critical");
|
||||
this._expiryTypeBadge.textContent = window.lang.strings("disabled");
|
||||
} else {
|
||||
this._title.innerHTML = window.lang.strings("accountDisabled").replace("{user}", this._genUserLink());
|
||||
}
|
||||
} else if (this.type == "createInvite") {
|
||||
this._title.innerHTML = window.lang.strings("inviteCreated").replace("{invite}", this._genInvLink());
|
||||
} else if (this.type == "deleteInvite") {
|
||||
let innerHTML = ``;
|
||||
if (this.source_type == "daemon") {
|
||||
innerHTML = window.lang.strings("inviteExpired");
|
||||
} else {
|
||||
innerHTML = window.lang.strings("inviteDeleted");
|
||||
}
|
||||
|
||||
this._title.innerHTML = innerHTML.replace("{invite}", this._renderInvText());
|
||||
}
|
||||
}
|
||||
|
||||
get time(): number { return this._timeUnix; }
|
||||
set time(v: number) {
|
||||
this._timeUnix = v;
|
||||
this._time.textContent = toDateString(new Date(v*1000));
|
||||
}
|
||||
|
||||
get source_type(): string { return this._act.source_type; }
|
||||
set source_type(v: string) {
|
||||
this._act.source_type = v;
|
||||
if ((this.source_type == "anon" || this.source_type == "user") && this.type == "creation") {
|
||||
this._sourceType.textContent = window.lang.strings("fromInvite");
|
||||
} else if (this.source_type == "admin") {
|
||||
this._sourceType.textContent = window.lang.strings("byAdmin");
|
||||
} else if (this.source_type == "user" && this.type != "creation") {
|
||||
this._sourceType.textContent = window.lang.strings("byUser");
|
||||
} else if (this.source_type == "daemon") {
|
||||
this._sourceType.textContent = window.lang.strings("byJfaGo");
|
||||
}
|
||||
}
|
||||
|
||||
get invite_code(): string { return this._act.invite_code; }
|
||||
set invite_code(v: string) {
|
||||
this._act.invite_code = v;
|
||||
}
|
||||
|
||||
get value(): string { return this._act.value; }
|
||||
set value(v: string) {
|
||||
this._act.value = v;
|
||||
}
|
||||
|
||||
get source(): string { return this._act.source; }
|
||||
set source(v: string) {
|
||||
this._act.source = v;
|
||||
if ((this.source_type == "anon" || this.source_type == "user") && this.type == "creation") {
|
||||
this._source.innerHTML = this._genInvLink();
|
||||
} else if ((this.source_type == "admin" || this.source_type == "user") && this._act.source != "" && this._act.source_username != "") {
|
||||
this._source.innerHTML = this._genSrcUserLink();
|
||||
}
|
||||
}
|
||||
|
||||
get id(): string { return this._act.id; }
|
||||
set id(v: string) { this._act.id = v; }
|
||||
|
||||
get user_id(): string { return this._act.user_id; }
|
||||
set user_id(v: string) { this._act.user_id = v; }
|
||||
|
||||
get username(): string { return this._act.username; }
|
||||
set username(v: string) { this._act.username = v; }
|
||||
|
||||
get source_username(): string { return this._act.source_username; }
|
||||
set source_username(v: string) { this._act.source_username = v; }
|
||||
|
||||
get title(): string { return this._title.textContent; }
|
||||
|
||||
matchesSearch = (query: string): boolean => {
|
||||
// console.log(this.title, "matches", query, ":", this.title.includes(query));
|
||||
return (
|
||||
this.title.toLowerCase().includes(query) ||
|
||||
this.username.toLowerCase().includes(query) ||
|
||||
this.source_username.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
constructor(act: activity) {
|
||||
this._card = document.createElement("div");
|
||||
|
||||
this._card.classList.add("card", "@low", "my-2");
|
||||
this._card.innerHTML = `
|
||||
<div class="flex flex-col md:flex-row justify-between mb-2">
|
||||
<span class="heading truncate flex-initial md:text-2xl text-xl activity-title"></span>
|
||||
<div class="flex flex-col flex-none ml-0 md:ml-2">
|
||||
<span class="font-medium md:text-sm text-xs activity-time" aria-label="${window.lang.strings("date")}"></span>
|
||||
<span class="activity-expiry-type badge self-start md:self-end mt-1"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row justify-between">
|
||||
<div>
|
||||
<span class="content supra mr-2 activity-source-type"></span><span class="activity-source"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="content activity-referrer"></span>
|
||||
</div>
|
||||
<div>
|
||||
<button class="button @low hover:~critical rounded-full px-1 py-px activity-delete" aria-label="${window.lang.strings("delete")}"><i class="ri-close-line"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this._title = this._card.querySelector(".activity-title");
|
||||
this._time = this._card.querySelector(".activity-time");
|
||||
this._sourceType = this._card.querySelector(".activity-source-type");
|
||||
this._source = this._card.querySelector(".activity-source");
|
||||
this._referrer = this._card.querySelector(".activity-referrer");
|
||||
this._expiryTypeBadge = this._card.querySelector(".activity-expiry-type");
|
||||
this._delete = this._card.querySelector(".activity-delete");
|
||||
|
||||
document.addEventListener("timefmt-change", () => {
|
||||
this.time = this.time;
|
||||
});
|
||||
|
||||
this._delete.addEventListener("click", this.delete);
|
||||
|
||||
this.update(act);
|
||||
|
||||
const pseudoUsers = this._card.getElementsByClassName("activity-pseudo-link-user") as HTMLCollectionOf<HTMLAnchorElement>;
|
||||
const pseudoInvites = this._card.getElementsByClassName("activity-pseudo-link-invite") as HTMLCollectionOf<HTMLAnchorElement>;
|
||||
|
||||
for (let i = 0; i < pseudoUsers.length; i++) {
|
||||
const navigate = (event: Event) => {
|
||||
event.preventDefault()
|
||||
window.tabs.switch("accounts");
|
||||
document.dispatchEvent(accountURLEvent(pseudoUsers[i].getAttribute("data-id")));
|
||||
window.history.pushState(null, document.title, pseudoUsers[i].getAttribute("data-href"));
|
||||
}
|
||||
pseudoUsers[i].onclick = navigate;
|
||||
pseudoUsers[i].onkeydown = navigate;
|
||||
}
|
||||
for (let i = 0; i < pseudoInvites.length; i++) {
|
||||
const navigate = (event: Event) => {
|
||||
event.preventDefault();
|
||||
window.invites.reload(() => {
|
||||
window.tabs.switch("invites");
|
||||
document.dispatchEvent(inviteURLEvent(pseudoInvites[i].getAttribute("data-id")));
|
||||
window.history.pushState(null, document.title, pseudoInvites[i].getAttribute("data-href"));
|
||||
});
|
||||
}
|
||||
pseudoInvites[i].onclick = navigate;
|
||||
pseudoInvites[i].onkeydown = navigate;
|
||||
}
|
||||
}
|
||||
|
||||
update = (act: activity) => {
|
||||
this._act = act;
|
||||
this.source_type = act.source_type;
|
||||
this.invite_code = act.invite_code;
|
||||
this.time = act.time;
|
||||
this.source = act.source;
|
||||
this.value = act.value;
|
||||
this.type = act.type;
|
||||
}
|
||||
|
||||
delete = () => _delete("/activity/" + this._act.id, null, (req: XMLHttpRequest) => {
|
||||
if (req.readyState != 4) return;
|
||||
if (req.status == 200) {
|
||||
window.notifications.customSuccess("activityDeleted", window.lang.notif("activityDeleted"));
|
||||
}
|
||||
document.dispatchEvent(activityReload);
|
||||
});
|
||||
|
||||
asElement = () => { return this._card; };
|
||||
}
|
||||
|
||||
interface ActivitiesDTO {
|
||||
activities: activity[];
|
||||
last_page: boolean;
|
||||
}
|
||||
|
||||
export class activityList {
|
||||
private _activityList: HTMLElement;
|
||||
private _activities: { [id: string]: Activity } = {};
|
||||
private _ordering: string[] = [];
|
||||
private _filterArea = document.getElementById("activity-filter-area");
|
||||
private _searchOptionsHeader = document.getElementById("activity-search-options-header");
|
||||
private _sortingByButton = document.getElementById("activity-sort-by-field") as HTMLButtonElement;
|
||||
private _notFoundPanel = document.getElementById("activity-not-found");
|
||||
private _searchBox = document.getElementById("activity-search") as HTMLInputElement;
|
||||
private _sortDirection = document.getElementById("activity-sort-direction") as HTMLButtonElement;
|
||||
private _loader = document.getElementById("activity-loader");
|
||||
private _loadMoreButton = document.getElementById("activity-load-more") as HTMLButtonElement;
|
||||
private _loadAllButton = document.getElementById("activity-load-all") as HTMLButtonElement;
|
||||
private _refreshButton = document.getElementById("activity-refresh") as HTMLButtonElement;
|
||||
private _keepSearchingDescription = document.getElementById("activity-keep-searching-description");
|
||||
private _keepSearchingButton = document.getElementById("activity-keep-searching");
|
||||
|
||||
private _totalRecords = document.getElementById("activity-total-records");
|
||||
private _loadedRecords = document.getElementById("activity-loaded-records");
|
||||
private _shownRecords = document.getElementById("activity-shown-records");
|
||||
|
||||
private _total: number;
|
||||
private _loaded: number;
|
||||
private _shown: number;
|
||||
|
||||
get total(): number { return this._total; }
|
||||
set total(v: number) {
|
||||
this._total = v;
|
||||
this._totalRecords.textContent = window.lang.var("strings", "totalRecords", `${v}`);
|
||||
}
|
||||
|
||||
get loaded(): number { return this._loaded; }
|
||||
set loaded(v: number) {
|
||||
this._loaded = v;
|
||||
this._loadedRecords.textContent = window.lang.var("strings", "loadedRecords", `${v}`);
|
||||
}
|
||||
|
||||
get shown(): number { return this._shown; }
|
||||
set shown(v: number) {
|
||||
this._shown = v;
|
||||
this._shownRecords.textContent = window.lang.var("strings", "shownRecords", `${v}`);
|
||||
}
|
||||
|
||||
private _search: Search;
|
||||
private _ascending: boolean;
|
||||
private _hasLoaded: boolean;
|
||||
private _lastLoad: number;
|
||||
private _page: number = 0;
|
||||
private _lastPage: boolean;
|
||||
|
||||
|
||||
setVisibility = (activities: string[], visible: boolean) => {
|
||||
this._activityList.textContent = ``;
|
||||
for (let id of this._ordering) {
|
||||
if (visible && activities.indexOf(id) != -1) {
|
||||
this._activityList.appendChild(this._activities[id].asElement());
|
||||
} else if (!visible && activities.indexOf(id) == -1) {
|
||||
this._activityList.appendChild(this._activities[id].asElement());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reload = () => {
|
||||
this._lastLoad = Date.now();
|
||||
this._lastPage = false;
|
||||
this._loadMoreButton.textContent = window.lang.strings("loadMore");
|
||||
this._loadMoreButton.disabled = false;
|
||||
this._loadAllButton.classList.remove("unfocused");
|
||||
this._loadAllButton.disabled = false;
|
||||
|
||||
this.total = 0;
|
||||
this.loaded = 0;
|
||||
this.shown = 0;
|
||||
|
||||
// this._page = 0;
|
||||
let limit = 10;
|
||||
if (this._page != 0) {
|
||||
limit *= this._page+1;
|
||||
};
|
||||
|
||||
let send = {
|
||||
"type": [],
|
||||
"limit": limit,
|
||||
"page": 0,
|
||||
"ascending": this.ascending
|
||||
}
|
||||
|
||||
_get("/activity/count", null, (req: XMLHttpRequest) => {
|
||||
if (req.readyState != 4 || req.status != 200) return;
|
||||
this.total = req.response["count"] as number;
|
||||
});
|
||||
|
||||
_post("/activity", send, (req: XMLHttpRequest) => {
|
||||
if (req.readyState != 4) return;
|
||||
if (req.status != 200) {
|
||||
window.notifications.customError("loadActivitiesError", window.lang.notif("errorLoadActivities"));
|
||||
return;
|
||||
}
|
||||
|
||||
this._hasLoaded = true;
|
||||
// Allow refreshes every 15s
|
||||
this._refreshButton.disabled = true;
|
||||
setTimeout(() => this._refreshButton.disabled = false, 15000);
|
||||
|
||||
let resp = req.response as ActivitiesDTO;
|
||||
// FIXME: Don't destroy everything each reload!
|
||||
this._activities = {};
|
||||
this._ordering = [];
|
||||
|
||||
for (let act of resp.activities) {
|
||||
this._activities[act.id] = new Activity(act);
|
||||
this._ordering.push(act.id);
|
||||
}
|
||||
this._search.items = this._activities;
|
||||
this._search.ordering = this._ordering;
|
||||
|
||||
this.loaded = this._ordering.length;
|
||||
|
||||
if (this._search.inSearch) {
|
||||
this._search.onSearchBoxChange(true);
|
||||
this._loadAllButton.classList.remove("unfocused");
|
||||
} else {
|
||||
this.shown = this.loaded;
|
||||
this.setVisibility(this._ordering, true);
|
||||
this._loadAllButton.classList.add("unfocused");
|
||||
this._notFoundPanel.classList.add("unfocused");
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
|
||||
loadMore = (callback?: () => void, loadAll: boolean = false) => {
|
||||
this._lastLoad = Date.now();
|
||||
this._loadMoreButton.disabled = true;
|
||||
// this._loadAllButton.disabled = true;
|
||||
const timeout = setTimeout(() => {
|
||||
this._loadMoreButton.disabled = false;
|
||||
// this._loadAllButton.disabled = false;
|
||||
}, 1000);
|
||||
this._page += 1;
|
||||
|
||||
let send = {
|
||||
"type": [],
|
||||
"limit": 10,
|
||||
"page": this._page,
|
||||
"ascending": this._ascending
|
||||
};
|
||||
|
||||
// this._activityList.classList.add("unfocused");
|
||||
// addLoader(this._loader, false, true);
|
||||
|
||||
_post("/activity", send, (req: XMLHttpRequest) => {
|
||||
if (req.readyState != 4) return;
|
||||
if (req.status != 200) {
|
||||
window.notifications.customError("loadActivitiesError", window.lang.notif("errorLoadActivities"));
|
||||
return;
|
||||
}
|
||||
|
||||
let resp = req.response as ActivitiesDTO;
|
||||
|
||||
this._lastPage = resp.last_page;
|
||||
if (this._lastPage) {
|
||||
clearTimeout(timeout);
|
||||
this._loadMoreButton.disabled = true;
|
||||
removeLoader(this._loadAllButton);
|
||||
this._loadAllButton.classList.add("unfocused");
|
||||
this._loadMoreButton.textContent = window.lang.strings("noMoreResults");
|
||||
}
|
||||
|
||||
for (let act of resp.activities) {
|
||||
this._activities[act.id] = new Activity(act);
|
||||
this._ordering.push(act.id);
|
||||
}
|
||||
// this._search.items = this._activities;
|
||||
// this._search.ordering = this._ordering;
|
||||
|
||||
this.loaded = this._ordering.length;
|
||||
|
||||
if (this._search.inSearch || loadAll) {
|
||||
if (this._lastPage) {
|
||||
loadAll = false;
|
||||
}
|
||||
this._search.onSearchBoxChange(true, loadAll);
|
||||
} else {
|
||||
this.setVisibility(this._ordering, true);
|
||||
this._notFoundPanel.classList.add("unfocused");
|
||||
}
|
||||
|
||||
if (callback) callback();
|
||||
// removeLoader(this._loader);
|
||||
// this._activityList.classList.remove("unfocused");
|
||||
}, true);
|
||||
}
|
||||
|
||||
private _queries: { [field: string]: QueryType } = {
|
||||
"id": {
|
||||
name: window.lang.strings("activityID"),
|
||||
getter: "id",
|
||||
bool: false,
|
||||
string: true,
|
||||
date: false
|
||||
},
|
||||
"title": {
|
||||
name: window.lang.strings("title"),
|
||||
getter: "title",
|
||||
bool: false,
|
||||
string: true,
|
||||
date: false
|
||||
},
|
||||
"user": {
|
||||
name: window.lang.strings("usersMentioned"),
|
||||
getter: "mentionedUsers",
|
||||
bool: false,
|
||||
string: true,
|
||||
date: false
|
||||
},
|
||||
"actor": {
|
||||
name: window.lang.strings("actor"),
|
||||
description: window.lang.strings("actorDescription"),
|
||||
getter: "actor",
|
||||
bool: false,
|
||||
string: true,
|
||||
date: false
|
||||
},
|
||||
"referrer": {
|
||||
name: window.lang.strings("referrer"),
|
||||
getter: "referrer",
|
||||
bool: true,
|
||||
string: true,
|
||||
date: false
|
||||
},
|
||||
"date": {
|
||||
name: window.lang.strings("date"),
|
||||
getter: "date",
|
||||
bool: false,
|
||||
string: false,
|
||||
date: true
|
||||
},
|
||||
"account-creation": {
|
||||
name: window.lang.strings("accountCreationFilter"),
|
||||
getter: "accountCreation",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"account-deletion": {
|
||||
name: window.lang.strings("accountDeletionFilter"),
|
||||
getter: "accountDeletion",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"account-disabled": {
|
||||
name: window.lang.strings("accountDisabledFilter"),
|
||||
getter: "accountDisabled",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"account-enabled": {
|
||||
name: window.lang.strings("accountEnabledFilter"),
|
||||
getter: "accountEnabled",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"contact-linked": {
|
||||
name: window.lang.strings("contactLinkedFilter"),
|
||||
getter: "contactLinked",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"contact-unlinked": {
|
||||
name: window.lang.strings("contactUnlinkedFilter"),
|
||||
getter: "contactUnlinked",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"password-change": {
|
||||
name: window.lang.strings("passwordChangeFilter"),
|
||||
getter: "passwordChange",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"password-reset": {
|
||||
name: window.lang.strings("passwordResetFilter"),
|
||||
getter: "passwordReset",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"invite-created": {
|
||||
name: window.lang.strings("inviteCreatedFilter"),
|
||||
getter: "inviteCreated",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"invite-deleted": {
|
||||
name: window.lang.strings("inviteDeletedFilter"),
|
||||
getter: "inviteDeleted",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
}
|
||||
};
|
||||
|
||||
get ascending(): boolean { return this._ascending; }
|
||||
set ascending(v: boolean) {
|
||||
this._ascending = v;
|
||||
this._sortDirection.innerHTML = `${window.lang.strings("sortDirection")} <i class="ri-arrow-${v ? "up" : "down"}-s-line ml-2"></i>`;
|
||||
if (this._hasLoaded) {
|
||||
this.reload();
|
||||
}
|
||||
}
|
||||
|
||||
detectScroll = () => {
|
||||
if (!this._hasLoaded) return;
|
||||
// console.log(window.innerHeight + document.documentElement.scrollTop, document.scrollingElement.scrollHeight);
|
||||
if (Math.abs(window.innerHeight + document.documentElement.scrollTop - document.scrollingElement.scrollHeight) < 50) {
|
||||
// window.notifications.customSuccess("scroll", "Reached bottom.");
|
||||
// Wait .5s between loads
|
||||
if (this._lastLoad + 500 > Date.now()) return;
|
||||
this.loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
private _prevResultCount = 0;
|
||||
|
||||
private _notFoundCallback = (notFound: boolean) => {
|
||||
if (notFound) this._loadMoreButton.classList.add("unfocused");
|
||||
else this._loadMoreButton.classList.remove("unfocused");
|
||||
|
||||
if (notFound && !this._lastPage) {
|
||||
this._keepSearchingButton.classList.remove("unfocused");
|
||||
this._keepSearchingDescription.classList.remove("unfocused");
|
||||
} else {
|
||||
this._keepSearchingButton.classList.add("unfocused");
|
||||
this._keepSearchingDescription.classList.add("unfocused");
|
||||
}
|
||||
};
|
||||
|
||||
constructor() {
|
||||
this._activityList = document.getElementById("activity-card-list");
|
||||
document.addEventListener("activity-reload", this.reload);
|
||||
|
||||
let conf: SearchConfiguration = {
|
||||
filterArea: this._filterArea,
|
||||
sortingByButton: this._sortingByButton,
|
||||
searchOptionsHeader: this._searchOptionsHeader,
|
||||
notFoundPanel: this._notFoundPanel,
|
||||
search: this._searchBox,
|
||||
clearSearchButtonSelector: ".activity-search-clear",
|
||||
queries: this._queries,
|
||||
setVisibility: this.setVisibility,
|
||||
filterList: document.getElementById("activity-filter-list"),
|
||||
// notFoundCallback: this._notFoundCallback,
|
||||
onSearchCallback: (visibleCount: number, newItems: boolean, loadAll: boolean) => {
|
||||
this.shown = visibleCount;
|
||||
|
||||
if (this._search.inSearch && !this._lastPage) this._loadAllButton.classList.remove("unfocused");
|
||||
else this._loadAllButton.classList.add("unfocused");
|
||||
|
||||
if (visibleCount < 10 || loadAll) {
|
||||
if (!newItems || this._prevResultCount != visibleCount || (visibleCount == 0 && !this._lastPage) || loadAll) this.loadMore(() => {}, loadAll);
|
||||
}
|
||||
this._prevResultCount = visibleCount;
|
||||
}
|
||||
}
|
||||
this._search = new Search(conf);
|
||||
this._search.generateFilterList();
|
||||
|
||||
this._hasLoaded = false;
|
||||
this.ascending = false;
|
||||
this._sortDirection.addEventListener("click", () => this.ascending = !this.ascending);
|
||||
|
||||
this._loadMoreButton.onclick = () => this.loadMore();
|
||||
this._loadAllButton.onclick = () => {
|
||||
addLoader(this._loadAllButton, true);
|
||||
this.loadMore(() => {}, true);
|
||||
};
|
||||
/* this._keepSearchingButton.onclick = () => {
|
||||
addLoader(this._keepSearchingButton, true);
|
||||
this.loadMore(() => removeLoader(this._keepSearchingButton, true));
|
||||
}; */
|
||||
this._refreshButton.onclick = this.reload;
|
||||
|
||||
window.onscroll = this.detectScroll;
|
||||
}
|
||||
}
|
@ -199,9 +199,10 @@ export function toggleLoader(el: HTMLElement, small: boolean = true) {
|
||||
}
|
||||
}
|
||||
|
||||
export function addLoader(el: HTMLElement, small: boolean = true) {
|
||||
export function addLoader(el: HTMLElement, small: boolean = true, relative: boolean = false) {
|
||||
if (!el.classList.contains("loader")) {
|
||||
el.classList.add("loader");
|
||||
if (relative) el.classList.add("rel");
|
||||
if (small) { el.classList.add("loader-sm"); }
|
||||
const dot = document.createElement("span") as HTMLSpanElement;
|
||||
dot.classList.add("dot")
|
||||
@ -213,6 +214,7 @@ export function removeLoader(el: HTMLElement, small: boolean = true) {
|
||||
if (el.classList.contains("loader")) {
|
||||
el.classList.remove("loader");
|
||||
el.classList.remove("loader-sm");
|
||||
el.classList.remove("rel");
|
||||
const dot = el.querySelector("span.dot");
|
||||
if (dot) { dot.remove(); }
|
||||
}
|
||||
|
@ -261,6 +261,8 @@ class DOMInvite implements Invite {
|
||||
}
|
||||
}
|
||||
|
||||
focus = () => this._container.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
|
||||
constructor(invite: Invite) {
|
||||
// first create the invite structure, then use our setter methods to fill in the data.
|
||||
this._container = document.createElement('div') as HTMLDivElement;
|
||||
@ -423,6 +425,26 @@ export class inviteList implements inviteList {
|
||||
|
||||
invites: { [code: string]: DOMInvite };
|
||||
|
||||
focusInvite = (inviteCode: string, errorMsg: string = window.lang.notif("errorInviteNoLongerExists")) => {
|
||||
for (let code of Object.keys(this.invites)) {
|
||||
this.invites[code].expanded = code == inviteCode;
|
||||
}
|
||||
if (inviteCode in this.invites) this.invites[inviteCode].focus();
|
||||
else window.notifications.customError("inviteDoesntExistError", errorMsg);
|
||||
};
|
||||
|
||||
public static readonly _inviteURLEvent = "invite-url";
|
||||
registerURLListener = () => document.addEventListener(inviteList._inviteURLEvent, (event: CustomEvent) => {
|
||||
this.focusInvite(event.detail);
|
||||
})
|
||||
|
||||
isInviteURL = () => { return window.location.pathname.startsWith(window.URLBase + "/invites/"); }
|
||||
|
||||
loadInviteURL = () => {
|
||||
let inviteCode = window.location.pathname.split(window.URLBase + "/invites/")[1].split("?lang")[0];
|
||||
this.focusInvite(inviteCode, window.lang.notif("errorInviteNotFound"));
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this._list = document.getElementById('invites') as HTMLDivElement;
|
||||
this.empty = true;
|
||||
@ -436,6 +458,8 @@ export class inviteList implements inviteList {
|
||||
this.empty = true;
|
||||
}
|
||||
}, false);
|
||||
|
||||
this.registerURLListener();
|
||||
}
|
||||
|
||||
get empty(): boolean { return this._empty; }
|
||||
@ -468,7 +492,7 @@ export class inviteList implements inviteList {
|
||||
this._list.appendChild(domInv.asElement());
|
||||
}
|
||||
|
||||
reload = () => _get("/invites", null, (req: XMLHttpRequest) => {
|
||||
reload = (callback?: () => void) => _get("/invites", null, (req: XMLHttpRequest) => {
|
||||
if (req.readyState == 4) {
|
||||
let data = req.response;
|
||||
if (req.status == 200) {
|
||||
@ -497,10 +521,13 @@ export class inviteList implements inviteList {
|
||||
this.invites[code].remove();
|
||||
delete this.invites[code];
|
||||
}
|
||||
|
||||
if (callback) callback();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
export const inviteURLEvent = (id: string) => { return new CustomEvent(inviteList._inviteURLEvent, {"detail": id}) };
|
||||
|
||||
function parseInvite(invite: { [f: string]: string | number | { [name: string]: number } | boolean }): Invite {
|
||||
let parsed: Invite = {};
|
||||
|
390
ts/modules/search.ts
Normal file
390
ts/modules/search.ts
Normal file
@ -0,0 +1,390 @@
|
||||
const dateParser = require("any-date-parser");
|
||||
|
||||
export interface QueryType {
|
||||
name: string;
|
||||
description?: string;
|
||||
getter: string;
|
||||
bool: boolean;
|
||||
string: boolean;
|
||||
date: boolean;
|
||||
dependsOnElement?: string; // Format for querySelector
|
||||
show?: boolean;
|
||||
}
|
||||
|
||||
export interface SearchConfiguration {
|
||||
filterArea: HTMLElement;
|
||||
sortingByButton: HTMLButtonElement;
|
||||
searchOptionsHeader: HTMLElement;
|
||||
notFoundPanel: HTMLElement;
|
||||
notFoundCallback?: (notFound: boolean) => void;
|
||||
filterList: HTMLElement;
|
||||
clearSearchButtonSelector: string;
|
||||
search: HTMLInputElement;
|
||||
queries: { [field: string]: QueryType };
|
||||
setVisibility: (items: string[], visible: boolean) => void;
|
||||
onSearchCallback: (visibleCount: number, newItems: boolean, loadAll: boolean) => void;
|
||||
loadMore?: () => void;
|
||||
}
|
||||
|
||||
export interface SearchableItem {
|
||||
matchesSearch: (query: string) => boolean;
|
||||
}
|
||||
|
||||
export class Search {
|
||||
private _c: SearchConfiguration;
|
||||
private _ordering: string[] = [];
|
||||
private _items: { [id: string]: SearchableItem };
|
||||
inSearch: boolean;
|
||||
|
||||
search = (query: String): string[] => {
|
||||
this._c.filterArea.textContent = "";
|
||||
|
||||
query = query.toLowerCase();
|
||||
|
||||
let result: string[] = [...this._ordering];
|
||||
let words: string[] = [];
|
||||
|
||||
let quoteSymbol = ``;
|
||||
let queryStart = -1;
|
||||
let lastQuote = -1;
|
||||
for (let i = 0; i < query.length; i++) {
|
||||
if (queryStart == -1 && query[i] != " " && query[i] != `"` && query[i] != `'`) {
|
||||
queryStart = i;
|
||||
}
|
||||
if ((query[i] == `"` || query[i] == `'`) && (quoteSymbol == `` || query[i] == quoteSymbol)) {
|
||||
if (lastQuote != -1) {
|
||||
lastQuote = -1;
|
||||
quoteSymbol = ``;
|
||||
} else {
|
||||
lastQuote = i;
|
||||
quoteSymbol = query[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (query[i] == " " || i == query.length-1) {
|
||||
if (lastQuote != -1) {
|
||||
continue;
|
||||
} else {
|
||||
let end = i+1;
|
||||
if (query[i] == " ") {
|
||||
end = i;
|
||||
while (i+1 < query.length && query[i+1] == " ") {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
words.push(query.substring(queryStart, end).replace(/['"]/g, ""));
|
||||
console.log("pushed", words);
|
||||
queryStart = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query = "";
|
||||
for (let word of words) {
|
||||
if (!word.includes(":")) {
|
||||
let cachedResult = [...result];
|
||||
for (let id of cachedResult) {
|
||||
const u = this._items[id];
|
||||
if (!u.matchesSearch(word)) {
|
||||
result.splice(result.indexOf(id), 1);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const split = [word.substring(0, word.indexOf(":")), word.substring(word.indexOf(":")+1)];
|
||||
|
||||
if (!(split[0] in this._c.queries)) continue;
|
||||
|
||||
const queryFormat = this._c.queries[split[0]];
|
||||
|
||||
if (queryFormat.bool) {
|
||||
let isBool = false;
|
||||
let boolState = false;
|
||||
if (split[1] == "true" || split[1] == "yes" || split[1] == "t" || split[1] == "y") {
|
||||
isBool = true;
|
||||
boolState = true;
|
||||
} else if (split[1] == "false" || split[1] == "no" || split[1] == "f" || split[1] == "n") {
|
||||
isBool = true;
|
||||
boolState = false;
|
||||
}
|
||||
if (isBool) {
|
||||
const filterCard = document.createElement("span");
|
||||
filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter");
|
||||
filterCard.classList.add("button", "~" + (boolState ? "positive" : "critical"), "@high", "center", "mx-2", "h-full");
|
||||
filterCard.innerHTML = `
|
||||
<span class="font-bold mr-2">${queryFormat.name}</span>
|
||||
<i class="text-2xl ri-${boolState? "checkbox" : "close"}-circle-fill"></i>
|
||||
`;
|
||||
|
||||
filterCard.addEventListener("click", () => {
|
||||
for (let quote of [`"`, `'`, ``]) {
|
||||
this._c.search.value = this._c.search.value.replace(split[0] + ":" + quote + split[1] + quote, "");
|
||||
}
|
||||
this._c.search.oninput((null as Event));
|
||||
})
|
||||
|
||||
this._c.filterArea.appendChild(filterCard);
|
||||
|
||||
// console.log("is bool, state", boolState);
|
||||
// So removing elements doesn't affect us
|
||||
let cachedResult = [...result];
|
||||
for (let id of cachedResult) {
|
||||
const u = this._items[id];
|
||||
const value = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u);
|
||||
// console.log("got", queryFormat.getter + ":", value);
|
||||
// Remove from result if not matching query
|
||||
if (!((value && boolState) || (!value && !boolState))) {
|
||||
// console.log("not matching, result is", result);
|
||||
result.splice(result.indexOf(id), 1);
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (queryFormat.string) {
|
||||
const filterCard = document.createElement("span");
|
||||
filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter");
|
||||
filterCard.classList.add("button", "~neutral", "@low", "center", "mx-2", "h-full");
|
||||
filterCard.innerHTML = `
|
||||
<span class="font-bold mr-2">${queryFormat.name}:</span> "${split[1]}"
|
||||
`;
|
||||
|
||||
filterCard.addEventListener("click", () => {
|
||||
for (let quote of [`"`, `'`, ``]) {
|
||||
let regex = new RegExp(split[0] + ":" + quote + split[1] + quote, "ig");
|
||||
this._c.search.value = this._c.search.value.replace(regex, "");
|
||||
}
|
||||
this._c.search.oninput((null as Event));
|
||||
})
|
||||
|
||||
this._c.filterArea.appendChild(filterCard);
|
||||
|
||||
let cachedResult = [...result];
|
||||
for (let id of cachedResult) {
|
||||
const u = this._items[id];
|
||||
const value = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u).toLowerCase();
|
||||
if (!(value.includes(split[1]))) {
|
||||
result.splice(result.indexOf(id), 1);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (queryFormat.date) {
|
||||
// -1 = Before, 0 = On, 1 = After, 2 = No symbol, assume 0
|
||||
let compareType = (split[1][0] == ">") ? 1 : ((split[1][0] == "<") ? -1 : ((split[1][0] == "=") ? 0 : 2));
|
||||
let unmodifiedValue = split[1];
|
||||
if (compareType != 2) {
|
||||
split[1] = split[1].substring(1);
|
||||
}
|
||||
if (compareType == 2) compareType = 0;
|
||||
|
||||
let attempt: { year?: number, month?: number, day?: number, hour?: number, minute?: number } = dateParser.attempt(split[1]);
|
||||
// Month in Date objects is 0-based, so make our parsed date that way too
|
||||
if ("month" in attempt) attempt.month -= 1;
|
||||
|
||||
let date: Date = (Date as any).fromString(split[1]) as Date;
|
||||
console.log("Read", attempt, "and", date);
|
||||
if ("invalid" in (date as any)) continue;
|
||||
|
||||
const filterCard = document.createElement("span");
|
||||
filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter");
|
||||
filterCard.classList.add("button", "~neutral", "@low", "center", "m-2", "h-full");
|
||||
filterCard.innerHTML = `
|
||||
<span class="font-bold mr-2">${queryFormat.name}:</span> ${(compareType == 1) ? window.lang.strings("after")+" " : ((compareType == -1) ? window.lang.strings("before")+" " : "")}${split[1]}
|
||||
`;
|
||||
|
||||
filterCard.addEventListener("click", () => {
|
||||
for (let quote of [`"`, `'`, ``]) {
|
||||
let regex = new RegExp(split[0] + ":" + quote + unmodifiedValue + quote, "ig");
|
||||
this._c.search.value = this._c.search.value.replace(regex, "");
|
||||
}
|
||||
|
||||
this._c.search.oninput((null as Event));
|
||||
})
|
||||
|
||||
this._c.filterArea.appendChild(filterCard);
|
||||
|
||||
let cachedResult = [...result];
|
||||
for (let id of cachedResult) {
|
||||
const u = this._items[id];
|
||||
const unixValue = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u);
|
||||
if (unixValue == 0) {
|
||||
result.splice(result.indexOf(id), 1);
|
||||
continue;
|
||||
}
|
||||
let value = new Date(unixValue*1000);
|
||||
|
||||
const getterPairs: [string, () => number][] = [["year", Date.prototype.getFullYear], ["month", Date.prototype.getMonth], ["day", Date.prototype.getDate], ["hour", Date.prototype.getHours], ["minute", Date.prototype.getMinutes]];
|
||||
|
||||
// When doing > or < <time> with no date, we need to ignore the rest of the Date object
|
||||
if (compareType != 0 && Object.keys(attempt).length == 2 && "hour" in attempt && "minute" in attempt) {
|
||||
const temp = new Date(date.valueOf());
|
||||
temp.setHours(value.getHours(), value.getMinutes());
|
||||
value = temp;
|
||||
console.log("just hours/minutes workaround, value set to", value);
|
||||
}
|
||||
|
||||
|
||||
let match = true;
|
||||
if (compareType == 0) {
|
||||
for (let pair of getterPairs) {
|
||||
if (pair[0] in attempt) {
|
||||
if (compareType == 0 && attempt[pair[0]] != pair[1].call(value)) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (compareType == -1) {
|
||||
match = (value < date);
|
||||
} else if (compareType == 1) {
|
||||
match = (value > date);
|
||||
}
|
||||
if (!match) {
|
||||
result.splice(result.indexOf(id), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
showHideSearchOptionsHeader = () => {
|
||||
const sortingBy = !(this._c.sortingByButton.parentElement.classList.contains("hidden"));
|
||||
const hasFilters = this._c.filterArea.textContent != "";
|
||||
console.log("sortingBy", sortingBy, "hasFilters", hasFilters);
|
||||
if (sortingBy || hasFilters) {
|
||||
this._c.searchOptionsHeader.classList.remove("hidden");
|
||||
} else {
|
||||
this._c.searchOptionsHeader.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
get items(): { [id: string]: SearchableItem } { return this._items; }
|
||||
set items(v: { [id: string]: SearchableItem }) {
|
||||
this._items = v;
|
||||
}
|
||||
|
||||
get ordering(): string[] { return this._ordering; }
|
||||
set ordering(v: string[]) { this._ordering = v; }
|
||||
|
||||
onSearchBoxChange = (newItems: boolean = false, loadAll: boolean = false) => {
|
||||
const query = this._c.search.value;
|
||||
if (!query) {
|
||||
this.inSearch = false;
|
||||
} else {
|
||||
this.inSearch = true;
|
||||
}
|
||||
const results = this.search(query);
|
||||
this._c.setVisibility(results, true);
|
||||
this._c.onSearchCallback(results.length, newItems, loadAll);
|
||||
this.showHideSearchOptionsHeader();
|
||||
if (results.length == 0) {
|
||||
this._c.notFoundPanel.classList.remove("unfocused");
|
||||
} else {
|
||||
this._c.notFoundPanel.classList.add("unfocused");
|
||||
}
|
||||
if (this._c.notFoundCallback) this._c.notFoundCallback(results.length == 0);
|
||||
}
|
||||
|
||||
fillInFilter = (name: string, value: string, offset?: number) => {
|
||||
this._c.search.value = name + ":" + value + " " + this._c.search.value;
|
||||
this._c.search.focus();
|
||||
let newPos = name.length + 1 + value.length;
|
||||
if (typeof offset !== 'undefined')
|
||||
newPos += offset;
|
||||
this._c.search.setSelectionRange(newPos, newPos);
|
||||
this._c.search.oninput(null as any);
|
||||
};
|
||||
|
||||
|
||||
|
||||
generateFilterList = () => {
|
||||
// Generate filter buttons
|
||||
for (let queryName of Object.keys(this._c.queries)) {
|
||||
const query = this._c.queries[queryName];
|
||||
if ("show" in query && !query.show) continue;
|
||||
if ("dependsOnElement" in query && query.dependsOnElement) {
|
||||
const el = document.querySelector(query.dependsOnElement);
|
||||
if (el === null) continue;
|
||||
}
|
||||
|
||||
const container = document.createElement("span") as HTMLSpanElement;
|
||||
container.classList.add("button", "button-xl", "~neutral", "@low", "mb-1", "mr-2");
|
||||
container.innerHTML = `
|
||||
<div class="flex flex-col mr-2">
|
||||
<span>${query.name}</span>
|
||||
<span class="support">${query.description || ""}</span>
|
||||
</div>
|
||||
`;
|
||||
if (query.bool) {
|
||||
const pos = document.createElement("button") as HTMLButtonElement;
|
||||
pos.type = "button";
|
||||
pos.ariaLabel = `Filter by "${query.name}": True`;
|
||||
pos.classList.add("button", "~positive", "ml-2");
|
||||
pos.innerHTML = `<i class="ri-checkbox-circle-fill"></i>`;
|
||||
pos.addEventListener("click", () => this.fillInFilter(queryName, "true"));
|
||||
const neg = document.createElement("button") as HTMLButtonElement;
|
||||
neg.type = "button";
|
||||
neg.ariaLabel = `Filter by "${query.name}": False`;
|
||||
neg.classList.add("button", "~critical", "ml-2");
|
||||
neg.innerHTML = `<i class="ri-close-circle-fill"></i>`;
|
||||
neg.addEventListener("click", () => this.fillInFilter(queryName, "false"));
|
||||
|
||||
container.appendChild(pos);
|
||||
container.appendChild(neg);
|
||||
}
|
||||
if (query.string) {
|
||||
const button = document.createElement("button") as HTMLButtonElement;
|
||||
button.type = "button";
|
||||
button.classList.add("button", "~urge", "ml-2");
|
||||
button.innerHTML = `<i class="ri-equal-line mr-2"></i>${window.lang.strings("matchText")}`;
|
||||
|
||||
// Position cursor between quotes
|
||||
button.addEventListener("click", () => this.fillInFilter(queryName, `""`, -1));
|
||||
|
||||
container.appendChild(button);
|
||||
}
|
||||
if (query.date) {
|
||||
const onDate = document.createElement("button") as HTMLButtonElement;
|
||||
onDate.type = "button";
|
||||
onDate.classList.add("button", "~urge", "ml-2");
|
||||
onDate.innerHTML = `<i class="ri-calendar-check-line mr-2"></i>On Date`;
|
||||
onDate.addEventListener("click", () => this.fillInFilter(queryName, `"="`, -1));
|
||||
|
||||
const beforeDate = document.createElement("button") as HTMLButtonElement;
|
||||
beforeDate.type = "button";
|
||||
beforeDate.classList.add("button", "~urge", "ml-2");
|
||||
beforeDate.innerHTML = `<i class="ri-calendar-check-line mr-2"></i>Before Date`;
|
||||
beforeDate.addEventListener("click", () => this.fillInFilter(queryName, `"<"`, -1));
|
||||
|
||||
const afterDate = document.createElement("button") as HTMLButtonElement;
|
||||
afterDate.type = "button";
|
||||
afterDate.classList.add("button", "~urge", "ml-2");
|
||||
afterDate.innerHTML = `<i class="ri-calendar-check-line mr-2"></i>After Date`;
|
||||
afterDate.addEventListener("click", () => this.fillInFilter(queryName, `">"`, -1));
|
||||
|
||||
container.appendChild(onDate);
|
||||
container.appendChild(beforeDate);
|
||||
container.appendChild(afterDate);
|
||||
}
|
||||
|
||||
this._c.filterList.appendChild(container);
|
||||
}
|
||||
}
|
||||
|
||||
constructor(c: SearchConfiguration) {
|
||||
this._c = c;
|
||||
|
||||
this._c.search.oninput = () => this.onSearchBoxChange();
|
||||
|
||||
const clearSearchButtons = Array.from(document.querySelectorAll(this._c.clearSearchButtonSelector)) as Array<HTMLSpanElement>;
|
||||
for (let b of clearSearchButtons) {
|
||||
b.addEventListener("click", () => {
|
||||
this._c.search.value = "";
|
||||
this.onSearchBoxChange();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
@ -20,7 +20,7 @@ export class Tabs implements Tabs {
|
||||
get current(): string { return this._current; }
|
||||
set current(tabID: string) { this.switch(tabID); }
|
||||
|
||||
switch = (tabID: string, noRun: boolean = false) => {
|
||||
switch = (tabID: string, noRun: boolean = false, keepURL: boolean = false) => {
|
||||
this._current = tabID;
|
||||
for (let t of this.tabs) {
|
||||
if (t.tabID == tabID) {
|
||||
@ -28,7 +28,7 @@ export class Tabs implements Tabs {
|
||||
if (t.preFunc && !noRun) { t.preFunc(); }
|
||||
t.tabEl.classList.remove("unfocused");
|
||||
if (t.postFunc && !noRun) { t.postFunc(); }
|
||||
document.dispatchEvent(new CustomEvent("tab-change", { detail: tabID }));
|
||||
document.dispatchEvent(new CustomEvent("tab-change", { detail: keepURL ? "" : tabID }));
|
||||
} else {
|
||||
t.buttonEl.classList.remove("active");
|
||||
t.buttonEl.classList.remove("~urge");
|
||||
|
@ -80,7 +80,7 @@ declare interface Tabs {
|
||||
current: string;
|
||||
tabs: Array<Tab>;
|
||||
addTab: (tabID: string, preFunc?: () => void, postFunc?: () => void) => void;
|
||||
switch: (tabID: string, noRun?: boolean) => void;
|
||||
switch: (tabID: string, noRun?: boolean, keepURL?: boolean) => void;
|
||||
}
|
||||
|
||||
declare interface Tab {
|
||||
@ -139,7 +139,9 @@ interface inviteList {
|
||||
empty: boolean;
|
||||
invites: { [code: string]: Invite }
|
||||
add: (invite: Invite) => void;
|
||||
reload: () => void;
|
||||
reload: (callback?: () => void) => void;
|
||||
isInviteURL: () => boolean;
|
||||
loadInviteURL: () => void;
|
||||
}
|
||||
|
||||
// Finally added to typescript, dont need this anymore.
|
||||
|
@ -4,6 +4,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/hrfee/mediabrowser"
|
||||
"github.com/lithammer/shortuuid/v3"
|
||||
)
|
||||
|
||||
type userDaemon struct {
|
||||
@ -60,10 +61,10 @@ func (app *appContext) checkUsers() {
|
||||
return
|
||||
}
|
||||
mode := "disable"
|
||||
termPlural := "Disabling"
|
||||
term := "Disabling"
|
||||
if app.config.Section("user_expiry").Key("behaviour").MustString("disable_user") == "delete_user" {
|
||||
mode = "delete"
|
||||
termPlural = "Deleting"
|
||||
term = "Deleting"
|
||||
}
|
||||
contact := false
|
||||
if messagesEnabled && app.config.Section("user_expiry").Key("send_email").MustBool(true) {
|
||||
@ -94,19 +95,33 @@ func (app *appContext) checkUsers() {
|
||||
app.storage.DeleteUserExpiryKey(expiry.JellyfinID)
|
||||
continue
|
||||
}
|
||||
app.info.Printf("%s expired user \"%s\"", termPlural, user.Name)
|
||||
app.info.Printf("%s expired user \"%s\"", term, user.Name)
|
||||
|
||||
// Record activity
|
||||
activity := Activity{
|
||||
UserID: id,
|
||||
SourceType: ActivityDaemon,
|
||||
Time: time.Now(),
|
||||
}
|
||||
|
||||
if mode == "delete" {
|
||||
status, err = app.jf.DeleteUser(id)
|
||||
activity.Type = ActivityDeletion
|
||||
activity.Value = user.Name
|
||||
} else if mode == "disable" {
|
||||
user.Policy.IsDisabled = true
|
||||
// Admins can't be disabled
|
||||
user.Policy.IsAdministrator = false
|
||||
status, err = app.jf.SetPolicy(id, user.Policy)
|
||||
activity.Type = ActivityDisabled
|
||||
}
|
||||
if !(status == 200 || status == 204) || err != nil {
|
||||
app.err.Printf("Failed to %s \"%s\" (%d): %s", mode, user.Name, status, err)
|
||||
continue
|
||||
}
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), activity)
|
||||
|
||||
app.storage.DeleteUserExpiryKey(expiry.JellyfinID)
|
||||
app.jf.CacheExpiry = time.Now()
|
||||
if contact {
|
||||
|
21
views.go
21
views.go
@ -17,6 +17,7 @@ import (
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/gomarkdown/markdown"
|
||||
"github.com/hrfee/mediabrowser"
|
||||
"github.com/lithammer/shortuuid/v3"
|
||||
"github.com/steambap/captcha"
|
||||
)
|
||||
|
||||
@ -38,6 +39,10 @@ func (app *appContext) loadCSSHeader() string {
|
||||
|
||||
func (app *appContext) getURLBase(gc *gin.Context) string {
|
||||
if strings.HasPrefix(gc.Request.URL.String(), app.URLBase) {
|
||||
// Hack to fix the common URL base /accounts
|
||||
if app.URLBase == "/accounts" && strings.HasPrefix(gc.Request.URL.String(), "/accounts/user/") {
|
||||
return ""
|
||||
}
|
||||
return app.URLBase
|
||||
}
|
||||
return ""
|
||||
@ -329,6 +334,7 @@ func (app *appContext) ResetPassword(gc *gin.Context) {
|
||||
}
|
||||
username = pwr.Username
|
||||
}
|
||||
|
||||
if (status == 200 || status == 204) && err == nil && (isInternal || resp.Success) {
|
||||
data["success"] = true
|
||||
data["pin"] = pin
|
||||
@ -338,6 +344,21 @@ func (app *appContext) ResetPassword(gc *gin.Context) {
|
||||
} else {
|
||||
app.err.Printf("Password Reset failed (%d): %v", status, err)
|
||||
}
|
||||
|
||||
// Only log PWRs we know the user for.
|
||||
if username != "" {
|
||||
jfUser, status, err := app.jf.UserByName(username, false)
|
||||
if err == nil && status == 200 {
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityResetPassword,
|
||||
UserID: jfUser.ID,
|
||||
SourceType: ActivityUser,
|
||||
Source: jfUser.ID,
|
||||
Time: time.Now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if app.config.Section("ombi").Key("enabled").MustBool(false) {
|
||||
jfUser, status, err := app.jf.UserByName(username, false)
|
||||
if status != 200 || err != nil {
|
||||
|
Loading…
Reference in New Issue
Block a user