Compare commits

...
10 Commits
Author SHA1 Message Date
Assclaw 211c3fa9cb Handle GrizzlyFlix signup mail and Seerr sync failures 2026-06-27 04:40:45 +00:00
Harvey Tindall de8e7c69be seerr: fix discord ID setting
APi change, discordId is now discordIds (an array).
2026-06-10 08:55:06 +01:00
Harvey Tindall 76878976ee PWR: add optional polling mode
enable watch_polling/"Use polling" if the usual PWR monitoring doesn't
work for you (likely if you're using a network mount (SMB/NFS) or
anything with FUSE.
2026-03-15 13:46:53 +00:00
Harvey Tindall 5aa640d63d settings: remove debug console.logs 2026-03-01 16:33:23 +00:00
Harvey Tindall d7fdf29c7c jf_activity: fix infinite load
computeScrollInfo wasn't being called before the first detectScroll
call, because there is no search functionality (somehow, i think). Fixed
by calling it if _scroll.rowHeight is 0.
2026-03-01 16:24:09 +00:00
Harvey Tindall 60bf89bc02 email: complain about invalid from address 2026-03-01 15:10:23 +00:00
Harvey Tindall 637cca0625 userpage: fix confirmation required message display
other content wasn't being hid when the message popped up, should now.
For #455.
2026-01-05 16:51:59 +00:00
Harvey Tindall 455bde491f tooltip: rework as pseudo-component, fix overflow
uses the <tool-tip> tag now, and the setupTooltips() function in ui.ts
must be called at any point in the pages load. added "below-center"
position, showing below and centered horizontally. breakpoint tailwind
lingo also now works (e.g. "below-center sm:right"). If a left or
right-aligned tooltip clips off the screen, it will be shifted the
appropriate amount left/right to avoid that automatically.
2026-01-05 16:49:13 +00:00
Harvey Tindall ee96bb9f1b tabs: add clearURL method, loading tabs clears previous qps
Navigatable has clearURL, which for Search clears "search" qp, and
invites clears "invite" qp. Tab interfaces optionally include
"contentObject: AsTab", and show/hide funcs are passed the contentObject
of the previously loaded tab if one is available, so that they can call
it's clearURL method. This means searches you typed for the accounts tab
won't pop up when switching to activity.
2026-01-05 10:39:20 +00:00
Harvey Tindall 721b209e1f list: add load queue
_loadLock set true when a load is happening, if another _load is called
when this is happening, the method call is appended to _loadQueue. When
a running _load finishes, it shift()s the _loadQueue and calls the
method from it if there is one.
2026-01-05 09:51:22 +00:00
28 changed files with 564 additions and 211 deletions
+25 -17
View File
@@ -189,32 +189,40 @@ func (app *appContext) NewUserFromInvite(gc *gin.Context) {
respond(500, "errorUnknown", gc) respond(500, "errorUnknown", gc)
return return
} }
if app.ConfirmationKeys == nil {
app.ConfirmationKeys = map[string]map[string]ConfirmationKey{}
}
cKeys, ok := app.ConfirmationKeys[req.Code]
if !ok {
cKeys = map[string]ConfirmationKey{}
}
cKeys[key] = ConfirmationKey{
newUserDTO: req,
completeContactMethods: completeContactMethods,
}
app.confirmationKeysLock.Lock()
app.ConfirmationKeys[req.Code] = cKeys
app.confirmationKeysLock.Unlock()
app.debug.Printf(lm.EmailConfirmationRequired, req.Username)
respond(401, "confirmEmail", gc)
msg, err := app.email.constructConfirmation(req.Code, req.Username, key, false) msg, err := app.email.constructConfirmation(req.Code, req.Username, key, false)
if err != nil { if err != nil {
app.err.Printf(lm.FailedConstructConfirmationEmail, req.Code, err) app.err.Printf(lm.FailedConstructConfirmationEmail, req.Code, err)
respond(500, "errorUnknown", gc)
return
} else if err := app.email.send(msg, req.Email); err != nil { } else if err := app.email.send(msg, req.Email); err != nil {
app.err.Printf(lm.FailedSendConfirmationEmail, req.Code, req.Email, err) 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 { } else {
app.debug.Printf(lm.SentConfirmationEmail, req.Code, req.Email) app.debug.Printf(lm.SentConfirmationEmail, req.Code, req.Email)
if app.ConfirmationKeys == nil {
app.ConfirmationKeys = map[string]map[string]ConfirmationKey{}
}
cKeys, ok := app.ConfirmationKeys[req.Code]
if !ok {
cKeys = map[string]ConfirmationKey{}
}
cKeys[key] = ConfirmationKey{
newUserDTO: req,
completeContactMethods: completeContactMethods,
}
app.confirmationKeysLock.Lock()
app.ConfirmationKeys[req.Code] = cKeys
app.confirmationKeysLock.Unlock()
app.debug.Printf(lm.EmailConfirmationRequired, req.Username)
respond(401, "confirmEmail", gc)
return
} }
return
} }
} }
+24
View File
@@ -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
+83 -59
View File
@@ -1,71 +1,95 @@
.tooltip { @layer components {
position: relative; tool-tip {
display: inline-block; position: relative;
} display: inline-block;
}
.tooltip .content { tool-tip .content {
visibility: hidden; visibility: hidden;
opacity: 0; opacity: 0;
max-width: 16rem; width: max-content;
min-width: 6rem; max-width: 16rem;
background-color: rgba(0, 0, 0, 0.6); /*min-width: 6rem;*/
color: #fff; background-color: rgba(0, 0, 0, 0.6);
padding: 0.5rem; color: #fff;
border-radius: 6px; padding: 0.5rem;
overflow-wrap: break-word; border-radius: 6px;
text-align: center; overflow-wrap: break-word;
transition: opacity 100ms; text-align: center;
transition: opacity 100ms;
position: absolute; position: absolute;
z-index: 1; z-index: 1;
top: -1rem; top: -1rem;
} }
.tooltip.below .content { tool-tip.above {
top: calc(100% + 0.125rem); --tooltip-position: above;
left: 50%; }
right: 0; tool-tip.below {
transform: translateX(-50%); --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 { tool-tip.below .content {
top: unset; top: 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.below-center .content {
background-color: rgba(0, 0, 0, 0.8); top: calc(100% + 0.125rem);
} max-width: calc(100vw - 4rem);
}
.tooltip.right .content { tool-tip.above .content {
left: 120%; top: unset;
} bottom: calc(100% + 0.125rem);
left: 50%;
right: 0;
transform: translateX(-50%);
}
.tooltip.right:dir(rtl):not(.force-ltr) .content { tool-tip.darker .content {
right: 120%; background-color: rgba(0, 0, 0, 0.8);
left: unset; }
}
.tooltip.left .content { tool-tip.right .content {
right: 120%; left: 0%;
} }
.tooltip.left:dir(rtl):not(.force-ltr) .content { tool-tip.right:dir(rtl):not(.force-ltr) .content {
left: 120%; right: 0%;
right: unset; left: unset;
} }
.tooltip .content.sm { tool-tip.left .content {
font-size: 0.8rem; right: 0%;
} }
.tooltip:hover .content, tool-tip.left:dir(rtl):not(.force-ltr) .content {
.tooltip:focus .content, left: 0%;
.tooltip:focus-within .content right: unset;
{ }
visibility: visible;
opacity: 1; 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;
}
} }
+4 -1
View File
@@ -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)
+1
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+14 -3
View File
@@ -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,7 +69,11 @@ 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 {
app.err.Printf(lm.FailedSyncContactMethods, lm.Jellyseerr, err) 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)
}
} }
} }
} }
+7
View File
@@ -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
+38 -26
View File
@@ -10,25 +10,26 @@ const (
) )
type User struct { type User struct {
UserTemplate // Note: You can set this with User.UserTemplate = value. UserTemplate // Note: You can set this with User.UserTemplate = value.
UserType int64 `json:"userType,omitempty"` UserType int64 `json:"userType,omitempty"`
Warnings []any `json:"warnings,omitempty"` Warnings []any `json:"warnings,omitempty"`
ID int64 `json:"id,omitempty"` ID int64 `json:"id,omitempty"`
Email string `json:"email,omitempty"` Email string `json:"email,omitempty"`
PlexUsername string `json:"plexUsername,omitempty"` PlexUsername string `json:"plexUsername,omitempty"`
JellyfinUsername string `json:"jellyfinUsername,omitempty"` JellyfinUsername string `json:"jellyfinUsername,omitempty"`
Username string `json:"username,omitempty"` Username string `json:"username,omitempty"`
RecoveryLinkExpirationDate any `json:"recoveryLinkExpirationDate,omitempty"` RecoveryLinkExpirationDate any `json:"recoveryLinkExpirationDate,omitempty"`
PlexID string `json:"plexId,omitempty"` PlexID string `json:"plexId,omitempty"`
JellyfinUserID string `json:"jellyfinUserId,omitempty"` JellyfinUserID string `json:"jellyfinUserId,omitempty"`
JellyfinDeviceID string `json:"jellyfinDeviceId,omitempty"` JellyfinDeviceID string `json:"jellyfinDeviceId,omitempty"`
JellyfinAuthToken string `json:"jellyfinAuthToken,omitempty"` JellyfinAuthToken string `json:"jellyfinAuthToken,omitempty"`
PlexToken string `json:"plexToken,omitempty"` PlexToken string `json:"plexToken,omitempty"`
Avatar string `json:"avatar,omitempty"` Avatar string `json:"avatar,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"` CreatedAt time.Time `json:"createdAt,omitempty"`
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,12 +105,16 @@ const (
type Notifications struct { type Notifications struct {
NotificationsTemplate NotificationsTemplate
PgpKey any `json:"pgpKey,omitempty"` NotificationsSettings
DiscordID string `json:"discordId,omitempty"` }
PushbulletAccessToken any `json:"pushbulletAccessToken,omitempty"`
PushoverApplicationToken any `json:"pushoverApplicationToken,omitempty"` type NotificationsSettings struct {
PushoverUserKey any `json:"pushoverUserKey,omitempty"` PgpKey any `json:"pgpKey,omitempty"`
TelegramChatID string `json:"telegramChatId,omitempty"` DiscordIDs []string `json:"discordIds,omitempty"`
PushbulletAccessToken any `json:"pushbulletAccessToken,omitempty"`
PushoverApplicationToken any `json:"pushoverApplicationToken,omitempty"`
PushoverUserKey any `json:"pushoverUserKey,omitempty"`
TelegramChatID string `json:"telegramChatId,omitempty"`
} }
type NotificationsTemplate struct { type NotificationsTemplate struct {
+4 -1
View File
@@ -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: "
+87 -46
View File
@@ -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,17 +54,35 @@ func (app *appContext) StartPWR() {
return return
} }
watcher, err := fsnotify.NewWatcher() usePolling := app.config.Section("password_resets").Key("watch_polling").MustBool(false)
if err != nil {
app.err.Printf(lm.FailedStartDaemon, "PWR", err) if !messagesEnabled {
return return
} }
defer watcher.Close() if usePolling {
watcher := pollingWatcher.New()
watcher.FilterOps(pollingWatcher.Write)
go pwrMonitor(app, watcher) go monitorPolling(app, watcher)
err = watcher.Add(path) if err := watcher.Add(path); err != nil {
if err != nil { app.err.Printf(lm.FailedStartDaemon, "PWR (polling)", err)
app.err.Printf(lm.FailedStartDaemon, "PWR", 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() 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
}
}
}
+17 -10
View File
@@ -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();
} }
if (isPageEventBindable(p)) p.bindPageEvents(); p.reload(() => {});
}), }
},
}; };
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,
+3
View File
@@ -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);
+7
View File
@@ -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>();
+4
View File
@@ -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
View File
@@ -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;
+19
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
} }
}; };
} }
+127
View File
@@ -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)),
];
}
+3
View File
@@ -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
View File
@@ -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;
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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>
`; `;
} }