1
0
mirror of https://github.com/hrfee/jfa-go.git synced 2024-06-25 19:07:47 +02:00

Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
4a193b3a63
Merge 97506c0bcd into 43e36ee6fc 2023-10-22 15:35:30 -06:00
36 changed files with 403 additions and 2227 deletions

View File

@ -1,186 +0,0 @@
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)
}

View File

@ -85,14 +85,6 @@ 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(),
})
}
}
@ -138,26 +130,12 @@ 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--
@ -214,7 +192,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, "@") || strings.HasPrefix(req.SendTo, "@")) {
if discordEnabled && !strings.Contains(req.SendTo, "@") {
users := app.discord.GetUsers(req.SendTo)
if len(users) == 0 {
invite.SendTo = fmt.Sprintf("Failed: User not found: \"%s\"", req.SendTo)
@ -258,18 +236,6 @@ 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)
}
@ -463,20 +429,10 @@ func (app *appContext) DeleteInvite(gc *gin.Context) {
var req deleteInviteDTO
gc.BindJSON(&req)
app.debug.Printf("%s: Deletion requested", req.Code)
inv, ok := app.storage.GetInvitesKey(req.Code)
var ok bool
_, 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

View File

@ -5,7 +5,6 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/lithammer/shortuuid/v3"
"gopkg.in/ini.v1"
)
@ -678,18 +677,7 @@ 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)
}
@ -709,16 +697,6 @@ 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)
}
@ -737,16 +715,6 @@ 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)
}
@ -765,15 +733,5 @@ 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)
}

View File

@ -8,7 +8,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt"
"github.com/lithammer/shortuuid/v3"
"github.com/timshannon/badgerhold/v4"
)
@ -208,16 +207,6 @@ 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 {
@ -370,16 +359,6 @@ 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)
}
@ -418,16 +397,6 @@ 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)
}
@ -499,16 +468,6 @@ 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)
}
@ -521,16 +480,6 @@ 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)
}
@ -542,16 +491,6 @@ 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)
}
@ -563,16 +502,6 @@ 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)
}
@ -691,15 +620,6 @@ 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"))

View File

@ -9,7 +9,6 @@ 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"
)
@ -46,17 +45,6 @@ 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 {
@ -315,24 +303,6 @@ 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 != ""),
@ -383,7 +353,6 @@ 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)
}
@ -570,10 +539,6 @@ 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 {
@ -588,16 +553,6 @@ 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)
@ -650,12 +605,6 @@ 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)
@ -665,17 +614,6 @@ 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)
@ -1159,20 +1097,6 @@ 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
View File

@ -7,7 +7,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/hrfee/mediabrowser"
"github.com/itchyny/timefmt-go"
"github.com/lithammer/shortuuid/v3"
"gopkg.in/ini.v1"
)
@ -158,7 +157,6 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
}
username = resp.UsersReset[0]
}
var user mediabrowser.User
var status int
var err error
@ -172,15 +170,6 @@ 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 = ""

View File

@ -78,9 +78,6 @@ 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, "/"), "!"))

View File

@ -515,31 +515,6 @@
}
}
},
"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": {

View File

@ -3,10 +3,6 @@
color: rgba(0, 0, 0, 0) !important;
}
.loader.rel {
position: relative;
}
.loader .dot {
--diameter: 0.5rem;
--radius: calc(var(--diameter) / 2);
@ -19,12 +15,6 @@
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%;
}

View File

@ -3,9 +3,7 @@ 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.
@ -74,37 +72,6 @@ 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 {
@ -124,13 +91,10 @@ 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()
},
func(app *appContext) { app.clearActivities() },
}
daemon.jobs = []func(app *appContext){func(app *appContext) {
app.debug.Println("Housekeeping: Checking for expired invites")
app.checkInvites()
}}
clearEmail := app.config.Section("email").Key("require_unique").MustBool(false)
clearDiscord := app.config.Section("discord").Key("require_unique").MustBool(false)

