Compare commits
10
Commits
c10f1e3b36
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
211c3fa9cb | ||
|
|
de8e7c69be | ||
|
|
76878976ee | ||
|
|
5aa640d63d | ||
|
|
d7fdf29c7c | ||
|
|
60bf89bc02 | ||
|
|
637cca0625 | ||
|
|
455bde491f | ||
|
|
ee96bb9f1b | ||
|
|
721b209e1f |
+16
-8
@@ -189,6 +189,21 @@ func (app *appContext) NewUserFromInvite(gc *gin.Context) {
|
|||||||
respond(500, "errorUnknown", gc)
|
respond(500, "errorUnknown", gc)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
msg, err := app.email.constructConfirmation(req.Code, req.Username, key, false)
|
||||||
|
if err != nil {
|
||||||
|
app.err.Printf(lm.FailedConstructConfirmationEmail, req.Code, err)
|
||||||
|
respond(500, "errorUnknown", gc)
|
||||||
|
return
|
||||||
|
} else if err := app.email.send(msg, req.Email); err != nil {
|
||||||
|
app.err.Printf(lm.FailedSendConfirmationEmail, req.Code, req.Email, err)
|
||||||
|
if !app.config.Section("email_confirmation").Key("allow_account_creation_on_send_failure").MustBool(false) {
|
||||||
|
respond(500, "errorUnknown", gc)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.debug.Printf("Email confirmation send failed for \"%s\"; continuing account creation because email_confirmation.allow_account_creation_on_send_failure is enabled", req.Username)
|
||||||
|
} else {
|
||||||
|
app.debug.Printf(lm.SentConfirmationEmail, req.Code, req.Email)
|
||||||
if app.ConfirmationKeys == nil {
|
if app.ConfirmationKeys == nil {
|
||||||
app.ConfirmationKeys = map[string]map[string]ConfirmationKey{}
|
app.ConfirmationKeys = map[string]map[string]ConfirmationKey{}
|
||||||
}
|
}
|
||||||
@@ -206,17 +221,10 @@ func (app *appContext) NewUserFromInvite(gc *gin.Context) {
|
|||||||
|
|
||||||
app.debug.Printf(lm.EmailConfirmationRequired, req.Username)
|
app.debug.Printf(lm.EmailConfirmationRequired, req.Username)
|
||||||
respond(401, "confirmEmail", gc)
|
respond(401, "confirmEmail", gc)
|
||||||
msg, err := app.email.constructConfirmation(req.Code, req.Username, key, false)
|
|
||||||
if err != nil {
|
|
||||||
app.err.Printf(lm.FailedConstructConfirmationEmail, req.Code, err)
|
|
||||||
} else if err := app.email.send(msg, req.Email); err != nil {
|
|
||||||
app.err.Printf(lm.FailedSendConfirmationEmail, req.Code, req.Email, err)
|
|
||||||
} else {
|
|
||||||
app.debug.Printf(lm.SentConfirmationEmail, req.Code, req.Email)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
invite, _ := app.storage.GetInvitesKey(req.Code)
|
invite, _ := app.storage.GetInvitesKey(req.Code)
|
||||||
|
|
||||||
|
|||||||
@@ -1179,6 +1179,13 @@ sections:
|
|||||||
type: text
|
type: text
|
||||||
value: /path/to/jellyfin
|
value: /path/to/jellyfin
|
||||||
description: Path to the folder Jellyfin puts password-reset files.
|
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
|
- setting: link_reset
|
||||||
name: Use reset link instead of PIN (Required for Ombi)
|
name: Use reset link instead of PIN (Required for Ombi)
|
||||||
requires_restart: true
|
requires_restart: true
|
||||||
@@ -1429,6 +1436,15 @@ sections:
|
|||||||
description: Jellyseerr requires email addresses to be unique. If this is not
|
description: Jellyseerr requires email addresses to be unique. If this is not
|
||||||
the case, you may see errors in jfa-go's logs. You can require unique addresses
|
the case, you may see errors in jfa-go's logs. You can require unique addresses
|
||||||
in Settings > Email.
|
in Settings > Email.
|
||||||
|
- setting: ignore_csrf_email_update_errors
|
||||||
|
name: Ignore CSRF email update errors
|
||||||
|
advanced: true
|
||||||
|
type: bool
|
||||||
|
value: true
|
||||||
|
depends_true: enabled
|
||||||
|
description: Some Seerr builds allow API-key reads/imports but reject email or
|
||||||
|
notification writes with CSRF protection. If enabled, jfa-go logs those write
|
||||||
|
rejections at debug level while still importing/linking users.
|
||||||
- section: backups
|
- section: backups
|
||||||
meta:
|
meta:
|
||||||
name: Backups
|
name: Backups
|
||||||
@@ -1507,6 +1523,14 @@ sections:
|
|||||||
requires_restart: true
|
requires_restart: true
|
||||||
type: bool
|
type: bool
|
||||||
value: false
|
value: false
|
||||||
|
- setting: allow_account_creation_on_send_failure
|
||||||
|
name: Continue if confirmation email fails
|
||||||
|
advanced: true
|
||||||
|
type: bool
|
||||||
|
value: false
|
||||||
|
description: If enabled, account creation continues when the confirmation email
|
||||||
|
cannot be sent. Use this only when signups should keep working during temporary
|
||||||
|
mail relay failures.
|
||||||
- setting: subject
|
- setting: subject
|
||||||
name: Email subject
|
name: Email subject
|
||||||
type: text
|
type: text
|
||||||
|
|||||||
+53
-29
@@ -1,13 +1,15 @@
|
|||||||
.tooltip {
|
@layer components {
|
||||||
|
tool-tip {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip .content {
|
tool-tip .content {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
width: max-content;
|
||||||
max-width: 16rem;
|
max-width: 16rem;
|
||||||
min-width: 6rem;
|
/*min-width: 6rem;*/
|
||||||
background-color: rgba(0, 0, 0, 0.6);
|
background-color: rgba(0, 0, 0, 0.6);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
@@ -19,53 +21,75 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
top: -1rem;
|
top: -1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip.below .content {
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
tool-tip.below .content {
|
||||||
top: calc(100% + 0.125rem);
|
top: calc(100% + 0.125rem);
|
||||||
left: 50%;
|
left: 50%;
|
||||||
right: 0;
|
right: 0;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip.above .content {
|
tool-tip.below-center .content {
|
||||||
|
top: calc(100% + 0.125rem);
|
||||||
|
max-width: calc(100vw - 4rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
tool-tip.above .content {
|
||||||
top: unset;
|
top: unset;
|
||||||
bottom: calc(100% + 0.125rem);
|
bottom: calc(100% + 0.125rem);
|
||||||
left: 50%;
|
left: 50%;
|
||||||
right: 0;
|
right: 0;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip.darker .content {
|
tool-tip.darker .content {
|
||||||
background-color: rgba(0, 0, 0, 0.8);
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip.right .content {
|
tool-tip.right .content {
|
||||||
left: 120%;
|
left: 0%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip.right:dir(rtl):not(.force-ltr) .content {
|
tool-tip.right:dir(rtl):not(.force-ltr) .content {
|
||||||
right: 120%;
|
right: 0%;
|
||||||
left: unset;
|
left: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip.left .content {
|
tool-tip.left .content {
|
||||||
right: 120%;
|
right: 0%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip.left:dir(rtl):not(.force-ltr) .content {
|
tool-tip.left:dir(rtl):not(.force-ltr) .content {
|
||||||
left: 120%;
|
left: 0%;
|
||||||
right: unset;
|
right: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip .content.sm {
|
tool-tip .content.sm {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip:hover .content,
|
/*tool-tip:hover .content,
|
||||||
.tooltip:focus .content,
|
tool-tip:focus .content,
|
||||||
.tooltip:focus-within .content
|
tool-tip:focus-within .content*/
|
||||||
{
|
tool-tip.shown .content {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ func NewEmailer(config *Config, storage *Storage, logs LoggerSet) *Emailer {
|
|||||||
config: config,
|
config: config,
|
||||||
storage: storage,
|
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()
|
method := emailer.config.Section("email").Key("method").String()
|
||||||
if method == "smtp" {
|
if method == "smtp" {
|
||||||
enc := sMail.EncryptionSTARTTLS
|
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))
|
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)
|
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 {
|
if err != nil {
|
||||||
emailer.err.Printf(lm.FailedInitSMTP, err)
|
emailer.err.Printf(lm.FailedInitMailer, lm.SMTP, err)
|
||||||
}
|
}
|
||||||
} else if method == "mailgun" {
|
} 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)
|
emailer.NewMailgun(emailer.config.Section("mailgun").Key("api_url").String(), emailer.config.Section("mailgun").Key("api_key").String(), emailer.config.proxyTransport)
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ require (
|
|||||||
github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a // indirect
|
github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a // indirect
|
||||||
github.com/quic-go/qpack v0.6.0 // indirect
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
github.com/quic-go/quic-go v0.57.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/rs/zerolog v1.34.0 // indirect
|
||||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||||
github.com/swaggo/swag v1.16.6 // indirect
|
github.com/swaggo/swag v1.16.6 // indirect
|
||||||
|
|||||||
@@ -279,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/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 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3AdqE=
|
||||||
github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
|
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 h1:VTiyQ6/+AxSJoSSLsMecnkh8i0ZqOEdiRl/odOc64fc=
|
||||||
github.com/robert-nix/ansihtml v1.0.1/go.mod h1:CJwclxYaTPc2RfcxtanEACsYuTksh4yDXcNeHHKZINE=
|
github.com/robert-nix/ansihtml v1.0.1/go.mod h1:CJwclxYaTPc2RfcxtanEACsYuTksh4yDXcNeHHKZINE=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
|
|||||||
+7
-7
@@ -229,10 +229,10 @@
|
|||||||
<label class="switch">
|
<label class="switch">
|
||||||
<input type="checkbox" id="expiry-use-previous">
|
<input type="checkbox" id="expiry-use-previous">
|
||||||
<span>{{ .strings.extendFromPreviousExpiry }}</span>
|
<span>{{ .strings.extendFromPreviousExpiry }}</span>
|
||||||
<div class="tooltip left">
|
<tool-tip class="left">
|
||||||
<i class="icon ri-information-line align-middle"></i>
|
<i class="icon ri-information-line align-middle"></i>
|
||||||
<div class="content sm w-max">{{ .strings.extendFromPreviousExpiryDescription }}</div>
|
<div class="content sm w-max">{{ .strings.extendFromPreviousExpiryDescription }}</div>
|
||||||
</div>
|
</tool-tip>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label class="switch">
|
<label class="switch">
|
||||||
@@ -547,7 +547,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div id="notification-box"></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="top-2 inset-x-2 lg:absolute flex flex-row justify-between">
|
||||||
<div class="flex flex-row gap-2">
|
<div class="flex flex-row gap-2">
|
||||||
{{ template "lang-select.html" . }}
|
{{ template "lang-select.html" . }}
|
||||||
@@ -718,13 +718,13 @@
|
|||||||
<div class="flex flex-row align-middle w-full gap-2">
|
<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 }}">
|
<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>
|
<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 }}">
|
<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>
|
<i class="ri-search-line"></i>
|
||||||
<span>{{ .strings.searchAll }}</span>
|
<span>{{ .strings.searchAll }}</span>
|
||||||
</button>
|
</button>
|
||||||
<span class="content sm">{{ .strings.searchAllRecords }}</span>
|
<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>
|
<button class="button ~info @low" id="accounts-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -828,13 +828,13 @@
|
|||||||
<div class="flex flex-row align-middle w-full gap-2">
|
<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 }}">
|
<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>
|
<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 }}">
|
<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>
|
<i class="ri-search-line"></i>
|
||||||
<span>{{ .strings.searchAll }}</span>
|
<span>{{ .strings.searchAll }}</span>
|
||||||
</button>
|
</button>
|
||||||
<span class="content sm">{{ .strings.searchAllRecords }}</span>
|
<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>
|
<button class="button ~info @low" id="activity-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@
|
|||||||
<body class="max-w-full overflow-x-hidden section">
|
<body class="max-w-full overflow-x-hidden section">
|
||||||
<div id="modal-email" class="modal">
|
<div id="modal-email" class="modal">
|
||||||
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
|
<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>
|
<span class="heading"></span>
|
||||||
<label class="label flex flex-col gap-2">
|
<label class="label flex flex-col gap-2">
|
||||||
<span class="supra">{{ .strings.emailAddress }}</span>
|
<span class="supra">{{ .strings.emailAddress }}</span>
|
||||||
|
|||||||
+13
-2
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -13,6 +14,10 @@ type JellyseerrInitialSyncStatus struct {
|
|||||||
Done bool
|
Done bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isJellyseerrCSRFFailure(err error) bool {
|
||||||
|
return err != nil && strings.Contains(strings.ToLower(err.Error()), "invalid csrf token")
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure the Jellyseerr cache is up to date before calling.
|
// Ensure the Jellyseerr cache is up to date before calling.
|
||||||
func (app *appContext) SynchronizeJellyseerrUser(jfID string) {
|
func (app *appContext) SynchronizeJellyseerrUser(jfID string) {
|
||||||
user, imported, err := app.js.GetOrImportUser(jfID, true)
|
user, imported, err := app.js.GetOrImportUser(jfID, true)
|
||||||
@@ -34,7 +39,9 @@ func (app *appContext) SynchronizeJellyseerrUser(jfID string) {
|
|||||||
if ok && email.Addr != "" && user.Email != email.Addr {
|
if ok && email.Addr != "" && user.Email != email.Addr {
|
||||||
err = app.js.ModifyMainUserSettings(jfID, jellyseerr.MainUserSettings{Email: email.Addr})
|
err = app.js.ModifyMainUserSettings(jfID, jellyseerr.MainUserSettings{Email: email.Addr})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "INVALID_EMAIL") {
|
if isJellyseerrCSRFFailure(err) && app.config.Section("jellyseerr").Key("ignore_csrf_email_update_errors").MustBool(true) {
|
||||||
|
app.debug.Printf("Skipping Jellyseerr email update for user \"%s\": Jellyseerr rejected the API-key write with CSRF protection", jfID)
|
||||||
|
} else if strings.Contains(err.Error(), "INVALID_EMAIL") {
|
||||||
app.err.Printf(lm.FailedSetEmailAddress, lm.Jellyseerr, jfID, err.Error()+"\""+email.Addr+"\"")
|
app.err.Printf(lm.FailedSetEmailAddress, lm.Jellyseerr, jfID, err.Error()+"\""+email.Addr+"\"")
|
||||||
} else {
|
} else {
|
||||||
app.err.Printf(lm.FailedSetEmailAddress, lm.Jellyseerr, jfID, err)
|
app.err.Printf(lm.FailedSetEmailAddress, lm.Jellyseerr, jfID, err)
|
||||||
@@ -45,7 +52,7 @@ func (app *appContext) SynchronizeJellyseerrUser(jfID string) {
|
|||||||
}
|
}
|
||||||
if discordEnabled {
|
if discordEnabled {
|
||||||
dcUser, ok := app.storage.GetDiscordKey(jfID)
|
dcUser, ok := app.storage.GetDiscordKey(jfID)
|
||||||
if ok && dcUser.ID != "" && notif.DiscordID != dcUser.ID {
|
if ok && dcUser.ID != "" && !slices.Contains(notif.DiscordIDs, dcUser.ID) {
|
||||||
contactMethods[jellyseerr.FieldDiscord] = dcUser.ID
|
contactMethods[jellyseerr.FieldDiscord] = dcUser.ID
|
||||||
contactMethods[jellyseerr.FieldDiscordEnabled] = dcUser.Contact
|
contactMethods[jellyseerr.FieldDiscordEnabled] = dcUser.Contact
|
||||||
}
|
}
|
||||||
@@ -62,9 +69,13 @@ func (app *appContext) SynchronizeJellyseerrUser(jfID string) {
|
|||||||
if len(contactMethods) != 0 {
|
if len(contactMethods) != 0 {
|
||||||
err := app.js.ModifyNotifications(jfID, contactMethods)
|
err := app.js.ModifyNotifications(jfID, contactMethods)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if isJellyseerrCSRFFailure(err) && app.config.Section("jellyseerr").Key("ignore_csrf_email_update_errors").MustBool(true) {
|
||||||
|
app.debug.Printf("Skipping Jellyseerr notification update for user \"%s\": Jellyseerr rejected the API-key write with CSRF protection", jfID)
|
||||||
|
} else {
|
||||||
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
|
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *appContext) SynchronizeJellyseerrUsers() {
|
func (app *appContext) SynchronizeJellyseerrUsers() {
|
||||||
|
|||||||
@@ -425,6 +425,13 @@ func (js *Jellyseerr) ApplyNotificationsTemplateToUser(jfID string, tmpl Notific
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (js *Jellyseerr) ModifyNotifications(jfID string, conf map[NotificationsField]any) error {
|
func (js *Jellyseerr) ModifyNotifications(jfID string, conf map[NotificationsField]any) error {
|
||||||
|
// Fix for API change: discord IDs are now stored as an array rather than as a single ID string
|
||||||
|
if v, ok := conf[FieldDiscord]; ok {
|
||||||
|
switch v.(type) {
|
||||||
|
case string:
|
||||||
|
conf[FieldDiscord] = []string{v.(string)}
|
||||||
|
}
|
||||||
|
}
|
||||||
u, err := js.getUser(jfID)
|
u, err := js.getUser(jfID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
+14
-2
@@ -29,6 +29,7 @@ type User struct {
|
|||||||
UpdatedAt time.Time `json:"updatedAt,omitempty"`
|
UpdatedAt time.Time `json:"updatedAt,omitempty"`
|
||||||
RequestCount int64 `json:"requestCount,omitempty"`
|
RequestCount int64 `json:"requestCount,omitempty"`
|
||||||
DisplayName string `json:"displayName,omitempty"`
|
DisplayName string `json:"displayName,omitempty"`
|
||||||
|
Settings *UserSettings `json:"settings,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u User) Name() string {
|
func (u User) Name() string {
|
||||||
@@ -44,6 +45,13 @@ func (u User) Name() string {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fields have been omitted (those I know not the type of)
|
||||||
|
type UserSettings struct {
|
||||||
|
ID int64 `json:"id,omitempty"`
|
||||||
|
Locale string `json:"locale,omitempty"`
|
||||||
|
NotificationsSettings
|
||||||
|
}
|
||||||
|
|
||||||
type UserTemplate struct {
|
type UserTemplate struct {
|
||||||
Permissions Permissions `json:"permissions,omitempty"`
|
Permissions Permissions `json:"permissions,omitempty"`
|
||||||
MovieQuotaLimit any `json:"movieQuotaLimit,omitempty"`
|
MovieQuotaLimit any `json:"movieQuotaLimit,omitempty"`
|
||||||
@@ -88,7 +96,7 @@ type NotificationTypes struct {
|
|||||||
type NotificationsField string
|
type NotificationsField string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
FieldDiscord NotificationsField = "discordId"
|
FieldDiscord NotificationsField = "discordIds"
|
||||||
FieldTelegram NotificationsField = "telegramChatId"
|
FieldTelegram NotificationsField = "telegramChatId"
|
||||||
FieldEmailEnabled NotificationsField = "emailEnabled"
|
FieldEmailEnabled NotificationsField = "emailEnabled"
|
||||||
FieldDiscordEnabled NotificationsField = "discordEnabled"
|
FieldDiscordEnabled NotificationsField = "discordEnabled"
|
||||||
@@ -97,8 +105,12 @@ const (
|
|||||||
|
|
||||||
type Notifications struct {
|
type Notifications struct {
|
||||||
NotificationsTemplate
|
NotificationsTemplate
|
||||||
|
NotificationsSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotificationsSettings struct {
|
||||||
PgpKey any `json:"pgpKey,omitempty"`
|
PgpKey any `json:"pgpKey,omitempty"`
|
||||||
DiscordID string `json:"discordId,omitempty"`
|
DiscordIDs []string `json:"discordIds,omitempty"`
|
||||||
PushbulletAccessToken any `json:"pushbulletAccessToken,omitempty"`
|
PushbulletAccessToken any `json:"pushbulletAccessToken,omitempty"`
|
||||||
PushoverApplicationToken any `json:"pushoverApplicationToken,omitempty"`
|
PushoverApplicationToken any `json:"pushoverApplicationToken,omitempty"`
|
||||||
PushoverUserKey any `json:"pushoverUserKey,omitempty"`
|
PushoverUserKey any `json:"pushoverUserKey,omitempty"`
|
||||||
|
|||||||
@@ -256,8 +256,11 @@ const (
|
|||||||
FailedGenerateDiscordInvite = "Failed to generate " + Discord + " invite: %v"
|
FailedGenerateDiscordInvite = "Failed to generate " + Discord + " invite: %v"
|
||||||
|
|
||||||
// email.go
|
// 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"
|
FailedGeneratePWRLink = "Failed to generate PWR link: %v"
|
||||||
|
InvalidFromAddress = "invalid from address: \"%s\""
|
||||||
|
|
||||||
// housekeeping-d.go
|
// housekeeping-d.go
|
||||||
hk = "Housekeeping: "
|
hk = "Housekeeping: "
|
||||||
|
|||||||
+80
-39
@@ -10,6 +10,12 @@ import (
|
|||||||
|
|
||||||
"github.com/fsnotify/fsnotify"
|
"github.com/fsnotify/fsnotify"
|
||||||
lm "github.com/hrfee/jfa-go/logmessages"
|
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.
|
// GenInternalReset generates a local password reset PIN, for use with the PWR option on the Admin page.
|
||||||
@@ -48,6 +54,23 @@ func (app *appContext) StartPWR() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
usePolling := app.config.Section("password_resets").Key("watch_polling").MustBool(false)
|
||||||
|
|
||||||
|
if !messagesEnabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if usePolling {
|
||||||
|
watcher := pollingWatcher.New()
|
||||||
|
watcher.FilterOps(pollingWatcher.Write)
|
||||||
|
|
||||||
|
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()
|
watcher, err := fsnotify.NewWatcher()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.err.Printf(lm.FailedStartDaemon, "PWR", err)
|
app.err.Printf(lm.FailedStartDaemon, "PWR", err)
|
||||||
@@ -55,11 +78,12 @@ func (app *appContext) StartPWR() {
|
|||||||
}
|
}
|
||||||
defer watcher.Close()
|
defer watcher.Close()
|
||||||
|
|
||||||
go pwrMonitor(app, watcher)
|
go monitorFS(app, watcher)
|
||||||
err = watcher.Add(path)
|
err = watcher.Add(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.err.Printf(lm.FailedStartDaemon, "PWR", err)
|
app.err.Printf(lm.FailedStartDaemon, "PWR", err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
waitForRestart()
|
waitForRestart()
|
||||||
}
|
}
|
||||||
@@ -72,52 +96,55 @@ type PasswordReset struct {
|
|||||||
Internal bool `json:"Internal,omitempty"`
|
Internal bool `json:"Internal,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func pwrMonitor(app *appContext, watcher *fsnotify.Watcher) {
|
func validatePWR(app *appContext, fname string, attempt int) {
|
||||||
if !messagesEnabled {
|
currentTime := time.Now()
|
||||||
|
if !strings.Contains(fname, "passwordreset") {
|
||||||
return
|
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 {
|
for {
|
||||||
select {
|
select {
|
||||||
case event, ok := <-watcher.Events:
|
case event, ok := <-watcher.Events:
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if event.Op&fsnotify.Write == fsnotify.Write && strings.Contains(event.Name, "passwordreset") {
|
if event.Has(fsnotify.Write) {
|
||||||
var pwr PasswordReset
|
validatePWR(app, event.Name, 0)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
case err, ok := <-watcher.Errors:
|
case err, ok := <-watcher.Errors:
|
||||||
if !ok {
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+14
-7
@@ -10,9 +10,12 @@ import { ProfileEditor, reloadProfileNames } from "./modules/profiles.js";
|
|||||||
import { _get, _post, notificationBox, whichAnimationEvent, bindManualDropdowns } from "./modules/common.js";
|
import { _get, _post, notificationBox, whichAnimationEvent, bindManualDropdowns } from "./modules/common.js";
|
||||||
import { Updater } from "./modules/update.js";
|
import { Updater } from "./modules/update.js";
|
||||||
import { Login } from "./modules/login.js";
|
import { Login } from "./modules/login.js";
|
||||||
|
import { setupTooltips } from "./modules/ui.js";
|
||||||
|
|
||||||
declare var window: GlobalWindow;
|
declare var window: GlobalWindow;
|
||||||
|
|
||||||
|
setupTooltips();
|
||||||
|
|
||||||
const theme = new ThemeManager(document.getElementById("button-theme"));
|
const theme = new ThemeManager(document.getElementById("button-theme"));
|
||||||
|
|
||||||
window.lang = new lang(window.langFile as LangFile);
|
window.lang = new lang(window.langFile as LangFile);
|
||||||
@@ -152,25 +155,29 @@ let navigated = false;
|
|||||||
// load tabs
|
// load tabs
|
||||||
const tabs: { id: string; url: string; reloader: () => void; unloader?: () => void }[] = [];
|
const tabs: { id: string; url: string; reloader: () => void; unloader?: () => void }[] = [];
|
||||||
[window.invites, accounts, activity, settings].forEach((p: AsTab) => {
|
[window.invites, accounts, activity, settings].forEach((p: AsTab) => {
|
||||||
let t: { id: string; url: string; reloader: () => void; unloader?: () => void } = {
|
let t: { id: string; url: string; reloader: (previous?: AsTab) => void; unloader?: () => void } = {
|
||||||
id: p.tabName,
|
id: p.tabName,
|
||||||
url: p.pagePath,
|
url: p.pagePath,
|
||||||
reloader: () =>
|
reloader: (previous: AsTab) => {
|
||||||
p.reload(() => {
|
if (isPageEventBindable(p)) p.bindPageEvents();
|
||||||
if (!navigated && isNavigatable(p)) {
|
if (!navigated && isNavigatable(p) && p.isURL()) {
|
||||||
if (p.isURL()) {
|
|
||||||
navigated = true;
|
navigated = true;
|
||||||
p.navigate();
|
p.navigate();
|
||||||
|
} else {
|
||||||
|
if (navigated && previous && isNavigatable(previous)) {
|
||||||
|
// Clear the query param, as it was likely for a different page
|
||||||
|
previous.clearURL();
|
||||||
}
|
}
|
||||||
|
p.reload(() => {});
|
||||||
}
|
}
|
||||||
if (isPageEventBindable(p)) p.bindPageEvents();
|
},
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
if (isPageEventBindable(p)) t.unloader = p.unbindPageEvents;
|
if (isPageEventBindable(p)) t.unloader = p.unbindPageEvents;
|
||||||
tabs.push(t);
|
tabs.push(t);
|
||||||
window.tabs.addTab(
|
window.tabs.addTab(
|
||||||
t.id,
|
t.id,
|
||||||
window.pages.Base + window.pages.Admin + "/" + t.url,
|
window.pages.Base + window.pages.Admin + "/" + t.url,
|
||||||
|
p,
|
||||||
null,
|
null,
|
||||||
t.reloader,
|
t.reloader,
|
||||||
t.unloader || null,
|
t.unloader || null,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { loadLangSelector } from "./modules/lang.js";
|
|||||||
import { Validator, ValidatorConf, ValidatorRespDTO } from "./modules/validator.js";
|
import { Validator, ValidatorConf, ValidatorRespDTO } from "./modules/validator.js";
|
||||||
import { Discord, Telegram, Matrix, ServiceConfiguration, MatrixConfiguration } from "./modules/account-linking.js";
|
import { Discord, Telegram, Matrix, ServiceConfiguration, MatrixConfiguration } from "./modules/account-linking.js";
|
||||||
import { Captcha, GreCAPTCHA } from "./modules/captcha.js";
|
import { Captcha, GreCAPTCHA } from "./modules/captcha.js";
|
||||||
|
import { setupTooltips } from "./modules/ui.js";
|
||||||
|
|
||||||
interface formWindow extends GlobalWindow {
|
interface formWindow extends GlobalWindow {
|
||||||
invalidPassword: string;
|
invalidPassword: string;
|
||||||
@@ -42,6 +43,8 @@ interface formWindow extends GlobalWindow {
|
|||||||
collectEmail: boolean;
|
collectEmail: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setupTooltips();
|
||||||
|
|
||||||
loadLangSelector("form");
|
loadLangSelector("form");
|
||||||
|
|
||||||
window.notifications = new notificationBox(document.getElementById("notification-box") as HTMLDivElement);
|
window.notifications = new notificationBox(document.getElementById("notification-box") as HTMLDivElement);
|
||||||
|
|||||||
@@ -1106,6 +1106,7 @@ export class accountsList extends PaginatedList implements Navigatable, AsTab {
|
|||||||
itemsPerPage: 40,
|
itemsPerPage: 40,
|
||||||
maxItemsLoadedForSearch: 200,
|
maxItemsLoadedForSearch: 200,
|
||||||
appendNewItems: (resp: PaginatedDTO) => {
|
appendNewItems: (resp: PaginatedDTO) => {
|
||||||
|
// console.log("append");
|
||||||
for (let u of (resp as UsersDTO).users || []) {
|
for (let u of (resp as UsersDTO).users || []) {
|
||||||
if (this.users.has(u.id)) {
|
if (this.users.has(u.id)) {
|
||||||
this.users.get(u.id).update(u);
|
this.users.get(u.id).update(u);
|
||||||
@@ -1121,6 +1122,7 @@ export class accountsList extends PaginatedList implements Navigatable, AsTab {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
replaceWithNewItems: (resp: PaginatedDTO) => {
|
replaceWithNewItems: (resp: PaginatedDTO) => {
|
||||||
|
// console.log("replace");
|
||||||
let accountsOnDOM = new Map<string, boolean>();
|
let accountsOnDOM = new Map<string, boolean>();
|
||||||
|
|
||||||
for (let id of this.users.keys()) {
|
for (let id of this.users.keys()) {
|
||||||
@@ -2514,6 +2516,10 @@ export class accountsList extends PaginatedList implements Navigatable, AsTab {
|
|||||||
if (details) this.details(details);
|
if (details) this.details(details);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
clearURL() {
|
||||||
|
this._search.clearURL();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// An alternate view showing accounts in sub-lists grouped by group/label.
|
// An alternate view showing accounts in sub-lists grouped by group/label.
|
||||||
@@ -2946,6 +2952,7 @@ class UserInfo extends PaginatedList {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this._search.setOrdering(Array.from(this.entries.keys()), "Date", true);
|
this._search.setOrdering(Array.from(this.entries.keys()), "Date", true);
|
||||||
|
this.computeScrollInfo();
|
||||||
},
|
},
|
||||||
replaceWithNewItems: (resp: PaginatedDTO) => {
|
replaceWithNewItems: (resp: PaginatedDTO) => {
|
||||||
let entriesOnDOM = new Map<string, boolean>();
|
let entriesOnDOM = new Map<string, boolean>();
|
||||||
|
|||||||
@@ -708,4 +708,8 @@ export class activityList extends PaginatedList implements Navigatable, AsTab {
|
|||||||
}
|
}
|
||||||
this._search.navigate(urlParams.toString());
|
this._search.navigate(urlParams.toString());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
clearURL() {
|
||||||
|
this._search.clearURL();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-6
@@ -13,7 +13,7 @@ import {
|
|||||||
} from "../modules/common.js";
|
} from "../modules/common.js";
|
||||||
import { DiscordSearch, DiscordUser, newDiscordSearch } from "../modules/discord.js";
|
import { DiscordSearch, DiscordUser, newDiscordSearch } from "../modules/discord.js";
|
||||||
import { reloadProfileNames } from "../modules/profiles.js";
|
import { reloadProfileNames } from "../modules/profiles.js";
|
||||||
import { HiddenInputField, RadioBasedTabSelector } from "./ui.js";
|
import { HiddenInputField, RadioBasedTabSelector, Tooltip } from "./ui.js";
|
||||||
|
|
||||||
declare var window: GlobalWindow;
|
declare var window: GlobalWindow;
|
||||||
|
|
||||||
@@ -205,10 +205,9 @@ class DOMInvite implements Invite {
|
|||||||
}
|
}
|
||||||
set send_to(address: string | null) {
|
set send_to(address: string | null) {
|
||||||
this._send_to = address;
|
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 icon = container.querySelector("i");
|
||||||
const chip = container.querySelector("span.inv-email-chip");
|
const chip = container.querySelector("span.inv-email-chip");
|
||||||
const tooltip = container.querySelector("span.content") as HTMLSpanElement;
|
|
||||||
if (!address) {
|
if (!address) {
|
||||||
icon.classList.remove("ri-mail-line");
|
icon.classList.remove("ri-mail-line");
|
||||||
icon.classList.remove("ri-mail-close-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.
|
// innerHTML as the newer sent_to re-uses this with HTML.
|
||||||
tooltip.innerHTML = address;
|
container.content.innerHTML = address;
|
||||||
}
|
}
|
||||||
private _sendToDialog: SendToDialog;
|
private _sendToDialog: SendToDialog;
|
||||||
private _sent_to: SentToList;
|
private _sent_to: SentToList;
|
||||||
@@ -603,10 +602,10 @@ class DOMInvite implements Invite {
|
|||||||
this._header.appendChild(this._infoArea);
|
this._header.appendChild(this._infoArea);
|
||||||
this._infoArea.classList.add("inv-infoarea", "flex", "flex-row", "items-center", "gap-2");
|
this._infoArea.classList.add("inv-infoarea", "flex", "flex-row", "items-center", "gap-2");
|
||||||
this._infoArea.innerHTML = `
|
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="inv-email-chip h-full"><i></i></span>
|
||||||
<span class="content sm p-1"></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>
|
<span class="button ~critical @low inv-delete h-full">${window.lang.strings("delete")}</span>
|
||||||
<label>
|
<label>
|
||||||
<i class="icon px-2.5 py-2 ri-arrow-down-s-line text-xl not-rotated"></i>
|
<i class="icon px-2.5 py-2 ri-arrow-down-s-line text-xl not-rotated"></i>
|
||||||
@@ -818,6 +817,14 @@ export class DOMInviteList implements InviteList {
|
|||||||
this.focusInvite(inviteCode, window.lang.notif("errorInviteNotFound"));
|
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() {
|
constructor() {
|
||||||
this._list = document.getElementById("invites") as HTMLDivElement;
|
this._list = document.getElementById("invites") as HTMLDivElement;
|
||||||
this.empty = true;
|
this.empty = true;
|
||||||
|
|||||||
@@ -359,6 +359,8 @@ export abstract class PaginatedList implements PageEventBindable {
|
|||||||
return bottomIdx;
|
return bottomIdx;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private _loadLock: boolean = false;
|
||||||
|
private _loadQueue: (() => void)[] = [];
|
||||||
private _load = (
|
private _load = (
|
||||||
itemLimit: number,
|
itemLimit: number,
|
||||||
page: number,
|
page: number,
|
||||||
@@ -367,7 +369,17 @@ export abstract class PaginatedList implements PageEventBindable {
|
|||||||
post?: (resp: PaginatedDTO) => void,
|
post?: (resp: PaginatedDTO) => void,
|
||||||
failCallback?: (req: XMLHttpRequest) => 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._lastLoad = Date.now();
|
||||||
|
this._loadLock = true;
|
||||||
let params = this._search.inServerSearch ? this._searchParams : this.defaultParams();
|
let params = this._search.inServerSearch ? this._searchParams : this.defaultParams();
|
||||||
params.limit = itemLimit;
|
params.limit = itemLimit;
|
||||||
params.page = page;
|
params.page = page;
|
||||||
@@ -397,9 +409,14 @@ export abstract class PaginatedList implements PageEventBindable {
|
|||||||
|
|
||||||
this._counter.loaded = this._search.ordering.length;
|
this._counter.loaded = this._search.ordering.length;
|
||||||
|
|
||||||
|
this._loadLock = false;
|
||||||
|
|
||||||
if (post) post(resp);
|
if (post) post(resp);
|
||||||
|
|
||||||
if (this._c.pageLoadCallback) this._c.pageLoadCallback(req);
|
if (this._c.pageLoadCallback) this._c.pageLoadCallback(req);
|
||||||
|
|
||||||
|
const next = this._loadQueue.shift();
|
||||||
|
if (next) next();
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
@@ -408,6 +425,7 @@ export abstract class PaginatedList implements PageEventBindable {
|
|||||||
// Removes all elements, and reloads the first page.
|
// Removes all elements, and reloads the first page.
|
||||||
public abstract reload: (callback?: (resp: PaginatedDTO) => void) => void;
|
public abstract reload: (callback?: (resp: PaginatedDTO) => void) => void;
|
||||||
protected _reload = (callback?: (resp: PaginatedDTO) => void) => {
|
protected _reload = (callback?: (resp: PaginatedDTO) => void) => {
|
||||||
|
console.trace("reloading");
|
||||||
this.lastPage = false;
|
this.lastPage = false;
|
||||||
this._counter.reset();
|
this._counter.reset();
|
||||||
this._counter.getTotal(
|
this._counter.getTotal(
|
||||||
@@ -511,6 +529,7 @@ export abstract class PaginatedList implements PageEventBindable {
|
|||||||
this._scroll.lastScrollY = scrollY;
|
this._scroll.lastScrollY = scrollY;
|
||||||
// If you've scrolled back up, do nothing
|
// If you've scrolled back up, do nothing
|
||||||
if (scrollSpeed < 0) return;
|
if (scrollSpeed < 0) return;
|
||||||
|
if (this._scroll.rowHeight == 0) this.computeScrollInfo();
|
||||||
let endIdx = this.maximumItemsToRender(scrollY);
|
let endIdx = this.maximumItemsToRender(scrollY);
|
||||||
|
|
||||||
// Throttling this function means we might not catch up in time if the user scrolls fast,
|
// Throttling this function means we might not catch up in time if the user scrolls fast,
|
||||||
|
|||||||
+23
-5
@@ -552,6 +552,7 @@ export class Search implements Navigatable {
|
|||||||
return this._ascending;
|
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 = (
|
onSearchBoxChange = (
|
||||||
newItems: boolean = false,
|
newItems: boolean = false,
|
||||||
appendedItems: boolean = false,
|
appendedItems: boolean = false,
|
||||||
@@ -721,6 +722,19 @@ export class Search implements Navigatable {
|
|||||||
return req;
|
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,
|
// 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
|
// or value if given. If everything is set up correctly, this should trigger a search when it is
|
||||||
// set to a new value.
|
// set to a new value.
|
||||||
@@ -737,7 +751,7 @@ export class Search implements Navigatable {
|
|||||||
triggerManually = !url.searchParams.has("search");
|
triggerManually = !url.searchParams.has("search");
|
||||||
url.searchParams.delete("search");
|
url.searchParams.delete("search");
|
||||||
}
|
}
|
||||||
console.log("pushing", url.toString());
|
// console.log("pushing", url.toString());
|
||||||
window.history.pushState(null, "", url.toString());
|
window.history.pushState(null, "", url.toString());
|
||||||
if (triggerManually) this.navigate();
|
if (triggerManually) this.navigate();
|
||||||
};
|
};
|
||||||
@@ -754,10 +768,13 @@ export class Search implements Navigatable {
|
|||||||
|
|
||||||
// navigate pulls the current "search" query param, puts it in the search box and searches it.
|
// navigate pulls the current "search" query param, puts it in the search box and searches it.
|
||||||
navigate = (url?: string, then?: () => void) => {
|
navigate = (url?: string, then?: () => void) => {
|
||||||
(window as any).s = this;
|
this._qps = new URLSearchParams(url || window.location.search);
|
||||||
const urlParams = new URLSearchParams(url || window.location.search);
|
if (this._clearWithoutNavigate) {
|
||||||
const searchContent = urlParams.get("search") || "";
|
this._clearWithoutNavigate = false;
|
||||||
console.log("navigate!, setting search box to ", searchContent);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchContent = this._qps.get("search") || "";
|
||||||
this._c.search.value = searchContent;
|
this._c.search.value = searchContent;
|
||||||
this.onSearchBoxChange();
|
this.onSearchBoxChange();
|
||||||
this.onServerSearch(then);
|
this.onServerSearch(then);
|
||||||
@@ -772,6 +789,7 @@ export class Search implements Navigatable {
|
|||||||
|
|
||||||
this._c.search.oninput = () => {
|
this._c.search.oninput = () => {
|
||||||
this.inServerSearch = false;
|
this.inServerSearch = false;
|
||||||
|
this.clearQueryParam();
|
||||||
this.onSearchBoxChange();
|
this.onSearchBoxChange();
|
||||||
};
|
};
|
||||||
this._c.search.addEventListener("keyup", (ev: KeyboardEvent) => {
|
this._c.search.addEventListener("keyup", (ev: KeyboardEvent) => {
|
||||||
|
|||||||
+17
-18
@@ -14,6 +14,7 @@ import {
|
|||||||
} from "../modules/common.js";
|
} from "../modules/common.js";
|
||||||
import { Marked } from "@ts-stack/markdown";
|
import { Marked } from "@ts-stack/markdown";
|
||||||
import { stripMarkdown } from "../modules/stripmd.js";
|
import { stripMarkdown } from "../modules/stripmd.js";
|
||||||
|
import { Tooltip } from "./ui.js";
|
||||||
|
|
||||||
declare var window: GlobalWindow;
|
declare var window: GlobalWindow;
|
||||||
|
|
||||||
@@ -109,7 +110,7 @@ class DOMSetting {
|
|||||||
protected _hideEl: HTMLElement;
|
protected _hideEl: HTMLElement;
|
||||||
protected _input: HTMLInputElement;
|
protected _input: HTMLInputElement;
|
||||||
protected _container: HTMLDivElement;
|
protected _container: HTMLDivElement;
|
||||||
protected _tooltip: HTMLDivElement;
|
protected _tooltip: Tooltip;
|
||||||
protected _required: HTMLSpanElement;
|
protected _required: HTMLSpanElement;
|
||||||
protected _restart: HTMLSpanElement;
|
protected _restart: HTMLSpanElement;
|
||||||
protected _advanced: boolean;
|
protected _advanced: boolean;
|
||||||
@@ -127,7 +128,6 @@ class DOMSetting {
|
|||||||
this._hideEl.classList.remove("unfocused");
|
this._hideEl.classList.remove("unfocused");
|
||||||
}
|
}
|
||||||
document.dispatchEvent(changedEvent(this._section, this.setting, this.valueAsString(), v));
|
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) => {
|
private _advancedListener = (event: advancedEvent) => {
|
||||||
@@ -154,11 +154,10 @@ class DOMSetting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get description(): string {
|
get description(): string {
|
||||||
return this._tooltip.querySelector("span.content").textContent;
|
return this._tooltip.content.textContent;
|
||||||
}
|
}
|
||||||
set description(d: string) {
|
set description(d: string) {
|
||||||
const content = this._tooltip.querySelector("span.content") as HTMLSpanElement;
|
this._tooltip.content.textContent = d;
|
||||||
content.textContent = d;
|
|
||||||
if (d == "") {
|
if (d == "") {
|
||||||
this._tooltip.classList.add("unfocused");
|
this._tooltip.classList.add("unfocused");
|
||||||
} else {
|
} else {
|
||||||
@@ -248,17 +247,17 @@ class DOMSetting {
|
|||||||
${inputOnTop ? input : ""}
|
${inputOnTop ? input : ""}
|
||||||
<div class="flex flex-row gap-2 items-baseline">
|
<div class="flex flex-row gap-2 items-baseline">
|
||||||
<span class="setting-label"></span>
|
<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>
|
<i class="icon ri-information-line align-[-0.05rem]"></i>
|
||||||
<span class="content sm"></span>
|
<span class="content sm"></span>
|
||||||
</div>
|
</tool-tip>
|
||||||
<span class="setting-required unfocused"></span>
|
<span class="setting-required unfocused"></span>
|
||||||
<span class="setting-restart unfocused"></span>
|
<span class="setting-restart unfocused"></span>
|
||||||
</div>
|
</div>
|
||||||
${inputOnTop ? "" : input}
|
${inputOnTop ? "" : input}
|
||||||
</label>
|
</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._required = this._container.querySelector("span.setting-required") as HTMLSpanElement;
|
||||||
this._restart = this._container.querySelector("span.setting-restart") 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"
|
// "input" variable should supply the HTML of an element with class "setting-input"
|
||||||
@@ -1406,8 +1405,8 @@ export class settingsList implements AsTab {
|
|||||||
|
|
||||||
// Create (restart)required badges (can't do on load as window.lang is unset)
|
// Create (restart)required badges (can't do on load as window.lang is unset)
|
||||||
RestartRequiredBadge = (() => {
|
RestartRequiredBadge = (() => {
|
||||||
const rr = document.createElement("span");
|
const rr = document.createElement("tool-tip") as Tooltip;
|
||||||
rr.classList.add("tooltip", "below", "force-ltr");
|
rr.classList.add("below", "force-ltr");
|
||||||
rr.innerHTML = `
|
rr.innerHTML = `
|
||||||
<span class="badge ~info dark:~d_warning align-[0.08rem]"><i class="icon ri-refresh-line h-full"></i></span>
|
<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>
|
<span class="content sm">${window.lang.strings("restartRequired")}</span>
|
||||||
@@ -1416,8 +1415,8 @@ export class settingsList implements AsTab {
|
|||||||
return rr;
|
return rr;
|
||||||
})();
|
})();
|
||||||
RequiredBadge = (() => {
|
RequiredBadge = (() => {
|
||||||
const r = document.createElement("span");
|
const r = document.createElement("tool-tip");
|
||||||
r.classList.add("tooltip", "below", "force-ltr");
|
r.classList.add("below", "force-ltr");
|
||||||
r.innerHTML = `
|
r.innerHTML = `
|
||||||
<span class="badge ~critical align-[0.08rem]"><i class="icon ri-asterisk h-full"></i></span>
|
<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>
|
<span class="content sm">${window.lang.strings("required")}</span>
|
||||||
@@ -1490,8 +1489,8 @@ export class settingsList implements AsTab {
|
|||||||
this._sections[section.section].update(section);
|
this._sections[section.section].update(section);
|
||||||
} else {
|
} else {
|
||||||
if (section.section == "messages" || section.section == "user_page") {
|
if (section.section == "messages" || section.section == "user_page") {
|
||||||
const editButton = document.createElement("div");
|
const editButton = document.createElement("tool-tip");
|
||||||
editButton.classList.add("tooltip", "left", "h-full", "force-ltr");
|
editButton.classList.add("left", "h-full", "force-ltr");
|
||||||
editButton.innerHTML = `
|
editButton.innerHTML = `
|
||||||
<span class="button ~neutral @low h-full">
|
<span class="button ~neutral @low h-full">
|
||||||
<i class="icon ri-edit-line"></i>
|
<i class="icon ri-edit-line"></i>
|
||||||
@@ -1528,8 +1527,8 @@ export class settingsList implements AsTab {
|
|||||||
}
|
}
|
||||||
this.addSection(section.section, section, icon);
|
this.addSection(section.section, section, icon);
|
||||||
} else if (section.section == "matrix" && !window.matrixEnabled) {
|
} else if (section.section == "matrix" && !window.matrixEnabled) {
|
||||||
const addButton = document.createElement("div");
|
const addButton = document.createElement("tool-tip");
|
||||||
addButton.classList.add("tooltip", "left", "h-full", "force-ltr");
|
addButton.classList.add("left", "h-full", "force-ltr");
|
||||||
addButton.innerHTML = `
|
addButton.innerHTML = `
|
||||||
<span class="button ~neutral h-full"><i class="icon ri-links-line"></i></span>
|
<span class="button ~neutral h-full"><i class="icon ri-links-line"></i></span>
|
||||||
<span class="content sm">
|
<span class="content sm">
|
||||||
@@ -1912,10 +1911,10 @@ class MessageEditor {
|
|||||||
`;
|
`;
|
||||||
if (this._names[id].description != "")
|
if (this._names[id].description != "")
|
||||||
innerHTML += `
|
innerHTML += `
|
||||||
<div class="tooltip right">
|
<tool-tip class="right">
|
||||||
<i class="icon ri-information-line"></i>
|
<i class="icon ri-information-line"></i>
|
||||||
<span class="content sm">${this._names[id].description}</span>
|
<span class="content sm">${this._names[id].description}</span>
|
||||||
</div>
|
</tool-tip>
|
||||||
`;
|
`;
|
||||||
innerHTML += `
|
innerHTML += `
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
+10
-6
@@ -1,4 +1,4 @@
|
|||||||
import { PageManager } from "../modules/pages.js";
|
import { PageManager } from "./pages";
|
||||||
|
|
||||||
export function isPageEventBindable(object: any): object is PageEventBindable {
|
export function isPageEventBindable(object: any): object is PageEventBindable {
|
||||||
return "bindPageEvents" in object;
|
return "bindPageEvents" in object;
|
||||||
@@ -12,7 +12,7 @@ export class TabManager implements TabManager {
|
|||||||
private _current: string = "";
|
private _current: string = "";
|
||||||
private _baseOffset = -1;
|
private _baseOffset = -1;
|
||||||
tabs: Map<string, Tab>;
|
tabs: Map<string, Tab>;
|
||||||
pages: PageManager;
|
pages: Pages;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.tabs = new Map<string, Tab>();
|
this.tabs = new Map<string, Tab>();
|
||||||
@@ -26,14 +26,16 @@ export class TabManager implements TabManager {
|
|||||||
addTab = (
|
addTab = (
|
||||||
tabID: string,
|
tabID: string,
|
||||||
url: string,
|
url: string,
|
||||||
preFunc = () => void {},
|
contentObject: AsTab | null,
|
||||||
postFunc = () => void {},
|
preFunc: (previous?: AsTab) => void = (_?: AsTab) => void {},
|
||||||
|
postFunc: (previous?: AsTab) => void = (_?: AsTab) => void {},
|
||||||
unloadFunc = () => void {},
|
unloadFunc = () => void {},
|
||||||
) => {
|
) => {
|
||||||
let tab: Tab = {
|
let tab: Tab = {
|
||||||
page: null,
|
page: null,
|
||||||
tabEl: document.getElementById("tab-" + tabID) as HTMLDivElement,
|
tabEl: document.getElementById("tab-" + tabID) as HTMLDivElement,
|
||||||
buttonEl: document.getElementById("button-tab-" + tabID) as HTMLButtonElement,
|
buttonEl: document.getElementById("button-tab-" + tabID) as HTMLButtonElement,
|
||||||
|
contentObject: contentObject,
|
||||||
preFunc: preFunc,
|
preFunc: preFunc,
|
||||||
postFunc: postFunc,
|
postFunc: postFunc,
|
||||||
};
|
};
|
||||||
@@ -91,14 +93,16 @@ export class TabManager implements TabManager {
|
|||||||
[t] = this.tabs.values();
|
[t] = this.tabs.values();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const prev = this.tabs.get(this.current);
|
||||||
|
|
||||||
this._current = t.page.name;
|
this._current = t.page.name;
|
||||||
|
|
||||||
if (t.preFunc && !noRun) {
|
if (t.preFunc && !noRun) {
|
||||||
t.preFunc();
|
t.preFunc(prev?.contentObject);
|
||||||
}
|
}
|
||||||
this.pages.load(tabID);
|
this.pages.load(tabID);
|
||||||
if (t.postFunc && !noRun) {
|
if (t.postFunc && !noRun) {
|
||||||
t.postFunc();
|
t.postFunc(prev?.contentObject);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,3 +209,130 @@ export class RadioBasedTabSelector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 { toClipboard, notificationBox } from "./modules/common.js";
|
||||||
|
import { setupTooltips } from "./modules/ui.js";
|
||||||
|
|
||||||
declare var window: GlobalWindow;
|
declare var window: GlobalWindow;
|
||||||
|
|
||||||
|
setupTooltips();
|
||||||
|
|
||||||
const pin = document.getElementById("pin") as HTMLSpanElement;
|
const pin = document.getElementById("pin") as HTMLSpanElement;
|
||||||
|
|
||||||
if (pin) {
|
if (pin) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Validator, ValidatorConf } from "./modules/validator.js";
|
|||||||
import { _post, addLoader, removeLoader } from "./modules/common.js";
|
import { _post, addLoader, removeLoader } from "./modules/common.js";
|
||||||
import { loadLangSelector } from "./modules/lang.js";
|
import { loadLangSelector } from "./modules/lang.js";
|
||||||
import { Captcha, GreCAPTCHA } from "./modules/captcha.js";
|
import { Captcha, GreCAPTCHA } from "./modules/captcha.js";
|
||||||
|
import { setupTooltips } from "./modules/ui.js";
|
||||||
|
|
||||||
interface formWindow extends Window {
|
interface formWindow extends Window {
|
||||||
invalidPassword: string;
|
invalidPassword: string;
|
||||||
@@ -39,6 +40,8 @@ loadLangSelector("pwr");
|
|||||||
|
|
||||||
declare var window: formWindow;
|
declare var window: formWindow;
|
||||||
|
|
||||||
|
setupTooltips();
|
||||||
|
|
||||||
const form = document.getElementById("form-create") as HTMLFormElement;
|
const form = document.getElementById("form-create") as HTMLFormElement;
|
||||||
const submitInput = form.querySelector("input[type=submit]") as HTMLInputElement;
|
const submitInput = form.querySelector("input[type=submit]") as HTMLInputElement;
|
||||||
const submitSpan = form.querySelector("span.submit") as HTMLSpanElement;
|
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 { lang, LangFile, loadLangSelector } from "./modules/lang.js";
|
||||||
import { ThemeManager } from "./modules/theme.js";
|
import { ThemeManager } from "./modules/theme.js";
|
||||||
import { PageManager } from "./modules/pages.js";
|
import { PageManager } from "./modules/pages.js";
|
||||||
|
import { setupTooltips } from "./modules/ui.js";
|
||||||
|
|
||||||
interface sWindow extends GlobalWindow {
|
interface sWindow extends GlobalWindow {
|
||||||
messages: {};
|
messages: {};
|
||||||
@@ -9,6 +10,8 @@ interface sWindow extends GlobalWindow {
|
|||||||
|
|
||||||
declare var window: sWindow;
|
declare var window: sWindow;
|
||||||
|
|
||||||
|
setupTooltips();
|
||||||
|
|
||||||
const theme = new ThemeManager(document.getElementById("button-theme"));
|
const theme = new ThemeManager(document.getElementById("button-theme"));
|
||||||
|
|
||||||
window.notifications = new notificationBox(document.getElementById("notification-box") as HTMLDivElement, 5);
|
window.notifications = new notificationBox(document.getElementById("notification-box") as HTMLDivElement, 5);
|
||||||
|
|||||||
+13
-3
@@ -75,6 +75,8 @@ declare interface AsTab {
|
|||||||
declare interface Navigatable {
|
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 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;
|
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 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;
|
navigate(url?: string): void;
|
||||||
}
|
}
|
||||||
@@ -196,14 +198,22 @@ declare interface Tab {
|
|||||||
page: Page;
|
page: Page;
|
||||||
tabEl: HTMLDivElement;
|
tabEl: HTMLDivElement;
|
||||||
buttonEl: HTMLSpanElement;
|
buttonEl: HTMLSpanElement;
|
||||||
preFunc?: () => void;
|
contentObject?: AsTab;
|
||||||
postFunc?: () => void;
|
preFunc?: (previous?: AsTab) => void;
|
||||||
|
postFunc?: (previous?: AsTab) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare interface Tabs {
|
declare interface Tabs {
|
||||||
tabs: Map<string, Tab>;
|
tabs: Map<string, Tab>;
|
||||||
pages: Pages;
|
pages: Pages;
|
||||||
addTab(tabID: string, url: string, preFunc: () => void, postFunc: () => void, unloadFunc: () => void): void;
|
addTab(
|
||||||
|
tabID: string,
|
||||||
|
url: string,
|
||||||
|
contentObject: AsTab | null,
|
||||||
|
preFunc: () => void,
|
||||||
|
postFunc: () => void,
|
||||||
|
unloadFunc: () => void,
|
||||||
|
): void;
|
||||||
current: string;
|
current: string;
|
||||||
switch(tabID: string, noRun?: boolean): void;
|
switch(tabID: string, noRun?: boolean): void;
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-2
@@ -17,6 +17,7 @@ import { Discord, Telegram, Matrix, ServiceConfiguration, MatrixConfiguration }
|
|||||||
import { Validator, ValidatorConf, ValidatorRespDTO } from "./modules/validator.js";
|
import { Validator, ValidatorConf, ValidatorRespDTO } from "./modules/validator.js";
|
||||||
import { PageManager } from "./modules/pages.js";
|
import { PageManager } from "./modules/pages.js";
|
||||||
import { generateCodeLink } from "./modules/invites.js";
|
import { generateCodeLink } from "./modules/invites.js";
|
||||||
|
import { setupTooltips } from "./modules/ui.js";
|
||||||
|
|
||||||
interface userWindow extends GlobalWindow {
|
interface userWindow extends GlobalWindow {
|
||||||
jellyfinID: string;
|
jellyfinID: string;
|
||||||
@@ -34,6 +35,8 @@ interface userWindow extends GlobalWindow {
|
|||||||
|
|
||||||
declare var window: userWindow;
|
declare var window: userWindow;
|
||||||
|
|
||||||
|
setupTooltips();
|
||||||
|
|
||||||
// const basePath = window.location.pathname.replace("/password/reset", "");
|
// const basePath = window.location.pathname.replace("/password/reset", "");
|
||||||
const basePath = window.pages.Base + window.pages.MyAccount;
|
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");
|
const confirmationRequired = window.modals.email.modal.querySelector(".confirmation-required");
|
||||||
confirmationRequired.classList.add("unfocused");
|
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");
|
content.classList.remove("unfocused");
|
||||||
|
|
||||||
const submit = window.modals.email.modal.querySelector(".card").children[0] as HTMLButtonElement;
|
const submit = window.modals.email.modal.querySelector(".card").children[0] as HTMLButtonElement;
|
||||||
@@ -757,7 +760,7 @@ document.addEventListener("details-reload", () => {
|
|||||||
}
|
}
|
||||||
if (!messageCard.textContent) {
|
if (!messageCard.textContent) {
|
||||||
messageCard.innerHTML = `
|
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>
|
<span class="block">${window.lang.strings("customMessagePlaceholderContent")}</span>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user