mirror of
https://github.com/hrfee/jfa-go.git
synced 2026-03-18 21:50:33 +01:00
Compare commits
16 Commits
dependabot
...
76878976ee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76878976ee | ||
|
|
5aa640d63d | ||
|
|
d7fdf29c7c | ||
|
|
60bf89bc02 | ||
|
|
637cca0625 | ||
|
|
455bde491f | ||
|
|
ee96bb9f1b | ||
|
|
721b209e1f | ||
|
|
c10f1e3b36 | ||
|
|
3308739619 | ||
|
|
748acc13c0 | ||
|
|
c3bac6c51c | ||
|
|
3e39657642 | ||
|
|
41334e9051 | ||
|
|
e2c34e574d | ||
|
|
b6459b665d |
105
api-users.go
105
api-users.go
@@ -1022,6 +1022,37 @@ func (app *appContext) GetLabels(gc *gin.Context) {
|
||||
gc.JSON(200, LabelsDTO{Labels: app.userCache.Labels})
|
||||
}
|
||||
|
||||
// @Summary Get the details of a given user. Skips the user cache and gets fresh information.
|
||||
// @Produce json
|
||||
// @Success 200 {object} respUser
|
||||
// @Failure 400 {object} boolResponse
|
||||
// @Failure 500 {object} stringResponse
|
||||
// @Param id path string true "id of user to fetch details of"
|
||||
// @Router /users/{id} [get]
|
||||
// @Security Bearer
|
||||
// @tags Users
|
||||
func (app *appContext) GetUser(gc *gin.Context) {
|
||||
userID := gc.Param("id")
|
||||
if userID == "" {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
user, err := app.jf.UserByID(userID, false)
|
||||
if err != nil {
|
||||
switch err.(type) {
|
||||
case mediabrowser.ErrUserNotFound:
|
||||
respondBool(400, false, gc)
|
||||
app.err.Printf(lm.FailedGetUser, userID, lm.Jellyfin, err)
|
||||
return
|
||||
default:
|
||||
respond(500, "Check logs", gc)
|
||||
app.err.Printf(lm.FailedGetUser, userID, lm.Jellyfin, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
gc.JSON(200, app.GetUserSummary(user))
|
||||
}
|
||||
|
||||
// @Summary Get a list of -all- Jellyfin users.
|
||||
// @Produce json
|
||||
// @Success 200 {object} getUsersDTO
|
||||
@@ -1420,7 +1451,30 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
|
||||
gc.JSON(code, errors)
|
||||
}
|
||||
|
||||
// @Summary Get the latest Jellyfin/Emby activities related to the given user ID. Returns as many as the server has recorded.
|
||||
// @Summary Gets the number of Jellyfin/Emby activities stored by jfa-go related to the given user ID. As the total collected by jfa-go is limited, this may not include all those held by Jellyfin.
|
||||
// @Produce json
|
||||
// @Success 200 {object} PageCountDTO
|
||||
// @Failure 400 {object} boolResponse
|
||||
// @Param id path string true "id of user to fetch activities of."
|
||||
// @Router /users/{id}/activities/jellyfin/count [get]
|
||||
// @Security Bearer
|
||||
// @tags Users
|
||||
func (app *appContext) CountJFActivitesForUser(gc *gin.Context) {
|
||||
userID := gc.Param("id")
|
||||
if userID == "" {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
activities, err := app.jf.activity.ByUserID(userID)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGetJFActivities, err)
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
gc.JSON(200, PageCountDTO{Count: uint64(len(activities))})
|
||||
}
|
||||
|
||||
// @Summary Get the latest Jellyfin/Emby activities related to the given user ID. Returns as many as the server has recorded. As the total collected by jfa-go is limited, this may not include all those held by Jellyfin.
|
||||
// @Produce json
|
||||
// @Success 200 {object} ActivityLogEntriesDTO
|
||||
// @Failure 400 {object} boolResponse
|
||||
@@ -1429,12 +1483,12 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
|
||||
// @Security Bearer
|
||||
// @tags Users
|
||||
func (app *appContext) GetJFActivitesForUser(gc *gin.Context) {
|
||||
userId := gc.Param("id")
|
||||
if userId == "" {
|
||||
userID := gc.Param("id")
|
||||
if userID == "" {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
activities, err := app.jf.activity.ByUserID(userId)
|
||||
activities, err := app.jf.activity.ByUserID(userID)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGetJFActivities, err)
|
||||
respondBool(400, false, gc)
|
||||
@@ -1450,3 +1504,46 @@ func (app *appContext) GetJFActivitesForUser(gc *gin.Context) {
|
||||
app.debug.Printf(lm.GotNEntries, len(activities))
|
||||
gc.JSON(200, out)
|
||||
}
|
||||
|
||||
// @Summary Get the latest Jellyfin/Emby activities related to the given user ID, paginated. As the total collected by jfa-go is limited, this may not include all those held by Jellyfin.
|
||||
// @Produce json
|
||||
// @Param PaginatedReqDTO body PaginatedReqDTO true "pagination parameters"
|
||||
// @Success 200 {object} PaginatedActivityLogEntriesDTO
|
||||
// @Failure 400 {object} boolResponse
|
||||
// @Param id path string true "id of user to fetch activities of."
|
||||
// @Router /users/{id}/activities/jellyfin [post]
|
||||
// @Security Bearer
|
||||
// @tags Users
|
||||
func (app *appContext) GetPaginatedJFActivitesForUser(gc *gin.Context) {
|
||||
var req PaginatedReqDTO
|
||||
gc.BindJSON(&req)
|
||||
userID := gc.Param("id")
|
||||
if userID == "" {
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
activities, err := app.jf.activity.ByUserID(userID)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGetJFActivities, err)
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
out := PaginatedActivityLogEntriesDTO{}
|
||||
startIndex := req.Page * req.Limit
|
||||
if startIndex >= len(activities) {
|
||||
out.LastPage = true
|
||||
gc.JSON(200, out)
|
||||
return
|
||||
}
|
||||
endIndex := min(startIndex+req.Limit, len(activities))
|
||||
activities = activities[startIndex:endIndex]
|
||||
|
||||
out.Entries = make([]ActivityLogEntryDTO, len(activities))
|
||||
out.LastPage = len(activities) != req.Limit
|
||||
for i := range activities {
|
||||
out.Entries[i].ActivityLogEntry = activities[i]
|
||||
out.Entries[i].Date = activities[i].Date.Unix()
|
||||
}
|
||||
app.debug.Printf(lm.GotNEntries, len(activities))
|
||||
gc.JSON(200, out)
|
||||
}
|
||||
|
||||
@@ -1179,6 +1179,13 @@ sections:
|
||||
type: text
|
||||
value: /path/to/jellyfin
|
||||
description: Path to the folder Jellyfin puts password-reset files.
|
||||
- setting: watch_polling
|
||||
name: Use polling
|
||||
requires_restart: true
|
||||
depends_true: watch_directory
|
||||
type: bool
|
||||
value: false
|
||||
description: Use if mounting over network (NFS/SMB/SFTP). Watch the Jellyfin directory by checking periodically, rather than using OS APIs.
|
||||
- setting: link_reset
|
||||
name: Use reset link instead of PIN (Required for Ombi)
|
||||
requires_restart: true
|
||||
|
||||
142
css/tooltip.css
142
css/tooltip.css
@@ -1,71 +1,95 @@
|
||||
.tooltip {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
@layer components {
|
||||
tool-tip {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tooltip .content {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
max-width: 16rem;
|
||||
min-width: 6rem;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
overflow-wrap: break-word;
|
||||
text-align: center;
|
||||
transition: opacity 100ms;
|
||||
tool-tip .content {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
width: max-content;
|
||||
max-width: 16rem;
|
||||
/*min-width: 6rem;*/
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
overflow-wrap: break-word;
|
||||
text-align: center;
|
||||
transition: opacity 100ms;
|
||||
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: -1rem;
|
||||
}
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: -1rem;
|
||||
}
|
||||
|
||||
.tooltip.below .content {
|
||||
top: calc(100% + 0.125rem);
|
||||
left: 50%;
|
||||
right: 0;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
tool-tip.above {
|
||||
--tooltip-position: above;
|
||||
}
|
||||
tool-tip.below {
|
||||
--tooltip-position: below;
|
||||
}
|
||||
tool-tip.below-center {
|
||||
--tooltip-position: below-center;
|
||||
}
|
||||
tool-tip.left {
|
||||
--tooltip-position: left;
|
||||
}
|
||||
tool-tip.right {
|
||||
--tooltip-position: right;
|
||||
}
|
||||
|
||||
.tooltip.above .content {
|
||||
top: unset;
|
||||
bottom: calc(100% + 0.125rem);
|
||||
left: 50%;
|
||||
right: 0;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
tool-tip.below .content {
|
||||
top: calc(100% + 0.125rem);
|
||||
left: 50%;
|
||||
right: 0;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.tooltip.darker .content {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
tool-tip.below-center .content {
|
||||
top: calc(100% + 0.125rem);
|
||||
max-width: calc(100vw - 4rem);
|
||||
}
|
||||
|
||||
.tooltip.right .content {
|
||||
left: 120%;
|
||||
}
|
||||
tool-tip.above .content {
|
||||
top: unset;
|
||||
bottom: calc(100% + 0.125rem);
|
||||
left: 50%;
|
||||
right: 0;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.tooltip.right:dir(rtl):not(.force-ltr) .content {
|
||||
right: 120%;
|
||||
left: unset;
|
||||
}
|
||||
tool-tip.darker .content {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.tooltip.left .content {
|
||||
right: 120%;
|
||||
}
|
||||
tool-tip.right .content {
|
||||
left: 0%;
|
||||
}
|
||||
|
||||
.tooltip.left:dir(rtl):not(.force-ltr) .content {
|
||||
left: 120%;
|
||||
right: unset;
|
||||
}
|
||||
tool-tip.right:dir(rtl):not(.force-ltr) .content {
|
||||
right: 0%;
|
||||
left: unset;
|
||||
}
|
||||
|
||||
.tooltip .content.sm {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
tool-tip.left .content {
|
||||
right: 0%;
|
||||
}
|
||||
|
||||
.tooltip:hover .content,
|
||||
.tooltip:focus .content,
|
||||
.tooltip:focus-within .content
|
||||
{
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
tool-tip.left:dir(rtl):not(.force-ltr) .content {
|
||||
left: 0%;
|
||||
right: unset;
|
||||
}
|
||||
|
||||
tool-tip .content.sm {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/*tool-tip:hover .content,
|
||||
tool-tip:focus .content,
|
||||
tool-tip:focus-within .content*/
|
||||
tool-tip.shown .content {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
5
email.go
5
email.go
@@ -88,6 +88,9 @@ func NewEmailer(config *Config, storage *Storage, logs LoggerSet) *Emailer {
|
||||
config: config,
|
||||
storage: storage,
|
||||
}
|
||||
if !strings.Contains(emailer.fromAddr, "@") {
|
||||
emailer.err.Printf(lm.FailedInitMailer, "", fmt.Errorf(lm.InvalidFromAddress, emailer.fromAddr))
|
||||
}
|
||||
method := emailer.config.Section("email").Key("method").String()
|
||||
if method == "smtp" {
|
||||
enc := sMail.EncryptionSTARTTLS
|
||||
@@ -107,7 +110,7 @@ func NewEmailer(config *Config, storage *Storage, logs LoggerSet) *Emailer {
|
||||
authType := sMail.AuthType(emailer.config.Section("smtp").Key("auth_type").MustInt(4))
|
||||
err := emailer.NewSMTP(emailer.config.Section("smtp").Key("server").String(), emailer.config.Section("smtp").Key("port").MustInt(465), username, password, enc, emailer.config.Section("smtp").Key("ssl_cert").MustString(""), emailer.config.Section("smtp").Key("hello_hostname").String(), emailer.config.Section("smtp").Key("cert_validation").MustBool(true), authType, emailer.config.proxyConfig)
|
||||
if err != nil {
|
||||
emailer.err.Printf(lm.FailedInitSMTP, err)
|
||||
emailer.err.Printf(lm.FailedInitMailer, lm.SMTP, err)
|
||||
}
|
||||
} else if method == "mailgun" {
|
||||
emailer.NewMailgun(emailer.config.Section("mailgun").Key("api_url").String(), emailer.config.Section("mailgun").Key("api_key").String(), emailer.config.proxyTransport)
|
||||
|
||||
3
go.mod
3
go.mod
@@ -42,7 +42,7 @@ require (
|
||||
github.com/hrfee/jfa-go/logger v0.0.0-20251123165523-7c9f91711460
|
||||
github.com/hrfee/jfa-go/logmessages v0.0.0-20251123165523-7c9f91711460
|
||||
github.com/hrfee/jfa-go/ombi v0.0.0-20251123165523-7c9f91711460
|
||||
github.com/hrfee/mediabrowser v0.3.35
|
||||
github.com/hrfee/mediabrowser v0.3.36
|
||||
github.com/hrfee/simple-template v1.1.0
|
||||
github.com/itchyny/timefmt-go v0.1.7
|
||||
github.com/lithammer/shortuuid/v3 v3.0.7
|
||||
@@ -109,6 +109,7 @@ require (
|
||||
github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.57.0 // indirect
|
||||
github.com/radovskyb/watcher v1.0.7 // indirect
|
||||
github.com/rs/zerolog v1.34.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/swaggo/swag v1.16.6 // indirect
|
||||
|
||||
4
go.sum
4
go.sum
@@ -200,6 +200,8 @@ github.com/hrfee/mediabrowser v0.3.34 h1:AKnd1V9wt+KWZmHDjj1GMkCgcgcpBKxPw5iUcYg
|
||||
github.com/hrfee/mediabrowser v0.3.34/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
|
||||
github.com/hrfee/mediabrowser v0.3.35 h1:xEq4cL96Di0G+S3ONBH1HHeQJU6IfUMZiaeGeuJSFS8=
|
||||
github.com/hrfee/mediabrowser v0.3.35/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
|
||||
github.com/hrfee/mediabrowser v0.3.36 h1:erYWzmaz4b6wfxEfeEWQHLMI9fSpGDy1m7NxkQmIVsw=
|
||||
github.com/hrfee/mediabrowser v0.3.36/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
|
||||
github.com/hrfee/simple-template v1.1.0 h1:PNQDTgc2H0s19/pWuhRh4bncuNJjPrW0fIX77YtY78M=
|
||||
github.com/hrfee/simple-template v1.1.0/go.mod h1:s9a5QgfqbmT7j9WCC3GD5JuEqvihBEohyr+oYZmr4bA=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
@@ -277,6 +279,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3AdqE=
|
||||
github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
|
||||
github.com/radovskyb/watcher v1.0.7 h1:AYePLih6dpmS32vlHfhCeli8127LzkIgwJGcwwe8tUE=
|
||||
github.com/radovskyb/watcher v1.0.7/go.mod h1:78okwvY5wPdzcb1UYnip1pvrZNIVEIh/Cm+ZuvsUYIg=
|
||||
github.com/robert-nix/ansihtml v1.0.1 h1:VTiyQ6/+AxSJoSSLsMecnkh8i0ZqOEdiRl/odOc64fc=
|
||||
github.com/robert-nix/ansihtml v1.0.1/go.mod h1:CJwclxYaTPc2RfcxtanEACsYuTksh4yDXcNeHHKZINE=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
|
||||
24
html/accounts-table-header.txt
Normal file
24
html/accounts-table-header.txt
Normal file
@@ -0,0 +1,24 @@
|
||||
<thead id="accounts-table-header">
|
||||
<tr>
|
||||
<th><input type="checkbox" value="" id="accounts-select-all"></th>
|
||||
<th class="table-inline my-2 grid gap-4 place-items-stretch accounts-header-username">{{ .strings.username }}</th>
|
||||
{{ if .jellyfinLogin }}
|
||||
<th class="text-center-i grid gap-4 place-items-stretch accounts-header-access-jfa">{{ .strings.accessJFA }}</th>
|
||||
{{ end }}
|
||||
<th class="grid gap-4 place-items-stretch accounts-header-email">{{ .strings.emailAddress }}</th>
|
||||
{{ if .telegramEnabled }}
|
||||
<th class="text-center-i grid gap-4 place-items-stretch accounts-header-telegram">Telegram</th>
|
||||
{{ end }}
|
||||
{{ if .matrixEnabled }}
|
||||
<th class="text-center-i grid gap-4 place-items-stretch accounts-header-matrix">Matrix</th>
|
||||
{{ end }}
|
||||
{{ if .discordEnabled }}
|
||||
<th class="text-center-i grid gap-4 place-items-stretch accounts-header-discord">Discord</th>
|
||||
{{ end }}
|
||||
{{ if .referralsEnabled }}
|
||||
<th class="text-center-i grid gap-4 place-items-stretch accounts-header-referrals">{{ .strings.referrals }}</th>
|
||||
{{ end }}
|
||||
<th class="grid gap-4 place-items-stretch accounts-header-expiry">{{ .strings.expiry }}</th>
|
||||
<th class="grid gap-4 place-items-stretch accounts-header-last-active">{{ .strings.lastActiveTime }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
122
html/admin.html
122
html/admin.html
@@ -86,16 +86,7 @@
|
||||
<span class="heading"><span id="header-modify-user"></span> <span class="modal-close">×</span></span>
|
||||
<p class="content">{{ .strings.modifySettingsDescription }}</p>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-row gap-2">
|
||||
<label class="grow">
|
||||
<input type="radio" name="modify-user-source" class="unfocused" id="radio-use-profile" checked>
|
||||
<span class="button ~neutral @high supra full-width center">{{ .strings.profile }}</span>
|
||||
</label>
|
||||
<label class="grow">
|
||||
<input type="radio" name="modify-user-source" class="unfocused" id="radio-use-user">
|
||||
<span class="button ~neutral @low supra full-width center">{{ .strings.user }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="modify-user-profile-source"></div>
|
||||
<div class="select ~neutral @low">
|
||||
<select id="modify-user-profiles"></select>
|
||||
</div>
|
||||
@@ -134,16 +125,7 @@
|
||||
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 flex flex-col gap-4" id="form-enable-referrals-user" href="">
|
||||
<span class="heading"><span id="header-enable-referrals-user"></span> <span class="modal-close">×</span></span>
|
||||
<p class="content">{{ .strings.enableReferralsDescription }}</p>
|
||||
<div class="flex flex-row gap-2">
|
||||
<label class="grow">
|
||||
<input type="radio" name="enable-referrals-user-source" class="unfocused" id="radio-referrals-use-profile" checked>
|
||||
<span class="button ~neutral @high supra full-width center">{{ .strings.profile }}</span>
|
||||
</label>
|
||||
<label class="grow">
|
||||
<input type="radio" name="enable-referrals-user-source" class="unfocused" id="radio-referrals-use-invite">
|
||||
<span class="button ~neutral @low supra full-width center">{{ .strings.invite }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="enable-referrals-user-source"></div>
|
||||
<div class="select ~neutral @low">
|
||||
<select id="enable-referrals-user-profiles"></select>
|
||||
</div>
|
||||
@@ -247,10 +229,10 @@
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="expiry-use-previous">
|
||||
<span>{{ .strings.extendFromPreviousExpiry }}</span>
|
||||
<div class="tooltip left">
|
||||
<tool-tip class="left">
|
||||
<i class="icon ri-information-line align-middle"></i>
|
||||
<div class="content sm w-max">{{ .strings.extendFromPreviousExpiryDescription }}</div>
|
||||
</div>
|
||||
</tool-tip>
|
||||
</label>
|
||||
</div>
|
||||
<label class="switch">
|
||||
@@ -565,7 +547,7 @@
|
||||
</form>
|
||||
</div>
|
||||
<div id="notification-box"></div>
|
||||
<div class="page-container m-2 lg:my-20 lg:mx-64 flex flex-col gap-4 overflow-x-hidden">
|
||||
<div class="page-container m-2 lg:my-20 lg:mx-64 flex flex-col gap-4">
|
||||
<div class="top-2 inset-x-2 lg:absolute flex flex-row justify-between">
|
||||
<div class="flex flex-row gap-2">
|
||||
{{ template "lang-select.html" . }}
|
||||
@@ -597,16 +579,7 @@
|
||||
<span class="heading">{{ .strings.create }}</span>
|
||||
<div class="flex flex-col md:flex-row gap-3" id="create-inv">
|
||||
<div class="card ~neutral @low flex flex-col gap-2 flex-1">
|
||||
<div class="flex flex-row gap-2">
|
||||
<label class="w-1/2">
|
||||
<input type="radio" name="duration" class="unfocused" id="radio-inv-duration" checked>
|
||||
<span class="button ~neutral @high supra full-width center">{{ .strings.inviteDuration }}</span>
|
||||
</label>
|
||||
<label class="w-1/2">
|
||||
<input type="radio" name="duration" class="unfocused" id="radio-user-expiry">
|
||||
<span class="button ~neutral @low supra full-width center">{{ .strings.userExpiry }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="invites-duration-type-tabs"></div>
|
||||
<div id="inv-duration" class="flex flex-col gap-2">
|
||||
<div class="flex flex-row gap-2">
|
||||
<div class="grow flex flex-col gap-4">
|
||||
@@ -745,13 +718,13 @@
|
||||
<div class="flex flex-row align-middle w-full gap-2">
|
||||
<input type="search" class="field ~neutral @low input search" id="accounts-search" placeholder="{{ .strings.search }}">
|
||||
<span class="button ~neutral @low center inside-input rounded-s-none accounts-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
|
||||
<div class="tooltip left">
|
||||
<tool-tip class="below">
|
||||
<button class="button ~info @low center h-full accounts-search-server gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
|
||||
<i class="ri-search-line"></i>
|
||||
<span>{{ .strings.searchAll }}</span>
|
||||
</button>
|
||||
<span class="content sm">{{ .strings.searchAllRecords }}</span>
|
||||
</div>
|
||||
</tool-tip>
|
||||
<button class="button ~info @low" id="accounts-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -771,6 +744,7 @@
|
||||
</div>
|
||||
<div class="supra sm">{{ .strings.actions }}</div>
|
||||
<div class="flex flex-row flex-wrap gap-3">
|
||||
<button type="button" title="{{ .strings.back }}" class="button ~neutral @low inline-flex gap-1 unfocused" id="user-details-back" aria-label="{{ .strings.back }}"><i class="icon ri-arrow-left-fill"></i>{{ .strings.back }}</button>
|
||||
<button class="button ~neutral @low center accounts-load-all">{{ .strings.loadAll }}</button>
|
||||
<span class="button ~neutral @low center " id="accounts-add-user">{{ .quantityStrings.addUser.Singular }}</span>
|
||||
<div id="accounts-announce-dropdown" class="dropdown pb-0i " tabindex="0">
|
||||
@@ -806,54 +780,36 @@
|
||||
<span class="button ~info @low center unfocused " id="accounts-send-pwr">{{ .strings.sendPWR }}</span>
|
||||
<span class="button ~critical @low center " id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span>
|
||||
</div>
|
||||
<div class="card @low accounts-header overflow-x-scroll">
|
||||
<table class="table text-base leading-5">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" value="" id="accounts-select-all"></th>
|
||||
<th class="table-inline my-2 grid gap-4 place-items-stretch accounts-header-username">{{ .strings.username }}</th>
|
||||
{{ if .jellyfinLogin }}
|
||||
<th class="text-center-i grid gap-4 place-items-stretch accounts-header-access-jfa">{{ .strings.accessJFA }}</th>
|
||||
{{ end }}
|
||||
<th class="grid gap-4 place-items-stretch accounts-header-email">{{ .strings.emailAddress }}</th>
|
||||
{{ if .telegramEnabled }}
|
||||
<th class="text-center-i grid gap-4 place-items-stretch accounts-header-telegram">Telegram</th>
|
||||
{{ end }}
|
||||
{{ if .matrixEnabled }}
|
||||
<th class="text-center-i grid gap-4 place-items-stretch accounts-header-matrix">Matrix</th>
|
||||
{{ end }}
|
||||
{{ if .discordEnabled }}
|
||||
<th class="text-center-i grid gap-4 place-items-stretch accounts-header-discord">Discord</th>
|
||||
{{ end }}
|
||||
{{ if .referralsEnabled }}
|
||||
<th class="text-center-i grid gap-4 place-items-stretch accounts-header-referrals">{{ .strings.referrals }}</th>
|
||||
{{ end }}
|
||||
<th class="grid gap-4 place-items-stretch accounts-header-expiry">{{ .strings.expiry }}</th>
|
||||
<th class="grid gap-4 place-items-stretch accounts-header-last-active">{{ .strings.lastActiveTime }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="accounts-list"></tbody>
|
||||
</table>
|
||||
<div id="accounts-loader"></div>
|
||||
<div class="unfocused h-[100%] my-3" id="accounts-not-found">
|
||||
<div class="flex flex-col gap-2 h-[100%] justify-center items-center">
|
||||
<span class="text-2xl font-medium italic text-center">{{ .strings.noResultsFound }}</span>
|
||||
<span class="text-sm font-light italic unfocused text-center" id="accounts-no-local-results">{{ .strings.noResultsFoundLocally }}</span>
|
||||
<div class="flex flex-row">
|
||||
<button class="button ~neutral @low accounts-search-clear gap-1">
|
||||
<i class="ri-close-line"></i>
|
||||
<span>{{ .strings.clearSearch }}</span>
|
||||
</button>
|
||||
<div id="accounts-table-details-container">
|
||||
<div class="card @low accounts-details transition-opacity transition-display unfocused">
|
||||
<div id="accounts-loader"></div>
|
||||
</div>
|
||||
<div class="card @low accounts-header overflow-x-scroll transition-opacity transition-display" id="accounts-table">
|
||||
<table class="table text-base leading-5">
|
||||
{{ template "accounts-table-header.txt" . }}
|
||||
<tbody id="accounts-list"></tbody>
|
||||
</table>
|
||||
<div id="accounts-loader"></div>
|
||||
<div class="unfocused h-[100%] my-3" id="accounts-not-found">
|
||||
<div class="flex flex-col gap-2 h-[100%] justify-center items-center">
|
||||
<span class="text-2xl font-medium italic text-center">{{ .strings.noResultsFound }}</span>
|
||||
<span class="text-sm font-light italic unfocused text-center" id="accounts-no-local-results">{{ .strings.noResultsFoundLocally }}</span>
|
||||
<div class="flex flex-row">
|
||||
<button class="button ~neutral @low accounts-search-clear gap-1">
|
||||
<i class="ri-close-line"></i>
|
||||
<span>{{ .strings.clearSearch }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row gap-2 my-3 justify-center">
|
||||
<button class="button ~neutral @low" id="accounts-load-more">{{ .strings.loadMore }}</button>
|
||||
<button class="button ~neutral @low accounts-load-all">{{ .strings.loadAll }}</button>
|
||||
<button class="button ~info @low center accounts-search-server gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
|
||||
<i class="ri-search-line"></i>
|
||||
<span>{{ .strings.searchAllRecords }}</span>
|
||||
</button>
|
||||
<div class="flex flex-row gap-2 my-3 justify-center">
|
||||
<button class="button ~neutral @low" id="accounts-load-more">{{ .strings.loadMore }}</button>
|
||||
<button class="button ~neutral @low accounts-load-all">{{ .strings.loadAll }}</button>
|
||||
<button class="button ~info @low center accounts-search-server gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
|
||||
<i class="ri-search-line"></i>
|
||||
<span>{{ .strings.searchAllRecords }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -872,13 +828,13 @@
|
||||
<div class="flex flex-row align-middle w-full gap-2">
|
||||
<input type="search" class="field ~neutral @low input search" id="activity-search" placeholder="{{ .strings.search }}">
|
||||
<span class="button ~neutral @low center inside-input rounded-s-none activity-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
|
||||
<div class="tooltip left">
|
||||
<tool-tip class="below">
|
||||
<button class="button ~info @low center h-full activity-search-server gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
|
||||
<i class="ri-search-line"></i>
|
||||
<span>{{ .strings.searchAll }}</span>
|
||||
</button>
|
||||
<span class="content sm">{{ .strings.searchAllRecords }}</span>
|
||||
</div>
|
||||
</tool-tip>
|
||||
<button class="button ~info @low" id="activity-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<body class="max-w-full overflow-x-hidden section">
|
||||
<div id="modal-email" class="modal">
|
||||
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-2 modal-email-content">
|
||||
<span class="heading"></span>
|
||||
<label class="label flex flex-col gap-2">
|
||||
<span class="supra">{{ .strings.emailAddress }}</span>
|
||||
@@ -75,7 +75,7 @@
|
||||
<span class="button ~warning h-min" alt="{{ .strings.theme }}" id="button-theme"><i class="ri-sun-line"></i></span>
|
||||
<span class="button ~critical @low unfocused" id="logout-button">{{ .strings.logout }}</span>
|
||||
</div>
|
||||
<a class="button ~info unfocused h-min flex flex-row gap-2" href="/" id="admin-back-button"><i class="ri-arrow-left-fill"></i>{{ .strings.admin }}</a>
|
||||
<a class="button ~info unfocused h-min flex flex-row gap-1" href="/" id="admin-back-button"><i class="ri-arrow-left-fill"></i>{{ .strings.admin }}</a>
|
||||
</div>
|
||||
<div class="card @low dark:~d_neutral" id="card-user">
|
||||
<span class="heading flex flex-row gap-4"></span>
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
"invite": "Invite",
|
||||
"accounts": "Accounts",
|
||||
"activity": "Activity",
|
||||
"activityFromJF": "Activity (from Jellyfin)",
|
||||
"activityFromJfa": "Activity (from jfa-go)",
|
||||
"settings": "Settings",
|
||||
"inviteMonths": "Months",
|
||||
"inviteDays": "Days",
|
||||
@@ -63,6 +65,8 @@
|
||||
"disabled": "Disabled",
|
||||
"run": "Run",
|
||||
"sendPWR": "Send Password Reset",
|
||||
"noActivityFound": "No activity found",
|
||||
"noActivityFoundLocally": "If this account hasn't been used in a while, you might find older entries on Jellyfin directly.",
|
||||
"noResultsFound": "No Results Found",
|
||||
"noResultsFoundLocally": "Only loaded records were searched. You can load more, or perform the search over all records on the server.",
|
||||
"keepSearching": "Keep Searching",
|
||||
@@ -224,7 +228,13 @@
|
||||
"restartRequired": "Restart required",
|
||||
"required": "Required",
|
||||
"syntax": "Syntax",
|
||||
"syntaxDescription": "Variables denoted as {variable}. If statements can evaluate truthfulness (e.g. {ifTruth}) or make basic comparisons (e.g. {ifCompare})"
|
||||
"syntaxDescription": "Variables denoted as {variable}. If statements can evaluate truthfulness (e.g. {ifTruth}) or make basic comparisons (e.g. {ifCompare})",
|
||||
"info": "Info",
|
||||
"debug": "Debug",
|
||||
"warn": "Warn",
|
||||
"error": "Error",
|
||||
"fatal": "Fatal",
|
||||
"severity": "Severity"
|
||||
},
|
||||
"notifications": {
|
||||
"pathCopied": "Full path copied to clipboard.",
|
||||
|
||||
@@ -46,7 +46,12 @@
|
||||
"inviteRemainingUses": "Remaining uses",
|
||||
"internal": "Internal",
|
||||
"external": "External",
|
||||
"failed": "Failed"
|
||||
"failed": "Failed",
|
||||
"details": "Details",
|
||||
"type": "Type",
|
||||
"other": "Other",
|
||||
"back": "Back",
|
||||
"none": "None"
|
||||
},
|
||||
"notifications": {
|
||||
"errorLoginBlank": "The username and/or password were left blank.",
|
||||
|
||||
@@ -256,8 +256,11 @@ const (
|
||||
FailedGenerateDiscordInvite = "Failed to generate " + Discord + " invite: %v"
|
||||
|
||||
// email.go
|
||||
FailedInitSMTP = "Failed to initialize SMTP mailer: %v"
|
||||
SMTP = "SMTP"
|
||||
Mailgun = "Mailgun"
|
||||
FailedInitMailer = "Failed to initalize %s mailer: %v"
|
||||
FailedGeneratePWRLink = "Failed to generate PWR link: %v"
|
||||
InvalidFromAddress = "invalid from address: \"%s\""
|
||||
|
||||
// housekeeping-d.go
|
||||
hk = "Housekeeping: "
|
||||
|
||||
27
models.go
27
models.go
@@ -174,15 +174,31 @@ type respUser struct {
|
||||
ReferralsEnabled bool `json:"referrals_enabled"`
|
||||
}
|
||||
|
||||
// ServerSearchReqDTO is a usual SortablePaginatedReqDTO with added fields for searching and filtering.
|
||||
type ServerSearchReqDTO struct {
|
||||
SortablePaginatedReqDTO
|
||||
ServerFilterReqDTO
|
||||
}
|
||||
|
||||
// ServerFilterReqDTO provides search terms and queries to a search or count route.
|
||||
type ServerFilterReqDTO struct {
|
||||
SearchTerms []string `json:"searchTerms"`
|
||||
Queries []QueryDTO `json:"queries"`
|
||||
}
|
||||
|
||||
type PaginatedDTO struct {
|
||||
LastPage bool `json:"last_page"`
|
||||
}
|
||||
|
||||
type PaginatedReqDTO struct {
|
||||
Limit int `json:"limit"`
|
||||
Page int `json:"page"` // zero-indexed
|
||||
type SortablePaginatedReqDTO struct {
|
||||
SortByField string `json:"sortByField"`
|
||||
Ascending bool `json:"ascending"`
|
||||
PaginatedReqDTO
|
||||
}
|
||||
|
||||
type PaginatedReqDTO struct {
|
||||
Limit int `json:"limit"`
|
||||
Page int `json:"page"` // zero-indexed
|
||||
}
|
||||
|
||||
type getUsersDTO struct {
|
||||
@@ -520,6 +536,11 @@ type ActivityLogEntriesDTO struct {
|
||||
Entries []ActivityLogEntryDTO `json:"entries"`
|
||||
}
|
||||
|
||||
type PaginatedActivityLogEntriesDTO struct {
|
||||
ActivityLogEntriesDTO
|
||||
PaginatedDTO
|
||||
}
|
||||
|
||||
type ActivityLogEntryDTO struct {
|
||||
mediabrowser.ActivityLogEntry
|
||||
Date int64 `json:"Date"`
|
||||
|
||||
133
pwreset.go
133
pwreset.go
@@ -10,6 +10,12 @@ import (
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
lm "github.com/hrfee/jfa-go/logmessages"
|
||||
pollingWatcher "github.com/radovskyb/watcher"
|
||||
)
|
||||
|
||||
const (
|
||||
RetryCount = 2
|
||||
RetryInterval = time.Second
|
||||
)
|
||||
|
||||
// GenInternalReset generates a local password reset PIN, for use with the PWR option on the Admin page.
|
||||
@@ -48,17 +54,35 @@ func (app *appContext) StartPWR() {
|
||||
return
|
||||
}
|
||||
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedStartDaemon, "PWR", err)
|
||||
usePolling := app.config.Section("password_resets").Key("watch_polling").MustBool(false)
|
||||
|
||||
if !messagesEnabled {
|
||||
return
|
||||
}
|
||||
defer watcher.Close()
|
||||
if usePolling {
|
||||
watcher := pollingWatcher.New()
|
||||
watcher.FilterOps(pollingWatcher.Write)
|
||||
|
||||
go pwrMonitor(app, watcher)
|
||||
err = watcher.Add(path)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedStartDaemon, "PWR", err)
|
||||
go monitorPolling(app, watcher)
|
||||
if err := watcher.Add(path); err != nil {
|
||||
app.err.Printf(lm.FailedStartDaemon, "PWR (polling)", err)
|
||||
}
|
||||
if err := watcher.Start(time.Second * 5); err != nil {
|
||||
app.err.Printf(lm.FailedStartDaemon, "PWR (polling)", err)
|
||||
}
|
||||
} else {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedStartDaemon, "PWR", err)
|
||||
return
|
||||
}
|
||||
defer watcher.Close()
|
||||
|
||||
go monitorFS(app, watcher)
|
||||
err = watcher.Add(path)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedStartDaemon, "PWR", err)
|
||||
}
|
||||
}
|
||||
|
||||
waitForRestart()
|
||||
@@ -72,52 +96,55 @@ type PasswordReset struct {
|
||||
Internal bool `json:"Internal,omitempty"`
|
||||
}
|
||||
|
||||
func pwrMonitor(app *appContext, watcher *fsnotify.Watcher) {
|
||||
if !messagesEnabled {
|
||||
func validatePWR(app *appContext, fname string, attempt int) {
|
||||
currentTime := time.Now()
|
||||
if !strings.Contains(fname, "passwordreset") {
|
||||
return
|
||||
}
|
||||
var pwr PasswordReset
|
||||
data, err := os.ReadFile(fname)
|
||||
if err != nil {
|
||||
app.debug.Printf(lm.FailedReading, fname, err)
|
||||
return
|
||||
}
|
||||
err = json.Unmarshal(data, &pwr)
|
||||
if len(pwr.Pin) == 0 || err != nil {
|
||||
app.debug.Printf(lm.FailedReading, fname, err)
|
||||
return
|
||||
}
|
||||
app.info.Printf(lm.NewPWRForUser, pwr.Username)
|
||||
if pwr.Expiry.Before(currentTime) {
|
||||
app.err.Printf(lm.PWRExpired, pwr.Username, pwr.Expiry)
|
||||
return
|
||||
}
|
||||
user, err := app.jf.UserByName(pwr.Username, false)
|
||||
if err != nil || user.ID == "" {
|
||||
app.err.Printf(lm.FailedGetUser, pwr.Username, lm.Jellyfin, err)
|
||||
return
|
||||
}
|
||||
name := app.getAddressOrName(user.ID)
|
||||
if name != "" {
|
||||
msg, err := app.email.constructReset(pwr, false)
|
||||
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedConstructPWRMessage, pwr.Username, err)
|
||||
} else if err := app.sendByID(msg, user.ID); err != nil {
|
||||
app.err.Printf(lm.FailedSendPWRMessage, pwr.Username, name, err)
|
||||
} else {
|
||||
app.err.Printf(lm.SentPWRMessage, pwr.Username, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func monitorFS(app *appContext, watcher *fsnotify.Watcher) {
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if event.Op&fsnotify.Write == fsnotify.Write && strings.Contains(event.Name, "passwordreset") {
|
||||
var pwr PasswordReset
|
||||
data, err := os.ReadFile(event.Name)
|
||||
if err != nil {
|
||||
app.debug.Printf(lm.FailedReading, event.Name, err)
|
||||
return
|
||||
}
|
||||
err = json.Unmarshal(data, &pwr)
|
||||
if len(pwr.Pin) == 0 || err != nil {
|
||||
app.debug.Printf(lm.FailedReading, event.Name, err)
|
||||
continue
|
||||
}
|
||||
app.info.Printf(lm.NewPWRForUser, pwr.Username)
|
||||
if currentTime := time.Now(); pwr.Expiry.After(currentTime) {
|
||||
user, err := app.jf.UserByName(pwr.Username, false)
|
||||
if err != nil || user.ID == "" {
|
||||
app.err.Printf(lm.FailedGetUser, pwr.Username, lm.Jellyfin, err)
|
||||
return
|
||||
}
|
||||
uid := user.ID
|
||||
name := app.getAddressOrName(uid)
|
||||
if name != "" {
|
||||
msg, err := app.email.constructReset(pwr, false)
|
||||
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedConstructPWRMessage, pwr.Username, err)
|
||||
} else if err := app.sendByID(msg, uid); err != nil {
|
||||
app.err.Printf(lm.FailedSendPWRMessage, pwr.Username, name, err)
|
||||
} else {
|
||||
app.err.Printf(lm.SentPWRMessage, pwr.Username, name)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
app.err.Printf(lm.PWRExpired, pwr.Username, pwr.Expiry)
|
||||
}
|
||||
|
||||
if event.Has(fsnotify.Write) {
|
||||
validatePWR(app, event.Name, 0)
|
||||
}
|
||||
case err, ok := <-watcher.Errors:
|
||||
if !ok {
|
||||
@@ -127,3 +154,17 @@ func pwrMonitor(app *appContext, watcher *fsnotify.Watcher) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func monitorPolling(app *appContext, watcher *pollingWatcher.Watcher) {
|
||||
for {
|
||||
select {
|
||||
case event := <-watcher.Event:
|
||||
validatePWR(app, event.Path, 0)
|
||||
case err := <-watcher.Error:
|
||||
app.err.Printf(lm.FailedStartDaemon, "PWR (polling)", err)
|
||||
return
|
||||
case <-watcher.Closed:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,7 +206,10 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
|
||||
api.POST(p+"/user", app.NewUserFromAdmin)
|
||||
api.POST(p+"/users/extend", app.ExtendExpiry)
|
||||
api.DELETE(p+"/users/:id/expiry", app.RemoveExpiry)
|
||||
api.GET(p+"/users/:id", app.GetUser)
|
||||
api.GET(p+"/users/:id/activities/jellyfin", app.GetJFActivitesForUser)
|
||||
api.GET(p+"/users/:id/activities/jellyfin/count", app.CountJFActivitesForUser)
|
||||
api.POST(p+"/users/:id/activities/jellyfin", app.GetPaginatedJFActivitesForUser)
|
||||
api.POST(p+"/users/enable", app.EnableDisableUsers)
|
||||
api.POST(p+"/invites", app.GenerateInvite)
|
||||
api.GET(p+"/invites", app.GetInvites)
|
||||
|
||||
102
ts/admin.ts
102
ts/admin.ts
@@ -1,7 +1,7 @@
|
||||
import { ThemeManager } from "./modules/theme.js";
|
||||
import { lang, LangFile, loadLangSelector } from "./modules/lang.js";
|
||||
import { Modal } from "./modules/modal.js";
|
||||
import { Tabs, Tab } from "./modules/tabs.js";
|
||||
import { TabManager, isPageEventBindable, isNavigatable } from "./modules/tabs.js";
|
||||
import { DOMInviteList, createInvite } from "./modules/invites.js";
|
||||
import { accountsList } from "./modules/accounts.js";
|
||||
import { settingsList } from "./modules/settings.js";
|
||||
@@ -10,9 +10,12 @@ import { ProfileEditor, reloadProfileNames } from "./modules/profiles.js";
|
||||
import { _get, _post, notificationBox, whichAnimationEvent, bindManualDropdowns } from "./modules/common.js";
|
||||
import { Updater } from "./modules/update.js";
|
||||
import { Login } from "./modules/login.js";
|
||||
import { setupTooltips } from "./modules/ui.js";
|
||||
|
||||
declare var window: GlobalWindow;
|
||||
|
||||
setupTooltips();
|
||||
|
||||
const theme = new ThemeManager(document.getElementById("button-theme"));
|
||||
|
||||
window.lang = new lang(window.langFile as LangFile);
|
||||
@@ -129,6 +132,9 @@ window.availableProfiles = window.availableProfiles || [];
|
||||
});
|
||||
});*/
|
||||
|
||||
// tab content objects will register with this independently, so initialise now
|
||||
window.tabs = new TabManager();
|
||||
|
||||
var inviteCreator = new createInvite();
|
||||
|
||||
var accounts = new accountsList();
|
||||
@@ -143,80 +149,40 @@ var profiles = new ProfileEditor();
|
||||
|
||||
window.notifications = new notificationBox(document.getElementById("notification-box") as HTMLDivElement, 5);
|
||||
|
||||
/*const modifySettingsSource = function () {
|
||||
const profile = document.getElementById('radio-use-profile') as HTMLInputElement;
|
||||
const user = document.getElementById('radio-use-user') as HTMLInputElement;
|
||||
const profileSelect = document.getElementById('modify-user-profiles') as HTMLDivElement;
|
||||
const userSelect = document.getElementById('modify-user-users') as HTMLDivElement;
|
||||
(user.nextElementSibling as HTMLSpanElement).classList.toggle('@low');
|
||||
(user.nextElementSibling as HTMLSpanElement).classList.toggle('@high');
|
||||
(profile.nextElementSibling as HTMLSpanElement).classList.toggle('@low');
|
||||
(profile.nextElementSibling as HTMLSpanElement).classList.toggle('@high');
|
||||
profileSelect.classList.toggle('unfocused');
|
||||
userSelect.classList.toggle('unfocused');
|
||||
}*/
|
||||
|
||||
// Determine if url references an invite or account
|
||||
let isInviteURL = window.invites.isInviteURL();
|
||||
let isAccountURL = accounts.isAccountURL();
|
||||
// only use a navigatable URL once
|
||||
let navigated = false;
|
||||
|
||||
// load tabs
|
||||
const tabs: { id: string; url: string; reloader: () => void; unloader?: () => void }[] = [
|
||||
{
|
||||
id: "invites",
|
||||
url: "",
|
||||
reloader: () =>
|
||||
window.invites.reload(() => {
|
||||
if (isInviteURL) {
|
||||
window.invites.loadInviteURL();
|
||||
// Don't keep loading the same item on every tab refresh
|
||||
isInviteURL = false;
|
||||
const tabs: { id: string; url: string; reloader: () => void; unloader?: () => void }[] = [];
|
||||
[window.invites, accounts, activity, settings].forEach((p: AsTab) => {
|
||||
let t: { id: string; url: string; reloader: (previous?: AsTab) => void; unloader?: () => void } = {
|
||||
id: p.tabName,
|
||||
url: p.pagePath,
|
||||
reloader: (previous: AsTab) => {
|
||||
if (isPageEventBindable(p)) p.bindPageEvents();
|
||||
if (!navigated && isNavigatable(p) && p.isURL()) {
|
||||
navigated = true;
|
||||
p.navigate();
|
||||
} else {
|
||||
if (navigated && previous && isNavigatable(previous)) {
|
||||
// Clear the query param, as it was likely for a different page
|
||||
previous.clearURL();
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "accounts",
|
||||
url: "accounts",
|
||||
reloader: () =>
|
||||
accounts.reload(() => {
|
||||
if (isAccountURL) {
|
||||
accounts.loadAccountURL();
|
||||
// Don't keep loading the same item on every tab refresh
|
||||
isAccountURL = false;
|
||||
}
|
||||
accounts.bindPageEvents();
|
||||
}),
|
||||
unloader: accounts.unbindPageEvents,
|
||||
},
|
||||
{
|
||||
id: "activity",
|
||||
url: "activity",
|
||||
reloader: () => {
|
||||
activity.reload();
|
||||
activity.bindPageEvents();
|
||||
p.reload(() => {});
|
||||
}
|
||||
},
|
||||
unloader: activity.unbindPageEvents,
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
url: "settings",
|
||||
reloader: settings.reload,
|
||||
},
|
||||
];
|
||||
|
||||
const defaultTab = tabs[0];
|
||||
|
||||
window.tabs = new Tabs();
|
||||
|
||||
for (let tab of tabs) {
|
||||
};
|
||||
if (isPageEventBindable(p)) t.unloader = p.unbindPageEvents;
|
||||
tabs.push(t);
|
||||
window.tabs.addTab(
|
||||
tab.id,
|
||||
window.pages.Base + window.pages.Admin + "/" + tab.url,
|
||||
t.id,
|
||||
window.pages.Base + window.pages.Admin + "/" + t.url,
|
||||
p,
|
||||
null,
|
||||
tab.reloader,
|
||||
tab.unloader || null,
|
||||
t.reloader,
|
||||
t.unloader || null,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
let matchedTab = false;
|
||||
for (const tab of tabs) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { loadLangSelector } from "./modules/lang.js";
|
||||
import { Validator, ValidatorConf, ValidatorRespDTO } from "./modules/validator.js";
|
||||
import { Discord, Telegram, Matrix, ServiceConfiguration, MatrixConfiguration } from "./modules/account-linking.js";
|
||||
import { Captcha, GreCAPTCHA } from "./modules/captcha.js";
|
||||
import { setupTooltips } from "./modules/ui.js";
|
||||
|
||||
interface formWindow extends GlobalWindow {
|
||||
invalidPassword: string;
|
||||
@@ -42,6 +43,8 @@ interface formWindow extends GlobalWindow {
|
||||
collectEmail: boolean;
|
||||
}
|
||||
|
||||
setupTooltips();
|
||||
|
||||
loadLangSelector("form");
|
||||
|
||||
window.notifications = new notificationBox(document.getElementById("notification-box") as HTMLDivElement);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -554,18 +554,20 @@ interface ActivitiesReqDTO extends PaginatedReqDTO {
|
||||
type: string[];
|
||||
}
|
||||
|
||||
interface ActivitiesDTO extends paginatedDTO {
|
||||
interface ActivitiesDTO extends PaginatedDTO {
|
||||
activities: activity[];
|
||||
}
|
||||
|
||||
export class activityList extends PaginatedList {
|
||||
export class activityList extends PaginatedList implements Navigatable, AsTab {
|
||||
readonly tabName = "activity";
|
||||
readonly pagePath = "activity";
|
||||
protected _container: HTMLElement;
|
||||
protected _sortDirection = document.getElementById("activity-sort-direction") as HTMLButtonElement;
|
||||
|
||||
protected _ascending: boolean;
|
||||
|
||||
get activities(): { [id: string]: Activity } {
|
||||
return this._search.items as { [id: string]: Activity };
|
||||
get activities(): Map<string, Activity> {
|
||||
return this._search.items as Map<string, Activity>;
|
||||
}
|
||||
// set activities(v: { [id: string]: Activity }) { this._search.items = v as SearchableItems; }
|
||||
|
||||
@@ -587,21 +589,19 @@ export class activityList extends PaginatedList {
|
||||
getPageEndpoint: "/activity",
|
||||
itemsPerPage: 20,
|
||||
maxItemsLoadedForSearch: 200,
|
||||
appendNewItems: (resp: paginatedDTO) => {
|
||||
appendNewItems: (resp: PaginatedDTO) => {
|
||||
let ordering: string[] = this._search.ordering;
|
||||
for (let act of (resp as ActivitiesDTO).activities || []) {
|
||||
this.activities[act.id] = new Activity(act);
|
||||
this.activities.set(act.id, new Activity(act));
|
||||
ordering.push(act.id);
|
||||
}
|
||||
this._search.setOrdering(ordering, this._c.defaultSortField, this.ascending);
|
||||
},
|
||||
replaceWithNewItems: (resp: paginatedDTO) => {
|
||||
replaceWithNewItems: (resp: PaginatedDTO) => {
|
||||
// FIXME: Implement updates to existing elements, rather than just wiping each time.
|
||||
|
||||
// Remove existing items
|
||||
for (let id of Object.keys(this.activities)) {
|
||||
delete this.activities[id];
|
||||
}
|
||||
this.activities.clear();
|
||||
// And wipe their ordering
|
||||
this._search.setOrdering([], this._c.defaultSortField, this.ascending);
|
||||
this._c.appendNewItems(resp);
|
||||
@@ -645,7 +645,7 @@ export class activityList extends PaginatedList {
|
||||
this._sortDirection.addEventListener("click", () => (this.ascending = !this.ascending));
|
||||
}
|
||||
|
||||
reload = (callback?: (resp: paginatedDTO) => void) => {
|
||||
reload = (callback?: (resp: PaginatedDTO) => void) => {
|
||||
this._reload(callback);
|
||||
};
|
||||
|
||||
@@ -653,7 +653,7 @@ export class activityList extends PaginatedList {
|
||||
this._loadMore(loadAll, callback);
|
||||
};
|
||||
|
||||
loadAll = (callback?: (resp?: paginatedDTO) => void) => {
|
||||
loadAll = (callback?: (resp?: PaginatedDTO) => void) => {
|
||||
this._loadAll(callback);
|
||||
};
|
||||
|
||||
@@ -689,4 +689,27 @@ export class activityList extends PaginatedList {
|
||||
this._keepSearchingDescription.classList.add("unfocused");
|
||||
}
|
||||
};*/
|
||||
|
||||
isURL = (url?: string) => {
|
||||
const urlParams = new URLSearchParams(url || window.location.search);
|
||||
const username = urlParams.get("user");
|
||||
return Boolean(username) || this._search.isURL(url);
|
||||
};
|
||||
|
||||
navigate = (url?: string) => {
|
||||
const urlParams = new URLSearchParams(url || window.location.search);
|
||||
const username = urlParams.get("user");
|
||||
let search = urlParams.get("search") || "";
|
||||
if (username) {
|
||||
search = `user:"${username}" ` + search;
|
||||
urlParams.set("search", search);
|
||||
// Get rid of it, as it'll now be included in the "search" param anyway
|
||||
urlParams.delete("user");
|
||||
}
|
||||
this._search.navigate(urlParams.toString());
|
||||
};
|
||||
|
||||
clearURL() {
|
||||
this._search.clearURL();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from "../modules/common.js";
|
||||
import { DiscordSearch, DiscordUser, newDiscordSearch } from "../modules/discord.js";
|
||||
import { reloadProfileNames } from "../modules/profiles.js";
|
||||
import { HiddenInputField } from "./ui.js";
|
||||
import { HiddenInputField, RadioBasedTabSelector, Tooltip } from "./ui.js";
|
||||
|
||||
declare var window: GlobalWindow;
|
||||
|
||||
@@ -205,10 +205,9 @@ class DOMInvite implements Invite {
|
||||
}
|
||||
set send_to(address: string | null) {
|
||||
this._send_to = address;
|
||||
const container = this._infoArea.querySelector(".tooltip") as HTMLDivElement;
|
||||
const container = this._infoArea.querySelector("tool-tip") as Tooltip;
|
||||
const icon = container.querySelector("i");
|
||||
const chip = container.querySelector("span.inv-email-chip");
|
||||
const tooltip = container.querySelector("span.content") as HTMLSpanElement;
|
||||
if (!address) {
|
||||
icon.classList.remove("ri-mail-line");
|
||||
icon.classList.remove("ri-mail-close-line");
|
||||
@@ -232,7 +231,7 @@ class DOMInvite implements Invite {
|
||||
}
|
||||
}
|
||||
// innerHTML as the newer sent_to re-uses this with HTML.
|
||||
tooltip.innerHTML = address;
|
||||
container.content.innerHTML = address;
|
||||
}
|
||||
private _sendToDialog: SendToDialog;
|
||||
private _sent_to: SentToList;
|
||||
@@ -603,10 +602,10 @@ class DOMInvite implements Invite {
|
||||
this._header.appendChild(this._infoArea);
|
||||
this._infoArea.classList.add("inv-infoarea", "flex", "flex-row", "items-center", "gap-2");
|
||||
this._infoArea.innerHTML = `
|
||||
<div class="tooltip below darker" tabindex="0">
|
||||
<tool-tip class="below darker" tabindex="0">
|
||||
<span class="inv-email-chip h-full"><i></i></span>
|
||||
<span class="content sm p-1"></span>
|
||||
</div>
|
||||
</tool-tip>
|
||||
<span class="button ~critical @low inv-delete h-full">${window.lang.strings("delete")}</span>
|
||||
<label>
|
||||
<i class="icon px-2.5 py-2 ri-arrow-down-s-line text-xl not-rotated"></i>
|
||||
@@ -784,6 +783,8 @@ class DOMInvite implements Invite {
|
||||
}
|
||||
|
||||
export class DOMInviteList implements InviteList {
|
||||
readonly tabName = "invites";
|
||||
readonly pagePath = "";
|
||||
private _list: HTMLDivElement;
|
||||
private _empty: boolean;
|
||||
// since invite reload sends profiles, this event it broadcast so the createInvite object can load them.
|
||||
@@ -804,18 +805,26 @@ export class DOMInviteList implements InviteList {
|
||||
this.focusInvite(event.detail);
|
||||
});
|
||||
|
||||
isInviteURL = () => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
isURL = (url?: string) => {
|
||||
const urlParams = new URLSearchParams(url || window.location.search);
|
||||
const inviteCode = urlParams.get("invite");
|
||||
return Boolean(inviteCode);
|
||||
};
|
||||
|
||||
loadInviteURL = () => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
navigate = (url?: string) => {
|
||||
const urlParams = new URLSearchParams(url || window.location.search);
|
||||
const inviteCode = urlParams.get("invite");
|
||||
this.focusInvite(inviteCode, window.lang.notif("errorInviteNotFound"));
|
||||
};
|
||||
|
||||
clearURL() {
|
||||
const url = new URL(window.location.href);
|
||||
if (!url.searchParams.has("invite")) return;
|
||||
url.searchParams.delete("invite");
|
||||
console.log("pushing", url.toString());
|
||||
window.history.pushState(null, "", url.toString());
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this._list = document.getElementById("invites") as HTMLDivElement;
|
||||
this.empty = true;
|
||||
@@ -945,8 +954,8 @@ export class createInvite {
|
||||
private _userHours = document.getElementById("user-hours") as HTMLSelectElement;
|
||||
private _userMinutes = document.getElementById("user-minutes") as HTMLSelectElement;
|
||||
|
||||
private _invDurationButton = document.getElementById("radio-inv-duration") as HTMLInputElement;
|
||||
private _userExpiryButton = document.getElementById("radio-user-expiry") as HTMLInputElement;
|
||||
// private _invDurationButton = document.getElementById("radio-inv-duration") as HTMLInputElement;
|
||||
// private _userExpiryButton = document.getElementById("radio-user-expiry") as HTMLInputElement;
|
||||
private _invDuration = document.getElementById("inv-duration");
|
||||
private _userExpiry = document.getElementById("user-expiry");
|
||||
|
||||
@@ -1191,30 +1200,18 @@ export class createInvite {
|
||||
this.uses = 1;
|
||||
this.label = "";
|
||||
|
||||
const checkDuration = () => {
|
||||
const invSpan = this._invDurationButton.nextElementSibling as HTMLSpanElement;
|
||||
const userSpan = this._userExpiryButton.nextElementSibling as HTMLSpanElement;
|
||||
if (this._invDurationButton.checked) {
|
||||
this._invDuration.classList.remove("unfocused");
|
||||
this._userExpiry.classList.add("unfocused");
|
||||
invSpan.classList.add("@high");
|
||||
invSpan.classList.remove("@low");
|
||||
userSpan.classList.add("@low");
|
||||
userSpan.classList.remove("@high");
|
||||
} else if (this._userExpiryButton.checked) {
|
||||
this._userExpiry.classList.remove("unfocused");
|
||||
this._invDuration.classList.add("unfocused");
|
||||
invSpan.classList.add("@low");
|
||||
invSpan.classList.remove("@high");
|
||||
userSpan.classList.add("@high");
|
||||
userSpan.classList.remove("@low");
|
||||
}
|
||||
};
|
||||
|
||||
this._userExpiryButton.checked = false;
|
||||
this._invDurationButton.checked = true;
|
||||
this._userExpiryButton.onchange = checkDuration;
|
||||
this._invDurationButton.onchange = checkDuration;
|
||||
new RadioBasedTabSelector(
|
||||
document.getElementById("invites-duration-type-tabs"),
|
||||
"invites-duration-type",
|
||||
{
|
||||
name: window.lang.strings("inviteDuration"),
|
||||
content: this._invDuration,
|
||||
},
|
||||
{
|
||||
name: window.lang.strings("userExpiry"),
|
||||
content: this._userExpiry,
|
||||
},
|
||||
);
|
||||
|
||||
this._days.onchange = this._checkDurationValidity;
|
||||
this._months.onchange = this._checkDurationValidity;
|
||||
|
||||
@@ -18,18 +18,20 @@ export class RecordCounter {
|
||||
private _loaded: number;
|
||||
private _shown: number;
|
||||
private _selected: number;
|
||||
constructor(container: HTMLElement) {
|
||||
this._container = container;
|
||||
this._container.innerHTML = `
|
||||
constructor(container?: HTMLElement) {
|
||||
if (container) {
|
||||
this._container = container;
|
||||
this._container.innerHTML = `
|
||||
<span class="records-total"></span>
|
||||
<span class="records-loaded"></span>
|
||||
<span class="records-shown"></span>
|
||||
<span class="records-selected"></span>
|
||||
`;
|
||||
this._totalRecords = this._container.getElementsByClassName("records-total")[0] as HTMLElement;
|
||||
this._loadedRecords = this._container.getElementsByClassName("records-loaded")[0] as HTMLElement;
|
||||
this._shownRecords = this._container.getElementsByClassName("records-shown")[0] as HTMLElement;
|
||||
this._selectedRecords = this._container.getElementsByClassName("records-selected")[0] as HTMLElement;
|
||||
this._totalRecords = this._container.getElementsByClassName("records-total")[0] as HTMLElement;
|
||||
this._loadedRecords = this._container.getElementsByClassName("records-loaded")[0] as HTMLElement;
|
||||
this._shownRecords = this._container.getElementsByClassName("records-shown")[0] as HTMLElement;
|
||||
this._selectedRecords = this._container.getElementsByClassName("records-selected")[0] as HTMLElement;
|
||||
}
|
||||
this.total = 0;
|
||||
this.loaded = 0;
|
||||
this.shown = 0;
|
||||
@@ -55,7 +57,7 @@ export class RecordCounter {
|
||||
}
|
||||
set total(v: number) {
|
||||
this._total = v;
|
||||
this._totalRecords.textContent = window.lang.var("strings", "totalRecords", `${v}`);
|
||||
if (this._totalRecords) this._totalRecords.textContent = window.lang.var("strings", "totalRecords", `${v}`);
|
||||
}
|
||||
|
||||
get loaded(): number {
|
||||
@@ -63,7 +65,7 @@ export class RecordCounter {
|
||||
}
|
||||
set loaded(v: number) {
|
||||
this._loaded = v;
|
||||
this._loadedRecords.textContent = window.lang.var("strings", "loadedRecords", `${v}`);
|
||||
if (this._loadedRecords) this._loadedRecords.textContent = window.lang.var("strings", "loadedRecords", `${v}`);
|
||||
}
|
||||
|
||||
get shown(): number {
|
||||
@@ -71,7 +73,7 @@ export class RecordCounter {
|
||||
}
|
||||
set shown(v: number) {
|
||||
this._shown = v;
|
||||
this._shownRecords.textContent = window.lang.var("strings", "shownRecords", `${v}`);
|
||||
if (this._shownRecords) this._shownRecords.textContent = window.lang.var("strings", "shownRecords", `${v}`);
|
||||
}
|
||||
|
||||
get selected(): number {
|
||||
@@ -79,8 +81,10 @@ export class RecordCounter {
|
||||
}
|
||||
set selected(v: number) {
|
||||
this._selected = v;
|
||||
if (v == 0) this._selectedRecords.textContent = ``;
|
||||
else this._selectedRecords.textContent = window.lang.var("strings", "selectedRecords", `${v}`);
|
||||
if (this._selectedRecords) {
|
||||
if (v == 0) this._selectedRecords.textContent = ``;
|
||||
else this._selectedRecords.textContent = window.lang.var("strings", "selectedRecords", `${v}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,23 +92,25 @@ export interface PaginatedListConfig {
|
||||
loader: HTMLElement;
|
||||
loadMoreButtons: Array<HTMLButtonElement>;
|
||||
loadAllButtons: Array<HTMLButtonElement>;
|
||||
refreshButton: HTMLButtonElement;
|
||||
filterArea: HTMLElement;
|
||||
searchOptionsHeader: HTMLElement;
|
||||
searchBox: HTMLInputElement;
|
||||
recordCounter: HTMLElement;
|
||||
totalEndpoint: string;
|
||||
getPageEndpoint: string;
|
||||
refreshButton?: HTMLButtonElement;
|
||||
filterArea?: HTMLElement;
|
||||
searchOptionsHeader?: HTMLElement;
|
||||
searchBox?: HTMLInputElement;
|
||||
recordCounter?: HTMLElement;
|
||||
totalEndpoint: string | (() => string);
|
||||
getPageEndpoint: string | (() => string);
|
||||
itemsPerPage: number;
|
||||
maxItemsLoadedForSearch: number;
|
||||
appendNewItems: (resp: paginatedDTO) => void;
|
||||
replaceWithNewItems: (resp: paginatedDTO) => void;
|
||||
defaultSortField: string;
|
||||
defaultSortAscending: boolean;
|
||||
appendNewItems: (resp: PaginatedDTO) => void;
|
||||
replaceWithNewItems: (resp: PaginatedDTO) => void;
|
||||
defaultSortField?: string;
|
||||
defaultSortAscending?: boolean;
|
||||
pageLoadCallback?: (req: XMLHttpRequest) => void;
|
||||
disableSearch?: boolean;
|
||||
hideButtonsOnLastPage?: boolean;
|
||||
}
|
||||
|
||||
export abstract class PaginatedList {
|
||||
export abstract class PaginatedList implements PageEventBindable {
|
||||
protected _c: PaginatedListConfig;
|
||||
|
||||
// Container to append items to.
|
||||
@@ -143,11 +149,17 @@ export abstract class PaginatedList {
|
||||
this._c.loadAllButtons.forEach((v) => v.classList.add("unfocused"));
|
||||
this._c.loadMoreButtons.forEach((v) => {
|
||||
v.textContent = window.lang.strings("noMoreResults");
|
||||
if (this._c.hideButtonsOnLastPage) {
|
||||
v.classList.add("unfocused");
|
||||
}
|
||||
v.disabled = true;
|
||||
});
|
||||
} else {
|
||||
this._c.loadMoreButtons.forEach((v) => {
|
||||
v.textContent = window.lang.strings("loadMore");
|
||||
if (this._c.hideButtonsOnLastPage) {
|
||||
v.classList.remove("unfocused");
|
||||
}
|
||||
v.disabled = false;
|
||||
});
|
||||
this._c.loadAllButtons.forEach((v) => v.classList.remove("unfocused"));
|
||||
@@ -186,10 +198,11 @@ export abstract class PaginatedList {
|
||||
this.loadMore(() => removeLoader(this._keepSearchingButton, true));
|
||||
}; */
|
||||
// Since this.reload doesn't exist, we need an arrow function to wrap it.
|
||||
this._c.refreshButton.onclick = () => this.reload();
|
||||
if (this._c.refreshButton) this._c.refreshButton.onclick = () => this.reload();
|
||||
}
|
||||
|
||||
autoSetServerSearchButtonsDisabled = () => {
|
||||
if (!this._search) return;
|
||||
const serverSearchSortChanged =
|
||||
this._search.inServerSearch &&
|
||||
(this._searchParams.sortByField != this._search.sortField ||
|
||||
@@ -218,7 +231,7 @@ export abstract class PaginatedList {
|
||||
searchConfig.onSearchCallback = (
|
||||
newItems: boolean,
|
||||
loadAll: boolean,
|
||||
callback?: (resp: paginatedDTO) => void,
|
||||
callback?: (resp: PaginatedDTO) => void,
|
||||
) => {
|
||||
// if (this._search.inSearch && !this.lastPage) this._c.loadAllButton.classList.remove("unfocused");
|
||||
// else this._c.loadAllButton.classList.add("unfocused");
|
||||
@@ -245,12 +258,12 @@ export abstract class PaginatedList {
|
||||
if (previousCallback) previousCallback(newItems, loadAll);
|
||||
};
|
||||
const previousServerSearch = searchConfig.searchServer;
|
||||
searchConfig.searchServer = (params: PaginatedReqDTO, newSearch: boolean) => {
|
||||
searchConfig.searchServer = (params: PaginatedReqDTO, newSearch: boolean, then?: () => void) => {
|
||||
this._searchParams = params;
|
||||
if (newSearch) this.reload();
|
||||
else this.loadMore(false);
|
||||
if (newSearch) this.reload(then);
|
||||
else this.loadMore(false, then);
|
||||
|
||||
if (previousServerSearch) previousServerSearch(params, newSearch);
|
||||
if (previousServerSearch) previousServerSearch(params, newSearch, then);
|
||||
};
|
||||
searchConfig.clearServerSearch = () => {
|
||||
console.trace("Clearing server search");
|
||||
@@ -259,7 +272,7 @@ export abstract class PaginatedList {
|
||||
};
|
||||
searchConfig.setVisibility = this.setVisibility;
|
||||
this._search = new Search(searchConfig);
|
||||
this._search.generateFilterList();
|
||||
if (!this._c.disableSearch) this._search.generateFilterList();
|
||||
this.lastPage = false;
|
||||
};
|
||||
|
||||
@@ -270,7 +283,7 @@ export abstract class PaginatedList {
|
||||
// else this._visible = this._search.ordering.filter(v => !elements.includes(v));
|
||||
// const frag = document.createDocumentFragment()
|
||||
// for (let i = 0; i < this._visible.length; i++) {
|
||||
// frag.appendChild(this._search.items[this._visible[i]].asElement())
|
||||
// frag.appendChild(this._search.items.get(this._visible[i]).asElement())
|
||||
// }
|
||||
// this._container.replaceChildren(frag);
|
||||
// if (this._search.timeSearches) {
|
||||
@@ -295,7 +308,7 @@ export abstract class PaginatedList {
|
||||
|
||||
if (!appendedItems) {
|
||||
// Wipe old elements and render 1 new one, so we can take the element height.
|
||||
this._container.replaceChildren(this._search.items[this._visible[0]].asElement());
|
||||
this._container.replaceChildren(this._search.items.get(this._visible[0]).asElement());
|
||||
}
|
||||
|
||||
this._computeScrollInfo();
|
||||
@@ -317,7 +330,7 @@ export abstract class PaginatedList {
|
||||
}
|
||||
const frag = document.createDocumentFragment();
|
||||
for (let i = baseIndex; i < this._scroll.initialRenderCount; i++) {
|
||||
frag.appendChild(this._search.items[this._visible[i]].asElement());
|
||||
frag.appendChild(this._search.items.get(this._visible[i]).asElement());
|
||||
}
|
||||
this._scroll.rendered = Math.max(baseIndex, this._scroll.initialRenderCount);
|
||||
// appendChild over replaceChildren because there's already elements on the DOM
|
||||
@@ -335,7 +348,7 @@ export abstract class PaginatedList {
|
||||
|
||||
this._scroll.screenHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0);
|
||||
|
||||
this._scroll.rowHeight = this._search.items[this._visible[0]].asElement().offsetHeight;
|
||||
this._scroll.rowHeight = this._search.items.get(this._visible[0]).asElement().offsetHeight;
|
||||
};
|
||||
|
||||
// returns the item index to render up to for the given scroll position.
|
||||
@@ -346,15 +359,27 @@ export abstract class PaginatedList {
|
||||
return bottomIdx;
|
||||
};
|
||||
|
||||
private _loadLock: boolean = false;
|
||||
private _loadQueue: (() => void)[] = [];
|
||||
private _load = (
|
||||
itemLimit: number,
|
||||
page: number,
|
||||
appendFunc: (resp: paginatedDTO) => void, // Function to append/put items in storage.
|
||||
pre?: (resp: paginatedDTO) => void,
|
||||
post?: (resp: paginatedDTO) => void,
|
||||
appendFunc: (resp: PaginatedDTO) => void, // Function to append/put items in storage.
|
||||
pre?: (resp: PaginatedDTO) => void,
|
||||
post?: (resp: PaginatedDTO) => void,
|
||||
failCallback?: (req: XMLHttpRequest) => void,
|
||||
) => {
|
||||
if (this._loadLock) {
|
||||
console.debug("Queuing load, position:", this._loadQueue.length);
|
||||
const now = Date.now();
|
||||
this._loadQueue.push(() => {
|
||||
console.debug("Queued load running, appended at:", now);
|
||||
this._load(itemLimit, page, appendFunc, pre, post, failCallback);
|
||||
});
|
||||
return;
|
||||
}
|
||||
this._lastLoad = Date.now();
|
||||
this._loadLock = true;
|
||||
let params = this._search.inServerSearch ? this._searchParams : this.defaultParams();
|
||||
params.limit = itemLimit;
|
||||
params.page = page;
|
||||
@@ -364,7 +389,7 @@ export abstract class PaginatedList {
|
||||
}
|
||||
|
||||
_post(
|
||||
this._c.getPageEndpoint,
|
||||
typeof this._c.getPageEndpoint === "string" ? this._c.getPageEndpoint : this._c.getPageEndpoint(),
|
||||
params,
|
||||
(req: XMLHttpRequest) => {
|
||||
if (req.readyState != 4) return;
|
||||
@@ -375,7 +400,7 @@ export abstract class PaginatedList {
|
||||
}
|
||||
this._hasLoaded = true;
|
||||
|
||||
let resp = req.response as paginatedDTO;
|
||||
let resp = req.response as PaginatedDTO;
|
||||
if (pre) pre(resp);
|
||||
|
||||
this.lastPage = resp.last_page;
|
||||
@@ -384,20 +409,28 @@ export abstract class PaginatedList {
|
||||
|
||||
this._counter.loaded = this._search.ordering.length;
|
||||
|
||||
this._loadLock = false;
|
||||
|
||||
if (post) post(resp);
|
||||
|
||||
if (this._c.pageLoadCallback) this._c.pageLoadCallback(req);
|
||||
|
||||
const next = this._loadQueue.shift();
|
||||
if (next) next();
|
||||
},
|
||||
true,
|
||||
);
|
||||
};
|
||||
|
||||
// Removes all elements, and reloads the first page.
|
||||
public abstract reload: (callback?: (resp: paginatedDTO) => void) => void;
|
||||
protected _reload = (callback?: (resp: paginatedDTO) => void) => {
|
||||
public abstract reload: (callback?: (resp: PaginatedDTO) => void) => void;
|
||||
protected _reload = (callback?: (resp: PaginatedDTO) => void) => {
|
||||
console.trace("reloading");
|
||||
this.lastPage = false;
|
||||
this._counter.reset();
|
||||
this._counter.getTotal(this._c.totalEndpoint);
|
||||
this._counter.getTotal(
|
||||
typeof this._c.totalEndpoint === "string" ? this._c.totalEndpoint : this._c.totalEndpoint(),
|
||||
);
|
||||
// Reload all currently visible elements, i.e. Load a new page of size (limit*(page+1)).
|
||||
let limit = this._c.itemsPerPage;
|
||||
if (this._page != 0) {
|
||||
@@ -407,12 +440,14 @@ export abstract class PaginatedList {
|
||||
limit,
|
||||
0,
|
||||
this._c.replaceWithNewItems,
|
||||
(_0: paginatedDTO) => {
|
||||
(_0: PaginatedDTO) => {
|
||||
// Allow refreshes every 15s
|
||||
this._c.refreshButton.disabled = true;
|
||||
setTimeout(() => (this._c.refreshButton.disabled = false), 15000);
|
||||
if (this._c.refreshButton) {
|
||||
this._c.refreshButton.disabled = true;
|
||||
setTimeout(() => (this._c.refreshButton.disabled = false), 15000);
|
||||
}
|
||||
},
|
||||
(resp: paginatedDTO) => {
|
||||
(resp: PaginatedDTO) => {
|
||||
this._search.onSearchBoxChange(true, false, false);
|
||||
if (this._search.inSearch) {
|
||||
// this._c.loadAllButton.classList.remove("unfocused");
|
||||
@@ -427,8 +462,8 @@ export abstract class PaginatedList {
|
||||
};
|
||||
|
||||
// Loads the next page. If "loadAll", all pages will be loaded until the last is reached.
|
||||
public abstract loadMore: (loadAll?: boolean, callback?: (resp?: paginatedDTO) => void) => void;
|
||||
protected _loadMore = (loadAll: boolean = false, callback?: (resp: paginatedDTO) => void) => {
|
||||
public abstract loadMore: (loadAll?: boolean, callback?: (resp?: PaginatedDTO) => void) => void;
|
||||
protected _loadMore = (loadAll: boolean = false, callback?: (resp: PaginatedDTO) => void) => {
|
||||
this._c.loadMoreButtons.forEach((v) => (v.disabled = true));
|
||||
const timeout = setTimeout(() => {
|
||||
this._c.loadMoreButtons.forEach((v) => (v.disabled = false));
|
||||
@@ -439,14 +474,14 @@ export abstract class PaginatedList {
|
||||
this._c.itemsPerPage,
|
||||
this._page,
|
||||
this._c.appendNewItems,
|
||||
(resp: paginatedDTO) => {
|
||||
(resp: PaginatedDTO) => {
|
||||
// Check before setting this.lastPage so we have a chance to cancel the timeout.
|
||||
if (resp.last_page) {
|
||||
clearTimeout(timeout);
|
||||
this._c.loadAllButtons.forEach((v) => removeLoader(v));
|
||||
}
|
||||
},
|
||||
(resp: paginatedDTO) => {
|
||||
(resp: PaginatedDTO) => {
|
||||
if (this._search.inSearch || loadAll) {
|
||||
if (this.lastPage) {
|
||||
loadAll = false;
|
||||
@@ -464,8 +499,8 @@ export abstract class PaginatedList {
|
||||
);
|
||||
};
|
||||
|
||||
public abstract loadAll: (callback?: (resp?: paginatedDTO) => void) => void;
|
||||
protected _loadAll = (callback?: (resp?: paginatedDTO) => void) => {
|
||||
public abstract loadAll: (callback?: (resp?: PaginatedDTO) => void) => void;
|
||||
protected _loadAll = (callback?: (resp?: PaginatedDTO) => void) => {
|
||||
this._c.loadAllButtons.forEach((v) => {
|
||||
addLoader(v, true);
|
||||
});
|
||||
@@ -494,6 +529,7 @@ export abstract class PaginatedList {
|
||||
this._scroll.lastScrollY = scrollY;
|
||||
// If you've scrolled back up, do nothing
|
||||
if (scrollSpeed < 0) return;
|
||||
if (this._scroll.rowHeight == 0) this.computeScrollInfo();
|
||||
let endIdx = this.maximumItemsToRender(scrollY);
|
||||
|
||||
// Throttling this function means we might not catch up in time if the user scrolls fast,
|
||||
@@ -507,7 +543,7 @@ export abstract class PaginatedList {
|
||||
const realEndIdx = Math.min(endIdx, this._visible.length);
|
||||
const frag = document.createDocumentFragment();
|
||||
for (let i = this._scroll.rendered; i < realEndIdx; i++) {
|
||||
frag.appendChild(this._search.items[this._visible[i]].asElement());
|
||||
frag.appendChild(this._search.items.get(this._visible[i]).asElement());
|
||||
}
|
||||
this._scroll.rendered = realEndIdx;
|
||||
this._container.appendChild(frag);
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
export interface Page {
|
||||
name: string;
|
||||
title: string;
|
||||
url: string;
|
||||
show: () => boolean;
|
||||
hide: () => boolean;
|
||||
shouldSkip: () => boolean;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
export interface PageConfig {
|
||||
hideOthersOnPageShow: boolean;
|
||||
defaultName: string;
|
||||
defaultTitle: string;
|
||||
}
|
||||
|
||||
export class PageManager {
|
||||
export class PageManager implements Pages {
|
||||
pages: Map<string, Page>;
|
||||
pageList: string[];
|
||||
hideOthers: boolean;
|
||||
defaultName: string = "";
|
||||
defaultTitle: string = "";
|
||||
|
||||
private _listeners: Map<string, { params: string[]; func: (qp: URLSearchParams) => void }> = new Map();
|
||||
private _previousParams = new URLSearchParams();
|
||||
|
||||
private _overridePushState = () => {
|
||||
const pushState = window.history.pushState;
|
||||
window.history.pushState = function (data: any, __: string, _: string | URL) {
|
||||
@@ -32,6 +25,8 @@ export class PageManager {
|
||||
};
|
||||
|
||||
private _onpopstate = (event: PopStateEvent) => {
|
||||
const prevParams = this._previousParams;
|
||||
this._previousParams = new URLSearchParams(window.location.search);
|
||||
let name = event.state;
|
||||
if (name == null) {
|
||||
// Attempt to use hash from URL, if it isn't there, try the last part of the URL.
|
||||
@@ -48,6 +43,14 @@ export class PageManager {
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
if (this._listeners.has(name)) {
|
||||
for (let qp of this._listeners.get(name).params) {
|
||||
if (prevParams.get(qp) != this._previousParams.get(qp)) {
|
||||
this._listeners.get(name).func(this._previousParams);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!this.hideOthers) {
|
||||
return;
|
||||
}
|
||||
@@ -115,4 +118,17 @@ export class PageManager {
|
||||
}
|
||||
this.loadPage(p);
|
||||
}
|
||||
|
||||
// FIXME: Make PageManager global.
|
||||
|
||||
// registerParamListener allows registering a listener which will be called when one or many of the given query param names are changed. It will only be called once per navigation.
|
||||
registerParamListener(pageName: string, func: (qp: URLSearchParams) => void, ...qps: string[]) {
|
||||
const p: { params: string[]; func: (qp: URLSearchParams) => void } = this._listeners.get(pageName) || {
|
||||
params: [],
|
||||
func: null,
|
||||
};
|
||||
if (func) p.func = func;
|
||||
p.params.push(...qps);
|
||||
this._listeners.set(pageName, p);
|
||||
}
|
||||
}
|
||||
|
||||
15
ts/modules/row.ts
Normal file
15
ts/modules/row.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export abstract class TableRow {
|
||||
protected _row: HTMLTableRowElement;
|
||||
|
||||
remove() {
|
||||
this._row.remove();
|
||||
}
|
||||
asElement(): HTMLTableRowElement {
|
||||
return this._row;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this._row = document.createElement("tr");
|
||||
this._row.classList.add("border-b", "border-dashed", "dark:border-dotted", "dark:border-stone-700");
|
||||
}
|
||||
}
|
||||
@@ -33,20 +33,20 @@ export interface QueryType {
|
||||
}
|
||||
|
||||
export interface SearchConfiguration {
|
||||
filterArea: HTMLElement;
|
||||
filterArea?: HTMLElement;
|
||||
sortingByButton?: HTMLButtonElement;
|
||||
searchOptionsHeader: HTMLElement;
|
||||
notFoundPanel: HTMLElement;
|
||||
notFoundLocallyText: HTMLElement;
|
||||
searchOptionsHeader?: HTMLElement;
|
||||
notFoundPanel?: HTMLElement;
|
||||
notFoundLocallyText?: HTMLElement;
|
||||
notFoundCallback?: (notFound: boolean) => void;
|
||||
filterList: HTMLElement;
|
||||
clearSearchButtonSelector: string;
|
||||
serverSearchButtonSelector: string;
|
||||
search: HTMLInputElement;
|
||||
filterList?: HTMLElement;
|
||||
clearSearchButtonSelector?: string;
|
||||
serverSearchButtonSelector?: string;
|
||||
search?: HTMLInputElement;
|
||||
queries: { [field: string]: QueryType };
|
||||
setVisibility: (items: string[], visible: boolean, appendedItems: boolean) => void;
|
||||
onSearchCallback: (newItems: boolean, loadAll: boolean, callback?: (resp: paginatedDTO) => void) => void;
|
||||
searchServer: (params: PaginatedReqDTO, newSearch: boolean) => void;
|
||||
onSearchCallback: (newItems: boolean, loadAll: boolean, callback?: (resp: PaginatedDTO) => void) => void;
|
||||
searchServer: (params: PaginatedReqDTO, newSearch: boolean, then?: () => void) => void;
|
||||
clearServerSearch: () => void;
|
||||
loadMore?: () => void;
|
||||
}
|
||||
@@ -68,9 +68,11 @@ export abstract class Query {
|
||||
protected _subject: QueryType;
|
||||
protected _operator: QueryOperator;
|
||||
protected _card: HTMLElement;
|
||||
protected _id: string;
|
||||
public type: string;
|
||||
|
||||
constructor(subject: QueryType | null, operator: QueryOperator) {
|
||||
constructor(id: string, subject: QueryType | null, operator: QueryOperator) {
|
||||
this._id = id;
|
||||
this._subject = subject;
|
||||
this._operator = operator;
|
||||
if (subject != null) {
|
||||
@@ -116,8 +118,8 @@ export abstract class Query {
|
||||
|
||||
export class BoolQuery extends Query {
|
||||
protected _value: boolean;
|
||||
constructor(subject: QueryType, value: boolean) {
|
||||
super(subject, QueryOperator.Equal);
|
||||
constructor(id: string, subject: QueryType, value: boolean) {
|
||||
super(id, subject, QueryOperator.Equal);
|
||||
this.type = "bool";
|
||||
this._value = value;
|
||||
this._card.classList.add("button", "@high", "center", "flex", "flex-row", "gap-2");
|
||||
@@ -165,8 +167,8 @@ export class BoolQuery extends Query {
|
||||
|
||||
export class StringQuery extends Query {
|
||||
protected _value: string;
|
||||
constructor(subject: QueryType, value: string) {
|
||||
super(subject, QueryOperator.Equal);
|
||||
constructor(id: string, subject: QueryType, value: string) {
|
||||
super(id, subject, QueryOperator.Equal);
|
||||
this.type = "string";
|
||||
this._value = value.toLowerCase();
|
||||
this._card.classList.add("button", "~neutral", "@low", "center", "flex", "flex-row", "gap-2");
|
||||
@@ -214,8 +216,8 @@ const dateSetters: Map<string, (v: number) => void> = (() => {
|
||||
export class DateQuery extends Query {
|
||||
protected _value: ParsedDate;
|
||||
|
||||
constructor(subject: QueryType, operator: QueryOperator, value: ParsedDate) {
|
||||
super(subject, operator);
|
||||
constructor(id: string, subject: QueryType, operator: QueryOperator, value: ParsedDate) {
|
||||
super(id, subject, operator);
|
||||
this.type = "date";
|
||||
this._value = value;
|
||||
this._card.classList.add("button", "~neutral", "@low", "center", "flex", "flex-row", "gap-2");
|
||||
@@ -276,14 +278,14 @@ export interface SearchableItem extends ListItem {
|
||||
|
||||
export const SearchableItemDataAttribute = "data-search-item";
|
||||
|
||||
export type SearchableItems = { [id: string]: SearchableItem };
|
||||
export type SearchableItems = Map<string, SearchableItem>;
|
||||
|
||||
export class Search {
|
||||
export class Search implements Navigatable {
|
||||
private _c: SearchConfiguration;
|
||||
private _sortField: string = "";
|
||||
private _ascending: boolean = true;
|
||||
private _ordering: string[] = [];
|
||||
private _items: SearchableItems = {};
|
||||
private _items: SearchableItems = new Map<string, SearchableItem>();
|
||||
// Search queries (filters)
|
||||
private _queries: Query[] = [];
|
||||
// Plain-text search terms
|
||||
@@ -368,7 +370,7 @@ export class Search {
|
||||
if (queryFormat.bool) {
|
||||
let [boolState, isBool] = BoolQuery.paramsFromString(split[1]);
|
||||
if (isBool) {
|
||||
q = new BoolQuery(queryFormat, boolState);
|
||||
q = new BoolQuery(split[0], queryFormat, boolState);
|
||||
q.onclick = () => {
|
||||
for (let quote of [`"`, `'`, ``]) {
|
||||
this._c.search.value = this._c.search.value.replace(
|
||||
@@ -383,7 +385,7 @@ export class Search {
|
||||
}
|
||||
}
|
||||
if (queryFormat.string) {
|
||||
q = new StringQuery(queryFormat, split[1]);
|
||||
q = new StringQuery(split[0], queryFormat, split[1]);
|
||||
|
||||
q.onclick = () => {
|
||||
for (let quote of [`"`, `'`, ``]) {
|
||||
@@ -398,7 +400,7 @@ export class Search {
|
||||
if (queryFormat.date) {
|
||||
let [parsedDate, op, isDate] = DateQuery.paramsFromString(split[1]);
|
||||
if (!isDate) continue;
|
||||
q = new DateQuery(queryFormat, op, parsedDate);
|
||||
q = new DateQuery(split[0], queryFormat, op, parsedDate);
|
||||
|
||||
q.onclick = () => {
|
||||
for (let quote of [`"`, `'`, ``]) {
|
||||
@@ -435,7 +437,7 @@ export class Search {
|
||||
for (let term of searchTerms) {
|
||||
let cachedResult = [...result];
|
||||
for (let id of cachedResult) {
|
||||
const u = this.items[id];
|
||||
const u = this.items.get(id);
|
||||
if (!u.matchesSearch(term)) {
|
||||
result.splice(result.indexOf(id), 1);
|
||||
}
|
||||
@@ -444,14 +446,14 @@ export class Search {
|
||||
}
|
||||
|
||||
for (let q of queries) {
|
||||
this._c.filterArea.appendChild(q.asElement());
|
||||
this._c.filterArea?.appendChild(q.asElement());
|
||||
// Skip if this query has already been performed by the server.
|
||||
if (this.inServerSearch && !q.localOnly) continue;
|
||||
|
||||
let cachedResult = [...result];
|
||||
if (q.type == "bool") {
|
||||
for (let id of cachedResult) {
|
||||
const u = this.items[id];
|
||||
const u = this.items.get(id);
|
||||
// Remove from result if not matching query
|
||||
if (!q.compareItem(u)) {
|
||||
// console.log("not matching, result is", result);
|
||||
@@ -460,7 +462,7 @@ export class Search {
|
||||
}
|
||||
} else if (q.type == "string") {
|
||||
for (let id of cachedResult) {
|
||||
const u = this.items[id];
|
||||
const u = this.items.get(id);
|
||||
// We want to compare case-insensitively, so we get value, lower-case it then compare,
|
||||
// rather than doing both with compareItem.
|
||||
const value = q.getValueFromItem(u).toLowerCase();
|
||||
@@ -470,7 +472,7 @@ export class Search {
|
||||
}
|
||||
} else if (q.type == "date") {
|
||||
for (let id of cachedResult) {
|
||||
const u = this.items[id];
|
||||
const u = this.items.get(id);
|
||||
// Getter here returns a unix timestamp rather than a date, so we can't use compareItem.
|
||||
const unixValue = q.getValueFromItem(u);
|
||||
if (unixValue == 0) {
|
||||
@@ -491,7 +493,7 @@ export class Search {
|
||||
// Returns a list of identifiers (used as keys in items, values in ordering).
|
||||
search = (query: string): string[] => {
|
||||
let timer = this.timeSearches ? performance.now() : null;
|
||||
this._c.filterArea.textContent = "";
|
||||
if (this._c.filterArea) this._c.filterArea.textContent = "";
|
||||
|
||||
const [searchTerms, queries] = this.parseTokens(Search.tokenizeSearch(query));
|
||||
|
||||
@@ -515,16 +517,16 @@ export class Search {
|
||||
showHideSearchOptionsHeader = () => {
|
||||
let sortingBy = false;
|
||||
if (this._c.sortingByButton) sortingBy = !this._c.sortingByButton.classList.contains("hidden");
|
||||
const hasFilters = this._c.filterArea.textContent != "";
|
||||
const hasFilters = this._c.filterArea ? this._c.filterArea.textContent != "" : false;
|
||||
if (sortingBy || hasFilters) {
|
||||
this._c.searchOptionsHeader.classList.remove("hidden");
|
||||
this._c.searchOptionsHeader?.classList.remove("hidden");
|
||||
} else {
|
||||
this._c.searchOptionsHeader.classList.add("hidden");
|
||||
this._c.searchOptionsHeader?.classList.add("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
// -all- elements.
|
||||
get items(): { [id: string]: SearchableItem } {
|
||||
get items(): Map<string, SearchableItem> {
|
||||
return this._items;
|
||||
}
|
||||
// set items(v: { [id: string]: SearchableItem }) {
|
||||
@@ -550,11 +552,12 @@ export class Search {
|
||||
return this._ascending;
|
||||
}
|
||||
|
||||
// FIXME: This is being called by navigate, and triggering a "load more" when we haven't loaded at all, and loading without a searchc when we have one!
|
||||
onSearchBoxChange = (
|
||||
newItems: boolean = false,
|
||||
appendedItems: boolean = false,
|
||||
loadAll: boolean = false,
|
||||
callback?: (resp: paginatedDTO) => void,
|
||||
callback?: (resp: PaginatedDTO) => void,
|
||||
) => {
|
||||
const query = this._c.search.value;
|
||||
if (!query) {
|
||||
@@ -585,14 +588,14 @@ export class Search {
|
||||
|
||||
setNotFoundPanelVisibility = (visible: boolean) => {
|
||||
if (this._inServerSearch || !this.inSearch) {
|
||||
this._c.notFoundLocallyText.classList.add("unfocused");
|
||||
this._c.notFoundLocallyText?.classList.add("unfocused");
|
||||
} else if (this.inSearch) {
|
||||
this._c.notFoundLocallyText.classList.remove("unfocused");
|
||||
this._c.notFoundLocallyText?.classList.remove("unfocused");
|
||||
}
|
||||
if (visible) {
|
||||
this._c.notFoundPanel.classList.remove("unfocused");
|
||||
this._c.notFoundPanel?.classList.remove("unfocused");
|
||||
} else {
|
||||
this._c.notFoundPanel.classList.add("unfocused");
|
||||
this._c.notFoundPanel?.classList.add("unfocused");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -606,6 +609,7 @@ export class Search {
|
||||
};
|
||||
|
||||
generateFilterList = () => {
|
||||
if (!this._c.filterList) return;
|
||||
const filterListContainer = document.createElement("div");
|
||||
filterListContainer.classList.add("flex", "flex-row", "flex-wrap", "gap-2");
|
||||
// Generate filter buttons
|
||||
@@ -691,14 +695,15 @@ export class Search {
|
||||
this._c.filterList.appendChild(filterListContainer);
|
||||
};
|
||||
|
||||
onServerSearch = () => {
|
||||
onServerSearch = (then?: () => void) => {
|
||||
const newServerSearch = !this.inServerSearch;
|
||||
this.inServerSearch = true;
|
||||
this.searchServer(newServerSearch);
|
||||
// this.setQueryParam();
|
||||
this.searchServer(newServerSearch, then);
|
||||
};
|
||||
|
||||
searchServer = (newServerSearch: boolean) => {
|
||||
this._c.searchServer(this.serverSearchParams(this._searchTerms, this._queries), newServerSearch);
|
||||
searchServer = (newServerSearch: boolean, then?: () => void) => {
|
||||
this._c.searchServer(this.serverSearchParams(this._searchTerms, this._queries), newServerSearch, then);
|
||||
};
|
||||
|
||||
serverSearchParams = (searchTerms: string[], queries: Query[]): PaginatedReqDTO => {
|
||||
@@ -717,40 +722,100 @@ export class Search {
|
||||
return req;
|
||||
};
|
||||
|
||||
private _qps: URLSearchParams = new URLSearchParams();
|
||||
private _clearWithoutNavigate = false;
|
||||
// clearQueryParam removes the "search" query parameter --without-- triggering a navigate call.
|
||||
clearQueryParam = () => {
|
||||
if (!this._qps.has("search")) return;
|
||||
this._clearWithoutNavigate = true;
|
||||
this.setQueryParam("");
|
||||
};
|
||||
|
||||
clearURL() {
|
||||
this.clearQueryParam();
|
||||
}
|
||||
|
||||
// setQueryParam sets the ?search query param to the current searchbox content,
|
||||
// or value if given. If everything is set up correctly, this should trigger a search when it is
|
||||
// set to a new value.
|
||||
setQueryParam = (value?: string) => {
|
||||
let triggerManually = false;
|
||||
if (value === undefined || value == null) value = this._c.search.value;
|
||||
const url = new URL(window.location.href);
|
||||
// FIXME: do better and make someone else clear this
|
||||
if (value.trim()) {
|
||||
url.searchParams.delete("user");
|
||||
url.searchParams.set("search", value);
|
||||
} else {
|
||||
// If the query param is already blank, no change will mean no call to navigate()
|
||||
triggerManually = !url.searchParams.has("search");
|
||||
url.searchParams.delete("search");
|
||||
}
|
||||
// console.log("pushing", url.toString());
|
||||
window.history.pushState(null, "", url.toString());
|
||||
if (triggerManually) this.navigate();
|
||||
};
|
||||
|
||||
setServerSearchButtonsDisabled = (disabled: boolean) => {
|
||||
this._serverSearchButtons.forEach((v: HTMLButtonElement) => (v.disabled = disabled));
|
||||
};
|
||||
|
||||
isURL = (url?: string) => {
|
||||
const urlParams = new URLSearchParams(url || window.location.search);
|
||||
const searchContent = urlParams.get("search");
|
||||
return Boolean(searchContent);
|
||||
};
|
||||
|
||||
// navigate pulls the current "search" query param, puts it in the search box and searches it.
|
||||
navigate = (url?: string, then?: () => void) => {
|
||||
this._qps = new URLSearchParams(url || window.location.search);
|
||||
if (this._clearWithoutNavigate) {
|
||||
this._clearWithoutNavigate = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const searchContent = this._qps.get("search") || "";
|
||||
this._c.search.value = searchContent;
|
||||
this.onSearchBoxChange();
|
||||
this.onServerSearch(then);
|
||||
};
|
||||
|
||||
constructor(c: SearchConfiguration) {
|
||||
this._c = c;
|
||||
if (!this._c.search) {
|
||||
// Make a dummy one
|
||||
this._c.search = document.createElement("input") as HTMLInputElement;
|
||||
}
|
||||
|
||||
this._c.search.oninput = () => {
|
||||
this.inServerSearch = false;
|
||||
this.clearQueryParam();
|
||||
this.onSearchBoxChange();
|
||||
};
|
||||
this._c.search.addEventListener("keyup", (ev: KeyboardEvent) => {
|
||||
if (ev.key == "Enter") {
|
||||
this.onServerSearch();
|
||||
this.setQueryParam();
|
||||
}
|
||||
});
|
||||
|
||||
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.inServerSearch = false;
|
||||
this.onSearchBoxChange();
|
||||
});
|
||||
if (this._c.clearSearchButtonSelector) {
|
||||
const clearSearchButtons = Array.from(
|
||||
document.querySelectorAll(this._c.clearSearchButtonSelector),
|
||||
) as Array<HTMLSpanElement>;
|
||||
for (let b of clearSearchButtons) {
|
||||
b.addEventListener("click", () => {
|
||||
this.inServerSearch = false;
|
||||
this.setQueryParam("");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this._serverSearchButtons = Array.from(
|
||||
document.querySelectorAll(this._c.serverSearchButtonSelector),
|
||||
) as Array<HTMLSpanElement>;
|
||||
this._serverSearchButtons = this._c.serverSearchButtonSelector
|
||||
? (Array.from(document.querySelectorAll(this._c.serverSearchButtonSelector)) as Array<HTMLSpanElement>)
|
||||
: [];
|
||||
for (let b of this._serverSearchButtons) {
|
||||
b.addEventListener("click", () => {
|
||||
this.onServerSearch();
|
||||
this.setQueryParam();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "../modules/common.js";
|
||||
import { Marked } from "@ts-stack/markdown";
|
||||
import { stripMarkdown } from "../modules/stripmd.js";
|
||||
import { PDT } from "src/data/timezoneNames";
|
||||
import { Tooltip } from "./ui.js";
|
||||
|
||||
declare var window: GlobalWindow;
|
||||
|
||||
@@ -110,7 +110,7 @@ class DOMSetting {
|
||||
protected _hideEl: HTMLElement;
|
||||
protected _input: HTMLInputElement;
|
||||
protected _container: HTMLDivElement;
|
||||
protected _tooltip: HTMLDivElement;
|
||||
protected _tooltip: Tooltip;
|
||||
protected _required: HTMLSpanElement;
|
||||
protected _restart: HTMLSpanElement;
|
||||
protected _advanced: boolean;
|
||||
@@ -128,7 +128,6 @@ class DOMSetting {
|
||||
this._hideEl.classList.remove("unfocused");
|
||||
}
|
||||
document.dispatchEvent(changedEvent(this._section, this.setting, this.valueAsString(), v));
|
||||
console.log(`dispatched settings-${this._section}-${this.setting} = ${this.valueAsString()}/${v}`);
|
||||
}
|
||||
|
||||
private _advancedListener = (event: advancedEvent) => {
|
||||
@@ -155,11 +154,10 @@ class DOMSetting {
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return this._tooltip.querySelector("span.content").textContent;
|
||||
return this._tooltip.content.textContent;
|
||||
}
|
||||
set description(d: string) {
|
||||
const content = this._tooltip.querySelector("span.content") as HTMLSpanElement;
|
||||
content.textContent = d;
|
||||
this._tooltip.content.textContent = d;
|
||||
if (d == "") {
|
||||
this._tooltip.classList.add("unfocused");
|
||||
} else {
|
||||
@@ -249,17 +247,17 @@ class DOMSetting {
|
||||
${inputOnTop ? input : ""}
|
||||
<div class="flex flex-row gap-2 items-baseline">
|
||||
<span class="setting-label"></span>
|
||||
<div class="setting-tooltip tooltip right unfocused">
|
||||
<tool-tip class="setting-tooltip below-center sm:right unfocused">
|
||||
<i class="icon ri-information-line align-[-0.05rem]"></i>
|
||||
<span class="content sm"></span>
|
||||
</div>
|
||||
</tool-tip>
|
||||
<span class="setting-required unfocused"></span>
|
||||
<span class="setting-restart unfocused"></span>
|
||||
</div>
|
||||
${inputOnTop ? "" : input}
|
||||
</label>
|
||||
`;
|
||||
this._tooltip = this._container.querySelector("div.setting-tooltip") as HTMLDivElement;
|
||||
this._tooltip = this._container.querySelector("tool-tip.setting-tooltip") as Tooltip;
|
||||
this._required = this._container.querySelector("span.setting-required") as HTMLSpanElement;
|
||||
this._restart = this._container.querySelector("span.setting-restart") as HTMLSpanElement;
|
||||
// "input" variable should supply the HTML of an element with class "setting-input"
|
||||
@@ -1068,7 +1066,9 @@ interface Settings {
|
||||
order?: Member[];
|
||||
}
|
||||
|
||||
export class settingsList {
|
||||
export class settingsList implements AsTab {
|
||||
readonly tabName = "settings";
|
||||
readonly pagePath = "settings";
|
||||
private _saveButton = document.getElementById("settings-save") as HTMLSpanElement;
|
||||
private _saveNoRestart = document.getElementById("settings-apply-no-restart") as HTMLSpanElement;
|
||||
private _saveRestart = document.getElementById("settings-apply-restart") as HTMLSpanElement;
|
||||
@@ -1405,8 +1405,8 @@ export class settingsList {
|
||||
|
||||
// Create (restart)required badges (can't do on load as window.lang is unset)
|
||||
RestartRequiredBadge = (() => {
|
||||
const rr = document.createElement("span");
|
||||
rr.classList.add("tooltip", "below", "force-ltr");
|
||||
const rr = document.createElement("tool-tip") as Tooltip;
|
||||
rr.classList.add("below", "force-ltr");
|
||||
rr.innerHTML = `
|
||||
<span class="badge ~info dark:~d_warning align-[0.08rem]"><i class="icon ri-refresh-line h-full"></i></span>
|
||||
<span class="content sm">${window.lang.strings("restartRequired")}</span>
|
||||
@@ -1415,8 +1415,8 @@ export class settingsList {
|
||||
return rr;
|
||||
})();
|
||||
RequiredBadge = (() => {
|
||||
const r = document.createElement("span");
|
||||
r.classList.add("tooltip", "below", "force-ltr");
|
||||
const r = document.createElement("tool-tip");
|
||||
r.classList.add("below", "force-ltr");
|
||||
r.innerHTML = `
|
||||
<span class="badge ~critical align-[0.08rem]"><i class="icon ri-asterisk h-full"></i></span>
|
||||
<span class="content sm">${window.lang.strings("required")}</span>
|
||||
@@ -1489,8 +1489,8 @@ export class settingsList {
|
||||
this._sections[section.section].update(section);
|
||||
} else {
|
||||
if (section.section == "messages" || section.section == "user_page") {
|
||||
const editButton = document.createElement("div");
|
||||
editButton.classList.add("tooltip", "left", "h-full", "force-ltr");
|
||||
const editButton = document.createElement("tool-tip");
|
||||
editButton.classList.add("left", "h-full", "force-ltr");
|
||||
editButton.innerHTML = `
|
||||
<span class="button ~neutral @low h-full">
|
||||
<i class="icon ri-edit-line"></i>
|
||||
@@ -1527,8 +1527,8 @@ export class settingsList {
|
||||
}
|
||||
this.addSection(section.section, section, icon);
|
||||
} else if (section.section == "matrix" && !window.matrixEnabled) {
|
||||
const addButton = document.createElement("div");
|
||||
addButton.classList.add("tooltip", "left", "h-full", "force-ltr");
|
||||
const addButton = document.createElement("tool-tip");
|
||||
addButton.classList.add("left", "h-full", "force-ltr");
|
||||
addButton.innerHTML = `
|
||||
<span class="button ~neutral h-full"><i class="icon ri-links-line"></i></span>
|
||||
<span class="content sm">
|
||||
@@ -1911,10 +1911,10 @@ class MessageEditor {
|
||||
`;
|
||||
if (this._names[id].description != "")
|
||||
innerHTML += `
|
||||
<div class="tooltip right">
|
||||
<tool-tip class="right">
|
||||
<i class="icon ri-information-line"></i>
|
||||
<span class="content sm">${this._names[id].description}</span>
|
||||
</div>
|
||||
</tool-tip>
|
||||
`;
|
||||
innerHTML += `
|
||||
</td>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { PageManager, Page } from "../modules/pages.js";
|
||||
import { PageManager } from "./pages";
|
||||
|
||||
export interface Tab {
|
||||
page: Page;
|
||||
tabEl: HTMLDivElement;
|
||||
buttonEl: HTMLSpanElement;
|
||||
preFunc?: () => void;
|
||||
postFunc?: () => void;
|
||||
export function isPageEventBindable(object: any): object is PageEventBindable {
|
||||
return "bindPageEvents" in object;
|
||||
}
|
||||
|
||||
export class Tabs implements Tabs {
|
||||
export function isNavigatable(object: any): object is Navigatable {
|
||||
return "isURL" in object && "navigate" in object;
|
||||
}
|
||||
|
||||
export class TabManager implements TabManager {
|
||||
private _current: string = "";
|
||||
private _baseOffset = -1;
|
||||
tabs: Map<string, Tab>;
|
||||
pages: PageManager;
|
||||
pages: Pages;
|
||||
|
||||
constructor() {
|
||||
this.tabs = new Map<string, Tab>();
|
||||
@@ -26,14 +26,16 @@ export class Tabs implements Tabs {
|
||||
addTab = (
|
||||
tabID: string,
|
||||
url: string,
|
||||
preFunc = () => void {},
|
||||
postFunc = () => void {},
|
||||
contentObject: AsTab | null,
|
||||
preFunc: (previous?: AsTab) => void = (_?: AsTab) => void {},
|
||||
postFunc: (previous?: AsTab) => void = (_?: AsTab) => void {},
|
||||
unloadFunc = () => void {},
|
||||
) => {
|
||||
let tab: Tab = {
|
||||
page: null,
|
||||
tabEl: document.getElementById("tab-" + tabID) as HTMLDivElement,
|
||||
buttonEl: document.getElementById("button-tab-" + tabID) as HTMLButtonElement,
|
||||
contentObject: contentObject,
|
||||
preFunc: preFunc,
|
||||
postFunc: postFunc,
|
||||
};
|
||||
@@ -91,14 +93,16 @@ export class Tabs implements Tabs {
|
||||
[t] = this.tabs.values();
|
||||
}
|
||||
|
||||
const prev = this.tabs.get(this.current);
|
||||
|
||||
this._current = t.page.name;
|
||||
|
||||
if (t.preFunc && !noRun) {
|
||||
t.preFunc();
|
||||
t.preFunc(prev?.contentObject);
|
||||
}
|
||||
this.pages.load(tabID);
|
||||
if (t.postFunc && !noRun) {
|
||||
t.postFunc();
|
||||
t.postFunc(prev?.contentObject);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
227
ts/modules/ui.ts
227
ts/modules/ui.ts
@@ -1,3 +1,5 @@
|
||||
declare var window: GlobalWindow;
|
||||
|
||||
export interface HiddenInputConf {
|
||||
container: HTMLElement;
|
||||
onSet: () => void;
|
||||
@@ -109,3 +111,228 @@ export class HiddenInputField {
|
||||
this.setEditing(!this.editing, false, noSave);
|
||||
}
|
||||
}
|
||||
|
||||
export interface RadioBasedTab {
|
||||
name: string;
|
||||
id?: string;
|
||||
// If passed, will be put inside the button instead of the name.
|
||||
buttonHTML?: string;
|
||||
// You must at least pass a content element or an onShow function.
|
||||
content?: HTMLElement;
|
||||
onShow?: () => void;
|
||||
onHide?: () => void;
|
||||
}
|
||||
|
||||
interface RadioBasedTabItem {
|
||||
tab: RadioBasedTab;
|
||||
input: HTMLInputElement;
|
||||
button: HTMLElement;
|
||||
}
|
||||
|
||||
export class RadioBasedTabSelector {
|
||||
private _id: string;
|
||||
private _container: HTMLElement;
|
||||
private _tabs: RadioBasedTabItem[];
|
||||
private _selected: string;
|
||||
constructor(container: HTMLElement, id: string, ...tabs: RadioBasedTab[]) {
|
||||
this._container = container;
|
||||
this._container.classList.add("flex", "flex-row", "gap-2");
|
||||
this._tabs = [];
|
||||
this._id = id;
|
||||
let i = 0;
|
||||
const frag = document.createDocumentFragment();
|
||||
for (let tab of tabs) {
|
||||
if (!tab.id) tab.id = tab.name;
|
||||
const label = document.createElement("label");
|
||||
label.classList.add("grow");
|
||||
label.innerHTML = `
|
||||
<input type="radio" name="${this._id}" value="${tab.name}" class="unfocused" ${i == 0 ? "checked" : ""}>
|
||||
<span class="button ~neutral ${i == 0 ? "@high" : "@low"} radio-tab-button supra w-full text-center">${tab.buttonHTML || tab.name}</span>
|
||||
`;
|
||||
let ft: RadioBasedTabItem = {
|
||||
tab: tab,
|
||||
input: label.getElementsByTagName("input")[0] as HTMLInputElement,
|
||||
button: label.getElementsByClassName("radio-tab-button")[0] as HTMLElement,
|
||||
};
|
||||
ft.input.onclick = () => {
|
||||
ft.input.checked = true;
|
||||
this.checkSource();
|
||||
};
|
||||
frag.appendChild(label);
|
||||
this._tabs.push(ft);
|
||||
|
||||
i++;
|
||||
}
|
||||
this._container.replaceChildren(frag);
|
||||
this.selected = 0;
|
||||
}
|
||||
|
||||
checkSource = () => {
|
||||
for (let tab of this._tabs) {
|
||||
if (tab.input.checked) {
|
||||
this._selected = tab.tab.id;
|
||||
tab.tab.content?.classList.remove("unfocused");
|
||||
tab.button.classList.add("@high");
|
||||
tab.button.classList.remove("@low");
|
||||
if (tab.tab.onShow) tab.tab.onShow();
|
||||
} else {
|
||||
tab.tab.content?.classList.add("unfocused");
|
||||
tab.button.classList.add("@low");
|
||||
tab.button.classList.remove("@high");
|
||||
if (tab.tab.onHide) tab.tab.onHide();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
get selected(): string {
|
||||
return this._selected;
|
||||
}
|
||||
set selected(id: string | number) {
|
||||
if (typeof id !== "string") {
|
||||
id = this._tabs[id as number].tab.id;
|
||||
}
|
||||
for (let tab of this._tabs) {
|
||||
if (tab.tab.id == id) {
|
||||
this._selected = tab.tab.id;
|
||||
tab.input.checked = true;
|
||||
tab.tab.content?.classList.remove("unfocused");
|
||||
tab.button.classList.add("@high");
|
||||
tab.button.classList.remove("@low");
|
||||
if (tab.tab.onShow) tab.tab.onShow();
|
||||
} else {
|
||||
tab.input.checked = false;
|
||||
tab.tab.content?.classList.add("unfocused");
|
||||
tab.button.classList.add("@low");
|
||||
tab.button.classList.remove("@high");
|
||||
if (tab.tab.onHide) tab.tab.onHide();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type TooltipPosition = "above" | "below" | "below-center" | "left" | "right";
|
||||
|
||||
export class Tooltip extends HTMLElement {
|
||||
private _content: HTMLElement;
|
||||
get content(): HTMLElement {
|
||||
if (!this._content) return this.getElementsByClassName("content")[0] as HTMLElement;
|
||||
return this._content;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.setup();
|
||||
}
|
||||
|
||||
get visible(): boolean {
|
||||
return this.classList.contains("shown");
|
||||
}
|
||||
|
||||
get position(): TooltipPosition {
|
||||
return window.getComputedStyle(this).getPropertyValue("--tooltip-position").trim() as TooltipPosition;
|
||||
}
|
||||
|
||||
toggle() {
|
||||
console.log("toggle!");
|
||||
this.visible ? this.close() : this.open();
|
||||
}
|
||||
|
||||
clicked: boolean = false;
|
||||
|
||||
private _listener = (event: MouseEvent | TouchEvent) => {
|
||||
if (event.target !== this && !this.contains(event.target as HTMLElement)) {
|
||||
this.close();
|
||||
document.removeEventListener("mousedown", this._listener);
|
||||
// document.removeEventListener("touchstart", this._listener);
|
||||
}
|
||||
};
|
||||
|
||||
open() {
|
||||
this.fixWidth(() => {
|
||||
this.classList.add("shown");
|
||||
if (this.clicked) {
|
||||
document.addEventListener("mousedown", this._listener);
|
||||
// document.addEventListener("touchstart", this._listener);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.clicked = false;
|
||||
this.classList.remove("shown");
|
||||
}
|
||||
|
||||
setup() {
|
||||
this._content = this.getElementsByClassName("content")[0] as HTMLElement;
|
||||
const clickEvent = () => {
|
||||
if (this.clicked) {
|
||||
console.log("clicked again!");
|
||||
this.toggle();
|
||||
} else {
|
||||
console.log("clicked!");
|
||||
this.clicked = true;
|
||||
this.open();
|
||||
}
|
||||
};
|
||||
/// this.addEventListener("touchstart", clickEvent);
|
||||
this.addEventListener("click", clickEvent);
|
||||
this.addEventListener("mouseover", () => {
|
||||
this.open();
|
||||
});
|
||||
this.addEventListener("mouseleave", () => {
|
||||
if (this.clicked) return;
|
||||
console.log("mouseleave");
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
fixWidth(after?: () => void) {
|
||||
this._content.style.left = "";
|
||||
this._content.style.right = "";
|
||||
if (this.position == "below-center") {
|
||||
const offset = this.offsetLeft;
|
||||
const pw = (this.offsetParent as HTMLElement).offsetWidth;
|
||||
const cw = this._content.offsetWidth;
|
||||
const pos = -1 * offset + (pw - cw) / 2.0;
|
||||
this._content.style.left = pos + "px";
|
||||
}
|
||||
const [leftObscured, rightObscured] = wherePartiallyObscuredX(this._content);
|
||||
if (rightObscured) {
|
||||
const rect = this._content.getBoundingClientRect();
|
||||
this._content.style.left =
|
||||
"calc(-1rem + " + ((window.innerWidth || document.documentElement.clientHeight) - rect.right) + "px)";
|
||||
}
|
||||
if (leftObscured) {
|
||||
const rect = this._content.getBoundingClientRect();
|
||||
this._content.style.right = "calc(-1rem + " + rect.left + "px)";
|
||||
"calc(-1rem + " + ((window.innerWidth || document.documentElement.clientHeight) - rect.right) + "px)";
|
||||
}
|
||||
if (after) after();
|
||||
}
|
||||
}
|
||||
|
||||
export function setupTooltips() {
|
||||
customElements.define("tool-tip", Tooltip);
|
||||
}
|
||||
|
||||
export function isPartiallyObscuredX(el: HTMLElement): boolean {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.left < 0 || rect.right > (window.innerWidth || document.documentElement.clientWidth);
|
||||
}
|
||||
|
||||
export function wherePartiallyObscuredX(el: HTMLElement): [boolean, boolean] {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return [Boolean(rect.left < 0), Boolean(rect.right > (window.innerWidth || document.documentElement.clientWidth))];
|
||||
}
|
||||
|
||||
export function isPartiallyObscuredY(el: HTMLElement): boolean {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.top < 0 || rect.bottom > (window.innerHeight || document.documentElement.clientHeight);
|
||||
}
|
||||
|
||||
export function wherePartiallyObscuredY(el: HTMLElement): [boolean, boolean] {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return [
|
||||
Boolean(rect.top < 0),
|
||||
Boolean(rect.bottom > (window.innerHeight || document.documentElement.clientHeight)),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { toClipboard, notificationBox } from "./modules/common.js";
|
||||
import { setupTooltips } from "./modules/ui.js";
|
||||
|
||||
declare var window: GlobalWindow;
|
||||
|
||||
setupTooltips();
|
||||
|
||||
const pin = document.getElementById("pin") as HTMLSpanElement;
|
||||
|
||||
if (pin) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Validator, ValidatorConf } from "./modules/validator.js";
|
||||
import { _post, addLoader, removeLoader } from "./modules/common.js";
|
||||
import { loadLangSelector } from "./modules/lang.js";
|
||||
import { Captcha, GreCAPTCHA } from "./modules/captcha.js";
|
||||
import { setupTooltips } from "./modules/ui.js";
|
||||
|
||||
interface formWindow extends Window {
|
||||
invalidPassword: string;
|
||||
@@ -39,6 +40,8 @@ loadLangSelector("pwr");
|
||||
|
||||
declare var window: formWindow;
|
||||
|
||||
setupTooltips();
|
||||
|
||||
const form = document.getElementById("form-create") as HTMLFormElement;
|
||||
const submitInput = form.querySelector("input[type=submit]") as HTMLInputElement;
|
||||
const submitSpan = form.querySelector("span.submit") as HTMLSpanElement;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { _get, _post, toggleLoader, notificationBox } from "./modules/common.js"
|
||||
import { lang, LangFile, loadLangSelector } from "./modules/lang.js";
|
||||
import { ThemeManager } from "./modules/theme.js";
|
||||
import { PageManager } from "./modules/pages.js";
|
||||
import { setupTooltips } from "./modules/ui.js";
|
||||
|
||||
interface sWindow extends GlobalWindow {
|
||||
messages: {};
|
||||
@@ -9,6 +10,8 @@ interface sWindow extends GlobalWindow {
|
||||
|
||||
declare var window: sWindow;
|
||||
|
||||
setupTooltips();
|
||||
|
||||
const theme = new ThemeManager(document.getElementById("button-theme"));
|
||||
|
||||
window.notifications = new notificationBox(document.getElementById("notification-box") as HTMLDivElement, 5);
|
||||
|
||||
@@ -61,13 +61,30 @@ declare interface GlobalWindow extends Window {
|
||||
loginAppearance: string;
|
||||
}
|
||||
|
||||
declare interface InviteList {
|
||||
declare interface PageEventBindable {
|
||||
bindPageEvents(): void;
|
||||
unbindPageEvents(): void;
|
||||
}
|
||||
|
||||
declare interface AsTab {
|
||||
readonly tabName: string;
|
||||
readonly pagePath: string;
|
||||
reload(callback: () => void): void;
|
||||
}
|
||||
|
||||
declare interface Navigatable {
|
||||
// isURL will return whether the given url (or the current page url if not passed) is a valid link to some resource(s) in the class.
|
||||
isURL(url?: string): boolean;
|
||||
// clearURL will remove related query params from the current URL. It will likely be called when switching pages.
|
||||
clearURL(): void;
|
||||
// navigate will load and focus the resource(s) in the class referenced by the given url (or current page url if not passed).
|
||||
navigate(url?: string): void;
|
||||
}
|
||||
declare interface InviteList extends Navigatable, AsTab {
|
||||
empty: boolean;
|
||||
invites: { [code: string]: Invite };
|
||||
add: (invite: Invite) => void;
|
||||
reload: (callback?: () => void) => void;
|
||||
isInviteURL: () => boolean;
|
||||
loadInviteURL: () => void;
|
||||
}
|
||||
|
||||
declare interface Invite {
|
||||
@@ -135,12 +152,6 @@ declare interface NotificationBox {
|
||||
customSuccess: (type: string, message: string) => void;
|
||||
}
|
||||
|
||||
declare interface Tabs {
|
||||
current: string;
|
||||
addTab: (tabID: string, url: string, preFunc?: () => void, postFunc?: () => void, unloadFunc?: () => void) => void;
|
||||
switch: (tabID: string, noRun?: boolean, keepURL?: boolean) => void;
|
||||
}
|
||||
|
||||
declare interface Modals {
|
||||
about: Modal;
|
||||
login: Modal;
|
||||
@@ -173,18 +184,66 @@ declare interface Modals {
|
||||
backups?: Modal;
|
||||
}
|
||||
|
||||
interface paginatedDTO {
|
||||
declare interface Page {
|
||||
name: string;
|
||||
title: string;
|
||||
url: string;
|
||||
show: () => boolean;
|
||||
hide: () => boolean;
|
||||
shouldSkip: () => boolean;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
declare interface Tab {
|
||||
page: Page;
|
||||
tabEl: HTMLDivElement;
|
||||
buttonEl: HTMLSpanElement;
|
||||
contentObject?: AsTab;
|
||||
preFunc?: (previous?: AsTab) => void;
|
||||
postFunc?: (previous?: AsTab) => void;
|
||||
}
|
||||
|
||||
declare interface Tabs {
|
||||
tabs: Map<string, Tab>;
|
||||
pages: Pages;
|
||||
addTab(
|
||||
tabID: string,
|
||||
url: string,
|
||||
contentObject: AsTab | null,
|
||||
preFunc: () => void,
|
||||
postFunc: () => void,
|
||||
unloadFunc: () => void,
|
||||
): void;
|
||||
current: string;
|
||||
switch(tabID: string, noRun?: boolean): void;
|
||||
}
|
||||
|
||||
declare interface Pages {
|
||||
pages: Map<string, Page>;
|
||||
pageList: string[];
|
||||
hideOthers: boolean;
|
||||
defaultName: string;
|
||||
defaultTitle: string;
|
||||
setPage(p: Page): void;
|
||||
load(name?: string): void;
|
||||
loadPage(p: Page): void;
|
||||
prev(name?: string): void;
|
||||
next(name?: string): void;
|
||||
registerParamListener(pageName: string, func: (qp: URLSearchParams) => void, ...qps: string[]): void;
|
||||
}
|
||||
|
||||
declare interface PaginatedDTO {
|
||||
last_page: boolean;
|
||||
}
|
||||
|
||||
interface PaginatedReqDTO {
|
||||
declare interface PaginatedReqDTO {
|
||||
limit: number;
|
||||
page: number;
|
||||
sortByField: string;
|
||||
ascending: boolean;
|
||||
}
|
||||
|
||||
interface DateAttempt {
|
||||
declare interface DateAttempt {
|
||||
year?: number;
|
||||
month?: number;
|
||||
day?: number;
|
||||
@@ -193,7 +252,7 @@ interface DateAttempt {
|
||||
offsetMinutesFromUTC?: number;
|
||||
}
|
||||
|
||||
interface ParsedDate {
|
||||
declare interface ParsedDate {
|
||||
attempt: DateAttempt;
|
||||
date: Date;
|
||||
text: string;
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Discord, Telegram, Matrix, ServiceConfiguration, MatrixConfiguration }
|
||||
import { Validator, ValidatorConf, ValidatorRespDTO } from "./modules/validator.js";
|
||||
import { PageManager } from "./modules/pages.js";
|
||||
import { generateCodeLink } from "./modules/invites.js";
|
||||
import { setupTooltips } from "./modules/ui.js";
|
||||
|
||||
interface userWindow extends GlobalWindow {
|
||||
jellyfinID: string;
|
||||
@@ -34,6 +35,8 @@ interface userWindow extends GlobalWindow {
|
||||
|
||||
declare var window: userWindow;
|
||||
|
||||
setupTooltips();
|
||||
|
||||
// const basePath = window.location.pathname.replace("/password/reset", "");
|
||||
const basePath = window.pages.Base + window.pages.MyAccount;
|
||||
|
||||
@@ -531,7 +534,7 @@ const addEditEmail = (add: boolean): void => {
|
||||
const confirmationRequired = window.modals.email.modal.querySelector(".confirmation-required");
|
||||
confirmationRequired.classList.add("unfocused");
|
||||
|
||||
const content = window.modals.email.modal.querySelector(".content");
|
||||
const content = window.modals.email.modal.querySelector(".modal-email-content");
|
||||
content.classList.remove("unfocused");
|
||||
|
||||
const submit = window.modals.email.modal.querySelector(".card").children[0] as HTMLButtonElement;
|
||||
@@ -757,7 +760,7 @@ document.addEventListener("details-reload", () => {
|
||||
}
|
||||
if (!messageCard.textContent) {
|
||||
messageCard.innerHTML = `
|
||||
<span class="heading mb-2">${window.lang.strings("customMessagePlaceholderHeader")} ✏️ </span>
|
||||
<span class="heading mb-2">${window.lang.strings("customMessagePlaceholderHeader")} ✏ </span>
|
||||
<span class="block">${window.lang.strings("customMessagePlaceholderContent")}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
12
usercache.go
12
usercache.go
@@ -599,18 +599,6 @@ func (q *QueryDTO) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ServerSearchReqDTO is a usual PaginatedReqDTO with added fields for searching and filtering.
|
||||
type ServerSearchReqDTO struct {
|
||||
PaginatedReqDTO
|
||||
ServerFilterReqDTO
|
||||
}
|
||||
|
||||
// ServerFilterReqDTO provides search terms and queries to a search or count route.
|
||||
type ServerFilterReqDTO struct {
|
||||
SearchTerms []string `json:"searchTerms"`
|
||||
Queries []QueryDTO `json:"queries"`
|
||||
}
|
||||
|
||||
// Filter reduces the passed slice of *respUsers
|
||||
// by searching for each term of terms[] with respUser.MatchesSearch,
|
||||
// and by evaluating Queries with Query.AsFilter().
|
||||
|
||||
Reference in New Issue
Block a user