View File

@ -475,7 +475,6 @@
<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>
@ -720,57 +719,6 @@
</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">

View File

@ -37,9 +37,9 @@
"profile": "Profil",
"unknown": "Ukendt",
"label": "Etiket",
"announce": "Meddelelse",
"announce": "Annoncere",
"subject": "Emne",
"message": "Besked",
"message": "Meddelelse",
"variables": "Variabler",
"conditionals": "Betingelser",
"preview": "Eksempel",
@ -47,13 +47,13 @@
"donate": "Doner",
"contactThrough": "Kontakt gennem:",
"extendExpiry": "Forlæng udløb",
"customizeMessages": "Tilpas Beskeder",
"customizeMessagesDescription": "Hvis du ikke vil bruge jfa-go's besked skabeloner, kan du oprette din egen ved hjælp af Markdown.",
"customizeMessages": "Tilpas Meddelelser",
"customizeMessagesDescription": "Hvis du ikke vil bruge jfa-go's meddelelses 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 besked",
"sendDeleteNotificationEmail": "Send notifikations meddelelse",
"sendDeleteNotifiationExample": "Din konto er blevet slettet.",
"settingsRestart": "Genstart",
"settingsRestarting": "Genstarter…",
@ -102,35 +102,7 @@
"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.",
"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"
"accessJFASettings": "Kan ikke ændres, da enten \"Kun administrator\" eller \"Tillad alle\" er blevet indstillet i Indstillinger > Generelt."
},
"notifications": {
"changedEmailAddress": "Ændret e-mail adresse på {n}.",
@ -161,16 +133,14 @@
"errorFailureCheckLogs": "Mislykkedes (tjek konsol/logfiler)",
"errorPartialFailureCheckLogs": "Delvis fejl (tjek konsol/logfiler)",
"errorUserCreated": "Kunne ikke oprette bruger {n}.",
"errorSendWelcomeEmail": "Kunne ikke sende velkomst besked (tjek konsol/logfiler",
"errorSendWelcomeEmail": "Kunne ikke sende velkomst meddelelse (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.",
"referralsEnabled": "Henvisninger aktiveret.",
"errorNoReferralTemplate": "Profilen indeholder ikke en henvisningsskabelon. Tilføj en i indstillingerne."
"errorSetOmbiProfile": "Ombi profilen kunne ikke gemmes."
},
"quantityStrings": {
"modifySettingsFor": {
@ -210,8 +180,8 @@
"plural": "Aktiveret {n} brugere."
},
"announceTo": {
"singular": "Send Meddelelse til {n} bruger",
"plural": "Send Meddelelse til {n} brugere"
"singular": "Annoncer til {n} bruger",
"plural": "Annoncer til {n} brugere"
},
"appliedSettings": {
"singular": "Anvendte indstillinger til {n} bruger.",
@ -228,10 +198,6 @@
"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"
}
}
}
}

View File

@ -6,7 +6,6 @@
"invites": "Invites",
"invite": "Invite",
"accounts": "Accounts",
"activity": "Activity",
"settings": "Settings",
"inviteMonths": "Months",
"inviteDays": "Days",
@ -55,12 +54,8 @@
"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.",
@ -123,7 +118,6 @@
"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",
@ -135,47 +129,7 @@
"userPagePage": "User Page: Page",
"buildTime": "Build Time",
"builtBy": "Built By",
"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"
"loginNotAdmin": "Not an Admin?"
},
"notifications": {
"changedEmailAddress": "Changed email address of {n}.",
@ -191,9 +145,6 @@
"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.",
@ -215,7 +166,6 @@
"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."
},

View File

