Compare commits

...

8 Commits

Author SHA1 Message Date
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
24 changed files with 463 additions and 165 deletions

View File

@@ -1179,6 +1179,13 @@ sections:
type: text
value: /path/to/jellyfin
description: Path to the folder Jellyfin puts password-reset files.
- setting: watch_polling
name: Use polling
requires_restart: true
depends_true: watch_directory
type: bool
value: false
description: Use if mounting over network (NFS/SMB/SFTP). Watch the Jellyfin directory by checking periodically, rather than using OS APIs.
- setting: link_reset
name: Use reset link instead of PIN (Required for Ombi)
requires_restart: true

View File

@@ -1,71 +1,95 @@
.tooltip {
position: relative;
display: inline-block;
}
@layer components {
tool-tip {
position: relative;
display: inline-block;
}
.tooltip .content {
visibility: hidden;
opacity: 0;
max-width: 16rem;
min-width: 6rem;
background-color: rgba(0, 0, 0, 0.6);
color: #fff;
padding: 0.5rem;
border-radius: 6px;
overflow-wrap: break-word;
text-align: center;
transition: opacity 100ms;
tool-tip .content {
visibility: hidden;
opacity: 0;
width: max-content;
max-width: 16rem;
/*min-width: 6rem;*/
background-color: rgba(0, 0, 0, 0.6);
color: #fff;
padding: 0.5rem;
border-radius: 6px;
overflow-wrap: break-word;
text-align: center;
transition: opacity 100ms;
position: absolute;
z-index: 1;
top: -1rem;
}
position: absolute;
z-index: 1;
top: -1rem;
}
.tooltip.below .content {
top: calc(100% + 0.125rem);
left: 50%;
right: 0;
transform: translateX(-50%);
}
tool-tip.above {
--tooltip-position: above;
}
tool-tip.below {
--tooltip-position: below;
}
tool-tip.below-center {
--tooltip-position: below-center;
}
tool-tip.left {
--tooltip-position: left;
}
tool-tip.right {
--tooltip-position: right;
}
.tooltip.above .content {
top: unset;
bottom: calc(100% + 0.125rem);
left: 50%;
right: 0;
transform: translateX(-50%);
}
tool-tip.below .content {
top: calc(100% + 0.125rem);
left: 50%;
right: 0;
transform: translateX(-50%);
}
.tooltip.darker .content {
background-color: rgba(0, 0, 0, 0.8);
}
tool-tip.below-center .content {
top: calc(100% + 0.125rem);
max-width: calc(100vw - 4rem);
}
.tooltip.right .content {
left: 120%;
}
tool-tip.above .content {
top: unset;
bottom: calc(100% + 0.125rem);
left: 50%;
right: 0;
transform: translateX(-50%);
}
.tooltip.right:dir(rtl):not(.force-ltr) .content {
right: 120%;
left: unset;
}
tool-tip.darker .content {
background-color: rgba(0, 0, 0, 0.8);
}
.tooltip.left .content {
right: 120%;
}
tool-tip.right .content {
left: 0%;
}
.tooltip.left:dir(rtl):not(.force-ltr) .content {
left: 120%;
right: unset;
}
tool-tip.right:dir(rtl):not(.force-ltr) .content {
right: 0%;
left: unset;
}
.tooltip .content.sm {
font-size: 0.8rem;
}
tool-tip.left .content {
right: 0%;
}
.tooltip:hover .content,
.tooltip:focus .content,
.tooltip:focus-within .content
{
visibility: visible;
opacity: 1;
tool-tip.left:dir(rtl):not(.force-ltr) .content {
left: 0%;
right: unset;
}
tool-tip .content.sm {
font-size: 0.8rem;
}
/*tool-tip:hover .content,
tool-tip:focus .content,
tool-tip:focus-within .content*/
tool-tip.shown .content {
visibility: visible;
opacity: 1;
}
}

View File

@@ -88,6 +88,9 @@ func NewEmailer(config *Config, storage *Storage, logs LoggerSet) *Emailer {
config: config,
storage: storage,
}
if !strings.Contains(emailer.fromAddr, "@") {
emailer.err.Printf(lm.FailedInitMailer, "", fmt.Errorf(lm.InvalidFromAddress, emailer.fromAddr))
}
method := emailer.config.Section("email").Key("method").String()
if method == "smtp" {
enc := sMail.EncryptionSTARTTLS
@@ -107,7 +110,7 @@ func NewEmailer(config *Config, storage *Storage, logs LoggerSet) *Emailer {
authType := sMail.AuthType(emailer.config.Section("smtp").Key("auth_type").MustInt(4))
err := emailer.NewSMTP(emailer.config.Section("smtp").Key("server").String(), emailer.config.Section("smtp").Key("port").MustInt(465), username, password, enc, emailer.config.Section("smtp").Key("ssl_cert").MustString(""), emailer.config.Section("smtp").Key("hello_hostname").String(), emailer.config.Section("smtp").Key("cert_validation").MustBool(true), authType, emailer.config.proxyConfig)
if err != nil {
emailer.err.Printf(lm.FailedInitSMTP, err)
emailer.err.Printf(lm.FailedInitMailer, lm.SMTP, err)
}
} else if method == "mailgun" {
emailer.NewMailgun(emailer.config.Section("mailgun").Key("api_url").String(), emailer.config.Section("mailgun").Key("api_key").String(), emailer.config.proxyTransport)

1
go.mod
View File

@@ -109,6 +109,7 @@ require (
github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.57.0 // indirect
github.com/radovskyb/watcher v1.0.7 // indirect
github.com/rs/zerolog v1.34.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/swaggo/swag v1.16.6 // indirect

2
go.sum
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/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3AdqE=
github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
github.com/radovskyb/watcher v1.0.7 h1:AYePLih6dpmS32vlHfhCeli8127LzkIgwJGcwwe8tUE=
github.com/radovskyb/watcher v1.0.7/go.mod h1:78okwvY5wPdzcb1UYnip1pvrZNIVEIh/Cm+ZuvsUYIg=
github.com/robert-nix/ansihtml v1.0.1 h1:VTiyQ6/+AxSJoSSLsMecnkh8i0ZqOEdiRl/odOc64fc=
github.com/robert-nix/ansihtml v1.0.1/go.mod h1:CJwclxYaTPc2RfcxtanEACsYuTksh4yDXcNeHHKZINE=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=

View File

@@ -229,10 +229,10 @@
<label class="switch">
<input type="checkbox" id="expiry-use-previous">
<span>{{ .strings.extendFromPreviousExpiry }}</span>
<div class="tooltip left">
<tool-tip class="left">
<i class="icon ri-information-line align-middle"></i>
<div class="content sm w-max">{{ .strings.extendFromPreviousExpiryDescription }}</div>
</div>
</tool-tip>
</label>
</div>
<label class="switch">
@@ -547,7 +547,7 @@
</form>
</div>
<div id="notification-box"></div>
<div class="page-container m-2 lg:my-20 lg:mx-64 flex flex-col gap-4 overflow-x-hidden">
<div class="page-container m-2 lg:my-20 lg:mx-64 flex flex-col gap-4">
<div class="top-2 inset-x-2 lg:absolute flex flex-row justify-between">
<div class="flex flex-row gap-2">
{{ template "lang-select.html" . }}
@@ -718,13 +718,13 @@
<div class="flex flex-row align-middle w-full gap-2">
<input type="search" class="field ~neutral @low input search" id="accounts-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center inside-input rounded-s-none accounts-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<div class="tooltip left">
<tool-tip class="below">
<button class="button ~info @low center h-full accounts-search-server gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
<i class="ri-search-line"></i>
<span>{{ .strings.searchAll }}</span>
</button>
<span class="content sm">{{ .strings.searchAllRecords }}</span>
</div>
</tool-tip>
<button class="button ~info @low" id="accounts-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
</div>
</div>
@@ -828,13 +828,13 @@
<div class="flex flex-row align-middle w-full gap-2">
<input type="search" class="field ~neutral @low input search" id="activity-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center inside-input rounded-s-none activity-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<div class="tooltip left">
<tool-tip class="below">
<button class="button ~info @low center h-full activity-search-server gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
<i class="ri-search-line"></i>
<span>{{ .strings.searchAll }}</span>
</button>
<span class="content sm">{{ .strings.searchAllRecords }}</span>
</div>
</tool-tip>
<button class="button ~info @low" id="activity-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
</div>
</div>

View File

@@ -23,7 +23,7 @@
<body class="max-w-full overflow-x-hidden section">
<div id="modal-email" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2 modal-email-content">
<span class="heading"></span>
<label class="label flex flex-col gap-2">
<span class="supra">{{ .strings.emailAddress }}</span>

View File

@@ -256,8 +256,11 @@ const (
FailedGenerateDiscordInvite = "Failed to generate " + Discord + " invite: %v"
// email.go
FailedInitSMTP = "Failed to initialize SMTP mailer: %v"
SMTP = "SMTP"
Mailgun = "Mailgun"
FailedInitMailer = "Failed to initalize %s mailer: %v"
FailedGeneratePWRLink = "Failed to generate PWR link: %v"
InvalidFromAddress = "invalid from address: \"%s\""
// housekeeping-d.go
hk = "Housekeeping: "

View File

@@ -10,6 +10,12 @@ import (
"github.com/fsnotify/fsnotify"
lm "github.com/hrfee/jfa-go/logmessages"
pollingWatcher "github.com/radovskyb/watcher"
)
const (
RetryCount = 2
RetryInterval = time.Second
)
// GenInternalReset generates a local password reset PIN, for use with the PWR option on the Admin page.
@@ -48,17 +54,35 @@ func (app *appContext) StartPWR() {
return
}
watcher, err := fsnotify.NewWatcher()
if err != nil {
app.err.Printf(lm.FailedStartDaemon, "PWR", err)
usePolling := app.config.Section("password_resets").Key("watch_polling").MustBool(false)
if !messagesEnabled {
return
}
defer watcher.Close()
if usePolling {
watcher := pollingWatcher.New()
watcher.FilterOps(pollingWatcher.Write)
go pwrMonitor(app, watcher)
err = watcher.Add(path)
if err != nil {
app.err.Printf(lm.FailedStartDaemon, "PWR", err)
go monitorPolling(app, watcher)
if err := watcher.Add(path); err != nil {
app.err.Printf(lm.FailedStartDaemon, "PWR (polling)", err)
}
if err := watcher.Start(time.Second * 5); err != nil {
app.err.Printf(lm.FailedStartDaemon, "PWR (polling)", err)
}
} else {
watcher, err := fsnotify.NewWatcher()
if err != nil {
app.err.Printf(lm.FailedStartDaemon, "PWR", err)
return
}
defer watcher.Close()
go monitorFS(app, watcher)
err = watcher.Add(path)
if err != nil {
app.err.Printf(lm.FailedStartDaemon, "PWR", err)
}
}
waitForRestart()
@@ -72,52 +96,55 @@ type PasswordReset struct {
Internal bool `json:"Internal,omitempty"`
}
func pwrMonitor(app *appContext, watcher *fsnotify.Watcher) {
if !messagesEnabled {
func validatePWR(app *appContext, fname string, attempt int) {
currentTime := time.Now()
if !strings.Contains(fname, "passwordreset") {
return
}
var pwr PasswordReset
data, err := os.ReadFile(fname)
if err != nil {
app.debug.Printf(lm.FailedReading, fname, err)
return
}
err = json.Unmarshal(data, &pwr)
if len(pwr.Pin) == 0 || err != nil {
app.debug.Printf(lm.FailedReading, fname, err)
return
}
app.info.Printf(lm.NewPWRForUser, pwr.Username)
if pwr.Expiry.Before(currentTime) {
app.err.Printf(lm.PWRExpired, pwr.Username, pwr.Expiry)
return
}
user, err := app.jf.UserByName(pwr.Username, false)
if err != nil || user.ID == "" {
app.err.Printf(lm.FailedGetUser, pwr.Username, lm.Jellyfin, err)
return
}
name := app.getAddressOrName(user.ID)
if name != "" {
msg, err := app.email.constructReset(pwr, false)
if err != nil {
app.err.Printf(lm.FailedConstructPWRMessage, pwr.Username, err)
} else if err := app.sendByID(msg, user.ID); err != nil {
app.err.Printf(lm.FailedSendPWRMessage, pwr.Username, name, err)
} else {
app.err.Printf(lm.SentPWRMessage, pwr.Username, name)
}
}
}
func monitorFS(app *appContext, watcher *fsnotify.Watcher) {
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
if event.Op&fsnotify.Write == fsnotify.Write && strings.Contains(event.Name, "passwordreset") {
var pwr PasswordReset
data, err := os.ReadFile(event.Name)
if err != nil {
app.debug.Printf(lm.FailedReading, event.Name, err)
return
}
err = json.Unmarshal(data, &pwr)
if len(pwr.Pin) == 0 || err != nil {
app.debug.Printf(lm.FailedReading, event.Name, err)
continue
}
app.info.Printf(lm.NewPWRForUser, pwr.Username)
if currentTime := time.Now(); pwr.Expiry.After(currentTime) {
user, err := app.jf.UserByName(pwr.Username, false)
if err != nil || user.ID == "" {
app.err.Printf(lm.FailedGetUser, pwr.Username, lm.Jellyfin, err)
return
}
uid := user.ID
name := app.getAddressOrName(uid)
if name != "" {
msg, err := app.email.constructReset(pwr, false)
if err != nil {
app.err.Printf(lm.FailedConstructPWRMessage, pwr.Username, err)
} else if err := app.sendByID(msg, uid); err != nil {
app.err.Printf(lm.FailedSendPWRMessage, pwr.Username, name, err)
} else {
app.err.Printf(lm.SentPWRMessage, pwr.Username, name)
}
}
} else {
app.err.Printf(lm.PWRExpired, pwr.Username, pwr.Expiry)
}
if event.Has(fsnotify.Write) {
validatePWR(app, event.Name, 0)
}
case err, ok := <-watcher.Errors:
if !ok {
@@ -127,3 +154,17 @@ func pwrMonitor(app *appContext, watcher *fsnotify.Watcher) {
}
}
}
func monitorPolling(app *appContext, watcher *pollingWatcher.Watcher) {
for {
select {
case event := <-watcher.Event:
validatePWR(app, event.Path, 0)
case err := <-watcher.Error:
app.err.Printf(lm.FailedStartDaemon, "PWR (polling)", err)
return
case <-watcher.Closed:
return
}
}
}

View File

@@ -10,9 +10,12 @@ import { ProfileEditor, reloadProfileNames } from "./modules/profiles.js";
import { _get, _post, notificationBox, whichAnimationEvent, bindManualDropdowns } from "./modules/common.js";
import { Updater } from "./modules/update.js";
import { Login } from "./modules/login.js";
import { setupTooltips } from "./modules/ui.js";
declare var window: GlobalWindow;
setupTooltips();
const theme = new ThemeManager(document.getElementById("button-theme"));
window.lang = new lang(window.langFile as LangFile);
@@ -152,25 +155,29 @@ let navigated = false;
// load tabs
const tabs: { id: string; url: string; reloader: () => void; unloader?: () => void }[] = [];
[window.invites, accounts, activity, settings].forEach((p: AsTab) => {
let t: { id: string; url: string; reloader: () => void; unloader?: () => void } = {
let t: { id: string; url: string; reloader: (previous?: AsTab) => void; unloader?: () => void } = {
id: p.tabName,
url: p.pagePath,
reloader: () =>
p.reload(() => {
if (!navigated && isNavigatable(p)) {
if (p.isURL()) {
navigated = true;
p.navigate();
}
reloader: (previous: AsTab) => {
if (isPageEventBindable(p)) p.bindPageEvents();
if (!navigated && isNavigatable(p) && p.isURL()) {
navigated = true;
p.navigate();
} else {
if (navigated && previous && isNavigatable(previous)) {
// Clear the query param, as it was likely for a different page
previous.clearURL();
}
if (isPageEventBindable(p)) p.bindPageEvents();
}),
p.reload(() => {});
}
},
};
if (isPageEventBindable(p)) t.unloader = p.unbindPageEvents;
tabs.push(t);
window.tabs.addTab(
t.id,
window.pages.Base + window.pages.Admin + "/" + t.url,
p,
null,
t.reloader,
t.unloader || null,

View File

@@ -5,6 +5,7 @@ import { loadLangSelector } from "./modules/lang.js";
import { Validator, ValidatorConf, ValidatorRespDTO } from "./modules/validator.js";
import { Discord, Telegram, Matrix, ServiceConfiguration, MatrixConfiguration } from "./modules/account-linking.js";
import { Captcha, GreCAPTCHA } from "./modules/captcha.js";
import { setupTooltips } from "./modules/ui.js";
interface formWindow extends GlobalWindow {
invalidPassword: string;
@@ -42,6 +43,8 @@ interface formWindow extends GlobalWindow {
collectEmail: boolean;
}
setupTooltips();
loadLangSelector("form");
window.notifications = new notificationBox(document.getElementById("notification-box") as HTMLDivElement);

View File

@@ -1106,6 +1106,7 @@ export class accountsList extends PaginatedList implements Navigatable, AsTab {
itemsPerPage: 40,
maxItemsLoadedForSearch: 200,
appendNewItems: (resp: PaginatedDTO) => {
// console.log("append");
for (let u of (resp as UsersDTO).users || []) {
if (this.users.has(u.id)) {
this.users.get(u.id).update(u);
@@ -1121,6 +1122,7 @@ export class accountsList extends PaginatedList implements Navigatable, AsTab {
);
},
replaceWithNewItems: (resp: PaginatedDTO) => {
// console.log("replace");
let accountsOnDOM = new Map<string, boolean>();
for (let id of this.users.keys()) {
@@ -2514,6 +2516,10 @@ export class accountsList extends PaginatedList implements Navigatable, AsTab {
if (details) this.details(details);
});
};
clearURL() {
this._search.clearURL();
}
}
// 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.computeScrollInfo();
},
replaceWithNewItems: (resp: PaginatedDTO) => {
let entriesOnDOM = new Map<string, boolean>();

View File

@@ -708,4 +708,8 @@ export class activityList extends PaginatedList implements Navigatable, AsTab {
}
this._search.navigate(urlParams.toString());
};
clearURL() {
this._search.clearURL();
}
}

View File

@@ -13,7 +13,7 @@ import {
} from "../modules/common.js";
import { DiscordSearch, DiscordUser, newDiscordSearch } from "../modules/discord.js";
import { reloadProfileNames } from "../modules/profiles.js";
import { HiddenInputField, RadioBasedTabSelector } from "./ui.js";
import { HiddenInputField, RadioBasedTabSelector, Tooltip } from "./ui.js";
declare var window: GlobalWindow;
@@ -205,10 +205,9 @@ class DOMInvite implements Invite {
}
set send_to(address: string | null) {
this._send_to = address;
const container = this._infoArea.querySelector(".tooltip") as HTMLDivElement;
const container = this._infoArea.querySelector("tool-tip") as Tooltip;
const icon = container.querySelector("i");
const chip = container.querySelector("span.inv-email-chip");
const tooltip = container.querySelector("span.content") as HTMLSpanElement;
if (!address) {
icon.classList.remove("ri-mail-line");
icon.classList.remove("ri-mail-close-line");
@@ -232,7 +231,7 @@ class DOMInvite implements Invite {
}
}
// innerHTML as the newer sent_to re-uses this with HTML.
tooltip.innerHTML = address;
container.content.innerHTML = address;
}
private _sendToDialog: SendToDialog;
private _sent_to: SentToList;
@@ -603,10 +602,10 @@ class DOMInvite implements Invite {
this._header.appendChild(this._infoArea);
this._infoArea.classList.add("inv-infoarea", "flex", "flex-row", "items-center", "gap-2");
this._infoArea.innerHTML = `
<div class="tooltip below darker" tabindex="0">
<tool-tip class="below darker" tabindex="0">
<span class="inv-email-chip h-full"><i></i></span>
<span class="content sm p-1"></span>
</div>
</tool-tip>
<span class="button ~critical @low inv-delete h-full">${window.lang.strings("delete")}</span>
<label>
<i class="icon px-2.5 py-2 ri-arrow-down-s-line text-xl not-rotated"></i>
@@ -818,6 +817,14 @@ export class DOMInviteList implements InviteList {
this.focusInvite(inviteCode, window.lang.notif("errorInviteNotFound"));
};
clearURL() {
const url = new URL(window.location.href);
if (!url.searchParams.has("invite")) return;
url.searchParams.delete("invite");
console.log("pushing", url.toString());
window.history.pushState(null, "", url.toString());
}
constructor() {
this._list = document.getElementById("invites") as HTMLDivElement;
this.empty = true;

View File

@@ -359,6 +359,8 @@ export abstract class PaginatedList implements PageEventBindable {
return bottomIdx;
};
private _loadLock: boolean = false;
private _loadQueue: (() => void)[] = [];
private _load = (
itemLimit: number,
page: number,
@@ -367,7 +369,17 @@ export abstract class PaginatedList implements PageEventBindable {
post?: (resp: PaginatedDTO) => void,
failCallback?: (req: XMLHttpRequest) => void,
) => {
if (this._loadLock) {
console.debug("Queuing load, position:", this._loadQueue.length);
const now = Date.now();
this._loadQueue.push(() => {
console.debug("Queued load running, appended at:", now);
this._load(itemLimit, page, appendFunc, pre, post, failCallback);
});
return;
}
this._lastLoad = Date.now();
this._loadLock = true;
let params = this._search.inServerSearch ? this._searchParams : this.defaultParams();
params.limit = itemLimit;
params.page = page;
@@ -397,9 +409,14 @@ export abstract class PaginatedList implements PageEventBindable {
this._counter.loaded = this._search.ordering.length;
this._loadLock = false;
if (post) post(resp);
if (this._c.pageLoadCallback) this._c.pageLoadCallback(req);
const next = this._loadQueue.shift();
if (next) next();
},
true,
);
@@ -408,6 +425,7 @@ export abstract class PaginatedList implements PageEventBindable {
// Removes all elements, and reloads the first page.
public abstract reload: (callback?: (resp: PaginatedDTO) => void) => void;
protected _reload = (callback?: (resp: PaginatedDTO) => void) => {
console.trace("reloading");
this.lastPage = false;
this._counter.reset();
this._counter.getTotal(
@@ -511,6 +529,7 @@ export abstract class PaginatedList implements PageEventBindable {
this._scroll.lastScrollY = scrollY;
// If you've scrolled back up, do nothing
if (scrollSpeed < 0) return;
if (this._scroll.rowHeight == 0) this.computeScrollInfo();
let endIdx = this.maximumItemsToRender(scrollY);
// Throttling this function means we might not catch up in time if the user scrolls fast,

View File

@@ -552,6 +552,7 @@ export class Search implements Navigatable {
return this._ascending;
}
// FIXME: This is being called by navigate, and triggering a "load more" when we haven't loaded at all, and loading without a searchc when we have one!
onSearchBoxChange = (
newItems: boolean = false,
appendedItems: boolean = false,
@@ -721,6 +722,19 @@ export class Search implements Navigatable {
return req;
};
private _qps: URLSearchParams = new URLSearchParams();
private _clearWithoutNavigate = false;
// clearQueryParam removes the "search" query parameter --without-- triggering a navigate call.
clearQueryParam = () => {
if (!this._qps.has("search")) return;
this._clearWithoutNavigate = true;
this.setQueryParam("");
};
clearURL() {
this.clearQueryParam();
}
// setQueryParam sets the ?search query param to the current searchbox content,
// or value if given. If everything is set up correctly, this should trigger a search when it is
// set to a new value.
@@ -737,7 +751,7 @@ export class Search implements Navigatable {
triggerManually = !url.searchParams.has("search");
url.searchParams.delete("search");
}
console.log("pushing", url.toString());
// console.log("pushing", url.toString());
window.history.pushState(null, "", url.toString());
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 = (url?: string, then?: () => void) => {
(window as any).s = this;
const urlParams = new URLSearchParams(url || window.location.search);
const searchContent = urlParams.get("search") || "";
console.log("navigate!, setting search box to ", searchContent);
this._qps = new URLSearchParams(url || window.location.search);
if (this._clearWithoutNavigate) {
this._clearWithoutNavigate = false;
return;
}
const searchContent = this._qps.get("search") || "";
this._c.search.value = searchContent;
this.onSearchBoxChange();
this.onServerSearch(then);
@@ -772,6 +789,7 @@ export class Search implements Navigatable {
this._c.search.oninput = () => {
this.inServerSearch = false;
this.clearQueryParam();
this.onSearchBoxChange();
};
this._c.search.addEventListener("keyup", (ev: KeyboardEvent) => {

View File

@@ -14,6 +14,7 @@ import {
} from "../modules/common.js";
import { Marked } from "@ts-stack/markdown";
import { stripMarkdown } from "../modules/stripmd.js";
import { Tooltip } from "./ui.js";
declare var window: GlobalWindow;
@@ -109,7 +110,7 @@ class DOMSetting {
protected _hideEl: HTMLElement;
protected _input: HTMLInputElement;
protected _container: HTMLDivElement;
protected _tooltip: HTMLDivElement;
protected _tooltip: Tooltip;
protected _required: HTMLSpanElement;
protected _restart: HTMLSpanElement;
protected _advanced: boolean;
@@ -127,7 +128,6 @@ class DOMSetting {
this._hideEl.classList.remove("unfocused");
}
document.dispatchEvent(changedEvent(this._section, this.setting, this.valueAsString(), v));
console.log(`dispatched settings-${this._section}-${this.setting} = ${this.valueAsString()}/${v}`);
}
private _advancedListener = (event: advancedEvent) => {
@@ -154,11 +154,10 @@ class DOMSetting {
}
get description(): string {
return this._tooltip.querySelector("span.content").textContent;
return this._tooltip.content.textContent;
}
set description(d: string) {
const content = this._tooltip.querySelector("span.content") as HTMLSpanElement;
content.textContent = d;
this._tooltip.content.textContent = d;
if (d == "") {
this._tooltip.classList.add("unfocused");
} else {
@@ -248,17 +247,17 @@ class DOMSetting {
${inputOnTop ? input : ""}
<div class="flex flex-row gap-2 items-baseline">
<span class="setting-label"></span>
<div class="setting-tooltip tooltip right unfocused">
<tool-tip class="setting-tooltip below-center sm:right unfocused">
<i class="icon ri-information-line align-[-0.05rem]"></i>
<span class="content sm"></span>
</div>
</tool-tip>
<span class="setting-required unfocused"></span>
<span class="setting-restart unfocused"></span>
</div>
${inputOnTop ? "" : input}
</label>
`;
this._tooltip = this._container.querySelector("div.setting-tooltip") as HTMLDivElement;
this._tooltip = this._container.querySelector("tool-tip.setting-tooltip") as Tooltip;
this._required = this._container.querySelector("span.setting-required") as HTMLSpanElement;
this._restart = this._container.querySelector("span.setting-restart") as HTMLSpanElement;
// "input" variable should supply the HTML of an element with class "setting-input"
@@ -1406,8 +1405,8 @@ export class settingsList implements AsTab {
// Create (restart)required badges (can't do on load as window.lang is unset)
RestartRequiredBadge = (() => {
const rr = document.createElement("span");
rr.classList.add("tooltip", "below", "force-ltr");
const rr = document.createElement("tool-tip") as Tooltip;
rr.classList.add("below", "force-ltr");
rr.innerHTML = `
<span class="badge ~info dark:~d_warning align-[0.08rem]"><i class="icon ri-refresh-line h-full"></i></span>
<span class="content sm">${window.lang.strings("restartRequired")}</span>
@@ -1416,8 +1415,8 @@ export class settingsList implements AsTab {
return rr;
})();
RequiredBadge = (() => {
const r = document.createElement("span");
r.classList.add("tooltip", "below", "force-ltr");
const r = document.createElement("tool-tip");
r.classList.add("below", "force-ltr");
r.innerHTML = `
<span class="badge ~critical align-[0.08rem]"><i class="icon ri-asterisk h-full"></i></span>
<span class="content sm">${window.lang.strings("required")}</span>
@@ -1490,8 +1489,8 @@ export class settingsList implements AsTab {
this._sections[section.section].update(section);
} else {
if (section.section == "messages" || section.section == "user_page") {
const editButton = document.createElement("div");
editButton.classList.add("tooltip", "left", "h-full", "force-ltr");
const editButton = document.createElement("tool-tip");
editButton.classList.add("left", "h-full", "force-ltr");
editButton.innerHTML = `
<span class="button ~neutral @low h-full">
<i class="icon ri-edit-line"></i>
@@ -1528,8 +1527,8 @@ export class settingsList implements AsTab {
}
this.addSection(section.section, section, icon);
} else if (section.section == "matrix" && !window.matrixEnabled) {
const addButton = document.createElement("div");
addButton.classList.add("tooltip", "left", "h-full", "force-ltr");
const addButton = document.createElement("tool-tip");
addButton.classList.add("left", "h-full", "force-ltr");
addButton.innerHTML = `
<span class="button ~neutral h-full"><i class="icon ri-links-line"></i></span>
<span class="content sm">
@@ -1912,10 +1911,10 @@ class MessageEditor {
`;
if (this._names[id].description != "")
innerHTML += `
<div class="tooltip right">
<tool-tip class="right">
<i class="icon ri-information-line"></i>
<span class="content sm">${this._names[id].description}</span>
</div>
</tool-tip>
`;
innerHTML += `
</td>

View File

@@ -1,4 +1,4 @@
import { PageManager } from "../modules/pages.js";
import { PageManager } from "./pages";
export function isPageEventBindable(object: any): object is PageEventBindable {
return "bindPageEvents" in object;
@@ -12,7 +12,7 @@ export class TabManager implements TabManager {
private _current: string = "";
private _baseOffset = -1;
tabs: Map<string, Tab>;
pages: PageManager;
pages: Pages;
constructor() {
this.tabs = new Map<string, Tab>();
@@ -26,14 +26,16 @@ export class TabManager implements TabManager {
addTab = (
tabID: string,
url: string,
preFunc = () => void {},
postFunc = () => void {},
contentObject: AsTab | null,
preFunc: (previous?: AsTab) => void = (_?: AsTab) => void {},
postFunc: (previous?: AsTab) => void = (_?: AsTab) => void {},
unloadFunc = () => void {},
) => {
let tab: Tab = {
page: null,
tabEl: document.getElementById("tab-" + tabID) as HTMLDivElement,
buttonEl: document.getElementById("button-tab-" + tabID) as HTMLButtonElement,
contentObject: contentObject,
preFunc: preFunc,
postFunc: postFunc,
};
@@ -91,14 +93,16 @@ export class TabManager implements TabManager {
[t] = this.tabs.values();
}
const prev = this.tabs.get(this.current);
this._current = t.page.name;
if (t.preFunc && !noRun) {
t.preFunc();
t.preFunc(prev?.contentObject);
}
this.pages.load(tabID);
if (t.postFunc && !noRun) {
t.postFunc();
t.postFunc(prev?.contentObject);
}
};
}

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)),
];
}

View File

@@ -1,7 +1,10 @@
import { toClipboard, notificationBox } from "./modules/common.js";
import { setupTooltips } from "./modules/ui.js";
declare var window: GlobalWindow;
setupTooltips();
const pin = document.getElementById("pin") as HTMLSpanElement;
if (pin) {

View File

@@ -3,6 +3,7 @@ import { Validator, ValidatorConf } from "./modules/validator.js";
import { _post, addLoader, removeLoader } from "./modules/common.js";
import { loadLangSelector } from "./modules/lang.js";
import { Captcha, GreCAPTCHA } from "./modules/captcha.js";
import { setupTooltips } from "./modules/ui.js";
interface formWindow extends Window {
invalidPassword: string;
@@ -39,6 +40,8 @@ loadLangSelector("pwr");
declare var window: formWindow;
setupTooltips();
const form = document.getElementById("form-create") as HTMLFormElement;
const submitInput = form.querySelector("input[type=submit]") as HTMLInputElement;
const submitSpan = form.querySelector("span.submit") as HTMLSpanElement;

View File

@@ -2,6 +2,7 @@ import { _get, _post, toggleLoader, notificationBox } from "./modules/common.js"
import { lang, LangFile, loadLangSelector } from "./modules/lang.js";
import { ThemeManager } from "./modules/theme.js";
import { PageManager } from "./modules/pages.js";
import { setupTooltips } from "./modules/ui.js";
interface sWindow extends GlobalWindow {
messages: {};
@@ -9,6 +10,8 @@ interface sWindow extends GlobalWindow {
declare var window: sWindow;
setupTooltips();
const theme = new ThemeManager(document.getElementById("button-theme"));
window.notifications = new notificationBox(document.getElementById("notification-box") as HTMLDivElement, 5);

View File

@@ -75,6 +75,8 @@ declare interface AsTab {
declare interface Navigatable {
// isURL will return whether the given url (or the current page url if not passed) is a valid link to some resource(s) in the class.
isURL(url?: string): boolean;
// clearURL will remove related query params from the current URL. It will likely be called when switching pages.
clearURL(): void;
// navigate will load and focus the resource(s) in the class referenced by the given url (or current page url if not passed).
navigate(url?: string): void;
}
@@ -196,14 +198,22 @@ declare interface Tab {
page: Page;
tabEl: HTMLDivElement;
buttonEl: HTMLSpanElement;
preFunc?: () => void;
postFunc?: () => void;
contentObject?: AsTab;
preFunc?: (previous?: AsTab) => void;
postFunc?: (previous?: AsTab) => void;
}
declare interface Tabs {
tabs: Map<string, Tab>;
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;
switch(tabID: string, noRun?: boolean): void;
}

View File

@@ -17,6 +17,7 @@ import { Discord, Telegram, Matrix, ServiceConfiguration, MatrixConfiguration }
import { Validator, ValidatorConf, ValidatorRespDTO } from "./modules/validator.js";
import { PageManager } from "./modules/pages.js";
import { generateCodeLink } from "./modules/invites.js";
import { setupTooltips } from "./modules/ui.js";
interface userWindow extends GlobalWindow {
jellyfinID: string;
@@ -34,6 +35,8 @@ interface userWindow extends GlobalWindow {
declare var window: userWindow;
setupTooltips();
// const basePath = window.location.pathname.replace("/password/reset", "");
const basePath = window.pages.Base + window.pages.MyAccount;
@@ -531,7 +534,7 @@ const addEditEmail = (add: boolean): void => {
const confirmationRequired = window.modals.email.modal.querySelector(".confirmation-required");
confirmationRequired.classList.add("unfocused");
const content = window.modals.email.modal.querySelector(".content");
const content = window.modals.email.modal.querySelector(".modal-email-content");
content.classList.remove("unfocused");
const submit = window.modals.email.modal.querySelector(".card").children[0] as HTMLButtonElement;
@@ -757,7 +760,7 @@ document.addEventListener("details-reload", () => {
}
if (!messageCard.textContent) {
messageCard.innerHTML = `
<span class="heading mb-2">${window.lang.strings("customMessagePlaceholderHeader")} </span>
<span class="heading mb-2">${window.lang.strings("customMessagePlaceholderHeader")} ✏ </span>
<span class="block">${window.lang.strings("customMessagePlaceholderContent")}</span>
`;
}