@ -102,35 +102,7 @@
"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.",
"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"
"accessJFASettings": "Kan niet worden aangepast, omdat \"Alleen beheerders\" of \"Laat alle Jellyfin-gebruikers inloggen\" is aangevinkt in Instellingen > Algemeen."
},
"notifications": {
"changedEmailAddress": "E-mailadres van {n} gewijzigd.",
@ -168,9 +140,7 @@
"accountConnected": "Account gekoppeld.",
"savedAnnouncement": "Aankondiging opgeslagen.",
"setOmbiProfile": "Opgeslagen ombi-profiel.",
"errorSetOmbiProfile": "Opslaan van ombi-profiel mislukt.",
"errorNoReferralTemplate": "Profiel bevat geen verwijzingssjabloon, voeg er een toe bij instellingen.",
"referralsEnabled": "Verwijzingen actief."
"errorSetOmbiProfile": "Opslaan van ombi-profiel mislukt."
},
"quantityStrings": {
"modifySettingsFor": {
@ -228,10 +198,6 @@
"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"
}
}
}
}

View File

@ -5,7 +5,7 @@
"strings": {
"username": "Brugernavn",
"password": "Adgangskode",
"emailAddress": "Email adresse",
"emailAddress": "E-mail Adresse",
"name": "Navn",
"submit": "Indsend",
"send": "Send",
@ -36,12 +36,7 @@
"add": "Tilføj",
"edit": "Rediger",
"delete": "Slet",
"inviteRemainingUses": "Resterende anvendelser",
"referrals": "Henvisninger",
"contactMethods": "Kontakt Metoder",
"accountStatus": "Kontostatus",
"notSet": "Ikke sat",
"myAccount": "Min Konto"
"inviteRemainingUses": "Resterende anvendelser"
},
"notifications": {
"errorLoginBlank": "Brugernavnet og/eller adgangskoden blev efterladt tomme.",
@ -50,18 +45,5 @@
"error401Unauthorized": "Adgang nægtet. Prøv at genindlæse siden.",
"errorSaveSettings": "Kunne ikke gemme indstillingerne."
},
"quantityStrings": {
"year": {
"singular": "{n} År",
"plural": "{n} År"
},
"month": {
"singular": "{n} Månede",
"plural": "{n} Måneder"
},
"day": {
"singular": "{n} Dag",
"plural": "{n} Dage"
}
}
}
"quantityStrings": {}
}

View File

@ -36,12 +36,7 @@
"add": "Voeg toe",
"edit": "Bewerken",
"delete": "Verwijderen",
"inviteRemainingUses": "Resterend aantal keer te gebruiken",
"referrals": "Verwijzingen",
"contactMethods": "Contactmethodes",
"accountStatus": "Account status",
"notSet": "Niet ingesteld",
"myAccount": "Mijn account"
"inviteRemainingUses": "Resterend aantal keer te gebruiken"
},
"notifications": {
"errorLoginBlank": "De gebruikersnaam en/of wachtwoord is leeg.",
@ -50,18 +45,5 @@
"error401Unauthorized": "Geen toegang. Probeer de pagina te vernieuwen.",
"errorSaveSettings": "Opslaan van instellingen mislukt."
},
"quantityStrings": {
"year": {
"singular": "{n} jaar",
"plural": "{n} jaar"
},
"month": {
"singular": "{n} maand",
"plural": "{n} maanden"
},
"day": {
"singular": "{n} dag",
"plural": "{n} dagen"
}
}
}
"quantityStrings": {}
}

View File

@ -19,25 +19,7 @@
"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.",
"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}."
"matrixEnterUser": "Skriv dit Bruger ID, tryk Indsend, og en PIN-kode vil blive sendt til dig. Skriv den her efter, for at fortsætte."
},
"notifications": {
"errorUserExists": "Brugeren eksistere allerede.",
@ -51,11 +33,7 @@
"errorNoEmail": "E-mail er påkrævet.",
"errorCaptcha": "Forkert Captcha.",
"errorPassword": "Tjek krav til adgangskode.",
"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."
"errorNoMatch": "Adgangskoder stemmer ikke overens."
},
"validationStrings": {
"length": {
@ -79,4 +57,4 @@
"plural": "Skal mindst have {n} specialtegn"
}
}
}
}

View File

@ -27,17 +27,7 @@
"editContactMethod": "Wijzig contact methode",
"joinTheServer": "Word lid van de server:",
"resetPassword": "Wachtwoord opnieuw instellen",
"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}."
"changePassword": "Wachtwoord wijzigen"
},
"validationStrings": {
"length": {

View File

@ -11,7 +11,7 @@
"disabled": "Deaktiveret",
"enabled": "Aktiveret",
"port": "Port",
"message": "Besked",
"message": "Meddelelse",
"serverAddress": "Serveradresse",
"emailSubject": "E-mail emne",
"URL": "URL",
@ -20,9 +20,7 @@
"errorInvalidUserPass": "Ugyldigt brugernavn/adgangskode.",
"errorUserDisabled": "Bruger kan være deaktiveret.",
"error404": "404, tjek den interne URL.",
"errorConnectionRefused": "Tilslutning afvist.",
"error": "Fejl",
"errorUnknown": "Ukendt fejl, tjek app logfiler."
"errorConnectionRefused": "Tilslutning afvist."
},
"startPage": {
"welcome": "Velkommen!",
@ -32,7 +30,7 @@
},
"endPage": {
"finished": "Færdig!",
"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.",
"restartMessage": "Du kan konfigurere Discord/Telegram/Matrix bots, tilpasse dine meddelelser og mere i Indstillinger. Klik herunder for at genstarte, og opdater siden.",
"refreshPage": "Opdater"
},
"language": {
@ -70,8 +68,7 @@
"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.",
"authorizeManualUserPageNotice": "Brug af dette vil deaktivere \"Brugerside\" funktionen."
"allowAllDescription": "Det anbefales ikke, du bør tillade individuelle brugere at logge ind, når de er konfigureret."
},
"jellyfinEmby": {
"title": "Jellyfin/Emby",
@ -116,15 +113,14 @@
},
"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 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.",
"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.",
"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. Dette er ikke nødvendigt, hvis du kun ønsker at bruge selvbetjenings nulstilling af adgangskode via \"Brugersiden\".",
"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.",
"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å.",
"resetLinksRequiredForUserPage": "Nødvendig for nulstilling af selvbetjenings adgangskode på brugersiden."
"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å."
},
"passwordValidation": {
"title": "Validering af adgangskode",
@ -150,11 +146,5 @@
"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."
}
}
}

View File

@ -20,9 +20,7 @@
"errorUserDisabled": "De gebruiker kan uitgeschakeld zijn.",
"error404": "404, controleer de interne URL.",
"errorNotAdmin": "Gebruiker heeft geen beheersrechten.",
"errorConnectionRefused": "Verbinding geweigerd.",
"errorUnknown": "Onbekende fout, bekijk de logs.",
"error": "Fout"
"errorConnectionRefused": "Verbinding geweigerd."
},
"startPage": {
"welcome": "Welkom!",
@ -32,7 +30,7 @@
},
"endPage": {
"finished": "Klaar!",
"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.",
"restartMessage": "Je kunt Discord/Telegram/Matrix bots instellen, berichten aanpassen en meer bij Instellingen. Klik hieronder om te herstarten, en ververs de pagina.",
"refreshPage": "Verversen"
},
"language": {
@ -63,8 +61,7 @@
"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.",
"authorizeManualUserPageNotice": "Gebruik hiervan schakelt de \"Gebruikerspagina\" functie uit."
"allowAllDescription": "Afgeraden, je kunt beter individuele gebruikers toegang geven na de setup."
},
"jellyfinEmby": {
"title": "Jellyfin/Emby",
@ -109,15 +106,14 @@
},
"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. Als je de \"Gebruikerspagina\" functionaliteit inschakelt, kan een reset ook daar worden gedaan aan de hand van een gebruikersnaam, e-mail, of contactmethode.",
"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.",
"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'. Dit is niet nodig als je alleen zelfzervice wachtwoordresets via de \"Gebruikerspagina\" wilt gebruiken.",
"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'.",
"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.",
"resetLinksRequiredForUserPage": "Nodig voor zelfservice wachtwoordreset op de gebruikerspagina."
"setPasswordNotice": "Als dit aanstaat hoeft de gebruiker het wachtwoord niet te wijzigen van de PINcode na de reset. Wachtwoordvalidatie wordt ook afgedwongen."
},
"passwordValidation": {
"title": "Wachtwoordvalidatie",
@ -150,11 +146,5 @@
"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."
}
}
}

View File

@ -9,10 +9,8 @@
"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": "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."
"languageMessageDiscord": "Bemærk: Indstil dit sprog med /lang <sprognavn>.",
"languageSet": "Sprog indstillet til {language}.",
"discordDMs": "Tjek venligst dine DM's for et svar."
}
}
}

View File

@ -11,8 +11,6 @@
"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.",
"sentInviteFailure": "Fout bij versturen uitnodiging, bekijk de logs.",
"sentInvite": "Uitnodiging verstuurd."
"discordDMs": "Bekijk alsjeblieft je DMs voor een antwoord."
}
}
}

View File

@ -638,9 +638,6 @@ 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.

View File

@ -430,32 +430,3 @@ 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"`
}

View File

@ -118,9 +118,6 @@ 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)
@ -235,10 +232,6 @@ 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)

View File

@ -21,42 +21,6 @@ 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
@ -550,32 +514,6 @@ 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"`

View File

@ -5,7 +5,6 @@ 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";
@ -90,8 +89,6 @@ var inviteCreator = new createInvite();
var accounts = new accountsList();
var activity = new activityList();
window.invites = new inviteList();
var settings = new settingsList();
@ -123,10 +120,6 @@ const tabs: { url: string, reloader: () => void }[] = [
url: "accounts",
reloader: accounts.reload
},
{
url: "activity",
reloader: activity.reload
},
{
url: "settings",
reloader: settings.reload
@ -144,9 +137,6 @@ 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);
@ -156,9 +146,7 @@ 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 (event.detail == "") {
tab = window.location.pathname;
} else if (tab == window.URLBase + "/invites") {
if (tab == window.URLBase + "/invites") {
if (window.location.pathname == window.URLBase + "/") {
tab = window.URLBase + "/";
} else if (window.URLBase) { tab = window.URLBase; }
@ -179,7 +167,6 @@ 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) {
@ -192,23 +179,6 @@ 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;
}
}

View File

@ -3,7 +3,6 @@ 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 {
@ -40,7 +39,7 @@ interface announcementTemplate {
var addDiscord: (passData: string) => void;
class user implements User, SearchableItem {
class user implements User {
private _id = "";
private _row: HTMLTableRowElement;
private _check: HTMLInputElement;
@ -74,8 +73,6 @@ class user implements User, SearchableItem {
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 != "";
@ -272,7 +269,7 @@ class user implements User, SearchableItem {
<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");
@ -783,13 +780,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 _searchBox = document.getElementById("accounts-search") as HTMLInputElement;
private _search: Search;
private _search = document.getElementById("accounts-search") as HTMLInputElement;
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;
@ -839,7 +836,7 @@ export class accountsList {
}
}
private _queries: { [field: string]: QueryType } = {
private _queries: { [field: string]: { name: string, getter: string, bool: boolean, string: boolean, date: boolean, dependsOnTableHeader?: string, show?: boolean }} = {
"id": {
// We don't use a translation here to circumvent the name substitution feature.
name: "Jellyfin/Emby ID",
@ -890,7 +887,7 @@ export class accountsList {
bool: true,
string: false,
date: false,
dependsOnElement: ".accounts-header-access-jfa"
dependsOnTableHeader: "accounts-header-access-jfa"
},
"email": {
name: window.lang.strings("emailAddress"),
@ -898,7 +895,7 @@ export class accountsList {
bool: true,
string: true,
date: false,
dependsOnElement: ".accounts-header-email"
dependsOnTableHeader: "accounts-header-email"
},
"telegram": {
name: "Telegram",
@ -906,7 +903,7 @@ export class accountsList {
bool: true,
string: true,
date: false,
dependsOnElement: ".accounts-header-telegram"
dependsOnTableHeader: "accounts-header-telegram"
},
"matrix": {
name: "Matrix",
@ -914,7 +911,7 @@ export class accountsList {
bool: true,
string: true,
date: false,
dependsOnElement: ".accounts-header-matrix"
dependsOnTableHeader: "accounts-header-matrix"
},
"discord": {
name: "Discord",
@ -922,7 +919,7 @@ export class accountsList {
bool: true,
string: true,
date: false,
dependsOnElement: ".accounts-header-discord"
dependsOnTableHeader: "accounts-header-discord"
},
"expiry": {
name: window.lang.strings("expiry"),
@ -930,7 +927,7 @@ export class accountsList {
bool: true,
string: false,
date: true,
dependsOnElement: ".accounts-header-expiry"
dependsOnTableHeader: "accounts-header-expiry"
},
"last-active": {
name: window.lang.strings("lastActiveTime"),
@ -945,12 +942,229 @@ export class accountsList {
bool: true,
string: false,
date: false,
dependsOnElement: ".accounts-header-referrals"
dependsOnTableHeader: "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;
@ -1680,25 +1894,6 @@ 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 = {};
@ -1706,7 +1901,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 = () => {
@ -1819,23 +2014,34 @@ export class accountsList {
this._deleteNotify.checked = false;
}*/
let conf: SearchConfiguration = {
filterArea: this._filterArea,
sortingByButton: this._sortingByButton,
searchOptionsHeader: this._searchOptionsHeader,
notFoundPanel: this._notFoundPanel,
filterList: document.getElementById("accounts-filter-list"),
search: this._searchBox,
queries: this._queries,
setVisibility: this.setVisibility,
clearSearchButtonSelector: ".accounts-search-clear",
onSearchCallback: (_0: number, _1: boolean, _2: boolean) => {
this._checkCheckCount();
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");
}
};
this._search = new Search(conf);
this._search.items = this._users;
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._announceTextarea.onkeyup = this.loadPreview;
addDiscord = newDiscordSearch(window.lang.strings("linkDiscord"), window.lang.strings("searchDiscordUser"), window.lang.strings("add"), (user: DiscordUser, id: string) => {
@ -1882,16 +2088,15 @@ 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._search.inSearch)) {
if (!(this._inSearch)) {
this.setVisibility(this._ordering, true);
this._notFoundPanel.classList.add("unfocused");
} else {
const results = this._search.search(this._searchBox.value);
const results = this.search(this._search.value);
this.setVisibility(results, true);
if (results.length == 0) {
this._notFoundPanel.classList.remove("unfocused");
@ -1905,12 +2110,87 @@ export class accountsList {
defaultSort();
this.showHideSearchOptionsHeader();
this._search.generateFilterList();
const filterList = document.getElementById("accounts-filter-list");
this.registerURLListener();
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);
}
}
reload = (callback?: () => void) => {
reload = () => {
_get("/users", null, (req: XMLHttpRequest) => {
if (req.readyState == 4 && req.status == 200) {
// same method as inviteList.reload()
@ -1930,12 +2210,11 @@ export class accountsList {
}
// console.log("reload, so sorting by", this._activeSortColumn);
this._ordering = this._columns[this._activeSortColumn].sort(this._users);
this._search.ordering = this._ordering;
if (!(this._search.inSearch)) {
if (!(this._inSearch)) {
this.setVisibility(this._ordering, true);
this._notFoundPanel.classList.add("unfocused");
} else {
const results = this._search.search(this._searchBox.value);
const results = this.search(this._search.value);
if (results.length == 0) {
this._notFoundPanel.classList.remove("unfocused");
} else {
@ -1944,16 +2223,12 @@ 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;

View File

@ -1,736 +0,0 @@
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;
}
}

View File

@ -199,10 +199,9 @@ export function toggleLoader(el: HTMLElement, small: boolean = true) {
}
}
export function addLoader(el: HTMLElement, small: boolean = true, relative: boolean = false) {
export function addLoader(el: HTMLElement, small: boolean = true) {
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")
@ -214,7 +213,6 @@ 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(); }
}

View File

@ -261,8 +261,6 @@ 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;
@ -425,26 +423,6 @@ 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;
@ -458,8 +436,6 @@ export class inviteList implements inviteList {
this.empty = true;
}
}, false);
this.registerURLListener();
}
get empty(): boolean { return this._empty; }
@ -492,7 +468,7 @@ export class inviteList implements inviteList {
this._list.appendChild(domInv.asElement());
}
reload = (callback?: () => void) => _get("/invites", null, (req: XMLHttpRequest) => {
reload = () => _get("/invites", null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
let data = req.response;
if (req.status == 200) {
@ -521,13 +497,10 @@ 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 = {};

View File

@ -1,390 +0,0 @@
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();
});
}
}
}

View File

@ -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, keepURL: boolean = false) => {
switch = (tabID: string, noRun: 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: keepURL ? "" : tabID }));
document.dispatchEvent(new CustomEvent("tab-change", { detail: tabID }));
} else {
t.buttonEl.classList.remove("active");
t.buttonEl.classList.remove("~urge");

View File

@ -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, keepURL?: boolean) => void;
switch: (tabID: string, noRun?: boolean) => void;
}
declare interface Tab {
@ -139,9 +139,7 @@ interface inviteList {
empty: boolean;
invites: { [code: string]: Invite }
add: (invite: Invite) => void;
reload: (callback?: () => void) => void;
isInviteURL: () => boolean;
loadInviteURL: () => void;
reload: () => void;
}
// Finally added to typescript, dont need this anymore.

View File

@ -4,7 +4,6 @@ import (
"time"
"github.com/hrfee/mediabrowser"
"github.com/lithammer/shortuuid/v3"
)
type userDaemon struct {
@ -61,10 +60,10 @@ func (app *appContext) checkUsers() {
return
}
mode := "disable"
term := "Disabling"
termPlural := "Disabling"
if app.config.Section("user_expiry").Key("behaviour").MustString("disable_user") == "delete_user" {
mode = "delete"
term = "Deleting"
termPlural = "Deleting"
}
contact := false
if messagesEnabled && app.config.Section("user_expiry").Key("send_email").MustBool(true) {
@ -95,33 +94,19 @@ func (app *appContext) checkUsers() {
app.storage.DeleteUserExpiryKey(expiry.JellyfinID)
continue
}
app.info.Printf("%s expired user \"%s\"", term, user.Name)
// Record activity
activity := Activity{
UserID: id,
SourceType: ActivityDaemon,
Time: time.Now(),
}
app.info.Printf("%s expired user \"%s\"", termPlural, user.Name)
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 {

View File

@ -17,7 +17,6 @@ import (
"github.com/golang-jwt/jwt"
"github.com/gomarkdown/markdown"
"github.com/hrfee/mediabrowser"
"github.com/lithammer/shortuuid/v3"
"github.com/steambap/captcha"
)
@ -39,10 +38,6 @@ 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 ""
@ -334,7 +329,6 @@ 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
@ -344,21 +338,6 @@ 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 {