Compare commits

...

16 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
Harvey Tindall
c10f1e3b36 list/search/accounts: fully(?) page-based search
searches are triggered by setting the search QP, and ran by a listener
set on PageManager. same with details. Works surprisingly well, but i'm
sure theres bugs.
2025-12-25 13:04:03 +00:00
Harvey Tindall
3308739619 admin: tab system improvement, search: ?search qp
tab content classes (e.g. settingsList, activityList)
can implement "AsTab", "Navigatable" and or "PageEventBindable",
the first giving them a tab name, a subpath and a reloader function,
the second an "isURL" and "navigate" function for loading resources,
the last giving them bind/unbindPageEvent methods. These are looped
through in ts/admin.ts still crudely, maybe tabs.ts could accept "AsTab"
implementers directly.

"Search" class now has a ?search query param which just encodes the
search box content, set when you perform a server search (hit enter or
press the button). ?user queries from the accounts or activity tab will
be converted to this form on loading.
2025-12-24 12:56:02 +00:00
Harvey Tindall
748acc13c0 accounts: show table row when clicking into details, more usage of
RadioBasedTabs

when clicking a username in the accounts tab, the details comes up with
a copy of the row you clicked, so you can view logs and edit the user.
Also decided not to use a RadioBasedTab here, instead providing a link
to the activity tab with a search for the user (added the ability to do
that, ?user=username). FIXME: handle deleting the user or just generally
editing them.
2025-12-23 20:37:29 +00:00
Harvey Tindall
c3bac6c51c ui: select by index, id over name, buttonHTML on RadioBasedTabSelector
pass a string (new "id" field) or number to RadioBasedTabSelector.selected = x.
setting an "id" for a tab is optional, if not set it's value defaults to the "name" value.
Also added optional buttonHTML to put inside the button insetad of the
given name.
2025-12-23 15:12:44 +00:00
Harvey Tindall
3e39657642 ui: add RadioBasedTabs
does the "tabs" seen on the invite tab (invite expiry/user expiry), and
will be used in all other similar cases.
2025-12-21 19:55:49 +00:00
Harvey Tindall
41334e9051 api-users: add individual user summary route
GET /users/:id, skips the usercache.
2025-12-21 19:19:46 +00:00
Harvey Tindall
e2c34e574d jf_activity: ui changes
just committing so I can pull on another device.
2025-12-21 19:07:03 +00:00
Harvey Tindall
b6459b665d jf_activity: paginated list in ui
added POST route for pagination to activity route, a count route, and
modified Search and PaginatedList a bit to support lists without search
fields (essentially just running an empty search). Visible by clicking
on a user's name in the accounts tab.
2025-12-20 18:27:39 +00:00
33 changed files with 1756 additions and 646 deletions

View File

@@ -1022,6 +1022,37 @@ func (app *appContext) GetLabels(gc *gin.Context) {
gc.JSON(200, LabelsDTO{Labels: app.userCache.Labels})
}
// @Summary Get the details of a given user. Skips the user cache and gets fresh information.
// @Produce json
// @Success 200 {object} respUser
// @Failure 400 {object} boolResponse
// @Failure 500 {object} stringResponse
// @Param id path string true "id of user to fetch details of"
// @Router /users/{id} [get]
// @Security Bearer
// @tags Users
func (app *appContext) GetUser(gc *gin.Context) {
userID := gc.Param("id")
if userID == "" {
respondBool(400, false, gc)
return
}
user, err := app.jf.UserByID(userID, false)
if err != nil {
switch err.(type) {
case mediabrowser.ErrUserNotFound:
respondBool(400, false, gc)
app.err.Printf(lm.FailedGetUser, userID, lm.Jellyfin, err)
return
default:
respond(500, "Check logs", gc)
app.err.Printf(lm.FailedGetUser, userID, lm.Jellyfin, err)
return
}
}
gc.JSON(200, app.GetUserSummary(user))
}
// @Summary Get a list of -all- Jellyfin users.
// @Produce json
// @Success 200 {object} getUsersDTO
@@ -1420,7 +1451,30 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
gc.JSON(code, errors)
}
// @Summary Get the latest Jellyfin/Emby activities related to the given user ID. Returns as many as the server has recorded.
// @Summary Gets the number of Jellyfin/Emby activities stored by jfa-go related to the given user ID. As the total collected by jfa-go is limited, this may not include all those held by Jellyfin.
// @Produce json
// @Success 200 {object} PageCountDTO
// @Failure 400 {object} boolResponse
// @Param id path string true "id of user to fetch activities of."
// @Router /users/{id}/activities/jellyfin/count [get]
// @Security Bearer
// @tags Users
func (app *appContext) CountJFActivitesForUser(gc *gin.Context) {
userID := gc.Param("id")
if userID == "" {
respondBool(400, false, gc)
return
}
activities, err := app.jf.activity.ByUserID(userID)
if err != nil {
app.err.Printf(lm.FailedGetJFActivities, err)
respondBool(400, false, gc)
return
}
gc.JSON(200, PageCountDTO{Count: uint64(len(activities))})
}
// @Summary Get the latest Jellyfin/Emby activities related to the given user ID. Returns as many as the server has recorded. As the total collected by jfa-go is limited, this may not include all those held by Jellyfin.
// @Produce json
// @Success 200 {object} ActivityLogEntriesDTO
// @Failure 400 {object} boolResponse
@@ -1429,12 +1483,12 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
// @Security Bearer
// @tags Users
func (app *appContext) GetJFActivitesForUser(gc *gin.Context) {
userId := gc.Param("id")
if userId == "" {
userID := gc.Param("id")
if userID == "" {
respondBool(400, false, gc)
return
}
activities, err := app.jf.activity.ByUserID(userId)
activities, err := app.jf.activity.ByUserID(userID)
if err != nil {
app.err.Printf(lm.FailedGetJFActivities, err)
respondBool(400, false, gc)
@@ -1450,3 +1504,46 @@ func (app *appContext) GetJFActivitesForUser(gc *gin.Context) {
app.debug.Printf(lm.GotNEntries, len(activities))
gc.JSON(200, out)
}
// @Summary Get the latest Jellyfin/Emby activities related to the given user ID, paginated. As the total collected by jfa-go is limited, this may not include all those held by Jellyfin.
// @Produce json
// @Param PaginatedReqDTO body PaginatedReqDTO true "pagination parameters"
// @Success 200 {object} PaginatedActivityLogEntriesDTO
// @Failure 400 {object} boolResponse
// @Param id path string true "id of user to fetch activities of."
// @Router /users/{id}/activities/jellyfin [post]
// @Security Bearer
// @tags Users
func (app *appContext) GetPaginatedJFActivitesForUser(gc *gin.Context) {
var req PaginatedReqDTO
gc.BindJSON(&req)
userID := gc.Param("id")
if userID == "" {
respondBool(400, false, gc)
return
}
activities, err := app.jf.activity.ByUserID(userID)
if err != nil {
app.err.Printf(lm.FailedGetJFActivities, err)
respondBool(400, false, gc)
return
}
out := PaginatedActivityLogEntriesDTO{}
startIndex := req.Page * req.Limit
if startIndex >= len(activities) {
out.LastPage = true
gc.JSON(200, out)
return
}
endIndex := min(startIndex+req.Limit, len(activities))
activities = activities[startIndex:endIndex]
out.Entries = make([]ActivityLogEntryDTO, len(activities))
out.LastPage = len(activities) != req.Limit
for i := range activities {
out.Entries[i].ActivityLogEntry = activities[i]
out.Entries[i].Date = activities[i].Date.Unix()
}
app.debug.Printf(lm.GotNEntries, len(activities))
gc.JSON(200, out)
}

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)

3
go.mod
View File

@@ -42,7 +42,7 @@ require (
github.com/hrfee/jfa-go/logger v0.0.0-20251123165523-7c9f91711460
github.com/hrfee/jfa-go/logmessages v0.0.0-20251123165523-7c9f91711460
github.com/hrfee/jfa-go/ombi v0.0.0-20251123165523-7c9f91711460
github.com/hrfee/mediabrowser v0.3.35
github.com/hrfee/mediabrowser v0.3.36
github.com/hrfee/simple-template v1.1.0
github.com/itchyny/timefmt-go v0.1.7
github.com/lithammer/shortuuid/v3 v3.0.7
@@ -109,6 +109,7 @@ require (
github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.57.0 // indirect
github.com/radovskyb/watcher v1.0.7 // indirect
github.com/rs/zerolog v1.34.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/swaggo/swag v1.16.6 // indirect

4
go.sum
View File

@@ -200,6 +200,8 @@ github.com/hrfee/mediabrowser v0.3.34 h1:AKnd1V9wt+KWZmHDjj1GMkCgcgcpBKxPw5iUcYg
github.com/hrfee/mediabrowser v0.3.34/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
github.com/hrfee/mediabrowser v0.3.35 h1:xEq4cL96Di0G+S3ONBH1HHeQJU6IfUMZiaeGeuJSFS8=
github.com/hrfee/mediabrowser v0.3.35/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
github.com/hrfee/mediabrowser v0.3.36 h1:erYWzmaz4b6wfxEfeEWQHLMI9fSpGDy1m7NxkQmIVsw=
github.com/hrfee/mediabrowser v0.3.36/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
github.com/hrfee/simple-template v1.1.0 h1:PNQDTgc2H0s19/pWuhRh4bncuNJjPrW0fIX77YtY78M=
github.com/hrfee/simple-template v1.1.0/go.mod h1:s9a5QgfqbmT7j9WCC3GD5JuEqvihBEohyr+oYZmr4bA=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
@@ -277,6 +279,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3AdqE=
github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
github.com/radovskyb/watcher v1.0.7 h1:AYePLih6dpmS32vlHfhCeli8127LzkIgwJGcwwe8tUE=
github.com/radovskyb/watcher v1.0.7/go.mod h1:78okwvY5wPdzcb1UYnip1pvrZNIVEIh/Cm+ZuvsUYIg=
github.com/robert-nix/ansihtml v1.0.1 h1:VTiyQ6/+AxSJoSSLsMecnkh8i0ZqOEdiRl/odOc64fc=
github.com/robert-nix/ansihtml v1.0.1/go.mod h1:CJwclxYaTPc2RfcxtanEACsYuTksh4yDXcNeHHKZINE=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=

View File

@@ -0,0 +1,24 @@
<thead id="accounts-table-header">
<tr>
<th><input type="checkbox" value="" id="accounts-select-all"></th>
<th class="table-inline my-2 grid gap-4 place-items-stretch accounts-header-username">{{ .strings.username }}</th>
{{ if .jellyfinLogin }}
<th class="text-center-i grid gap-4 place-items-stretch accounts-header-access-jfa">{{ .strings.accessJFA }}</th>
{{ end }}
<th class="grid gap-4 place-items-stretch accounts-header-email">{{ .strings.emailAddress }}</th>
{{ if .telegramEnabled }}
<th class="text-center-i grid gap-4 place-items-stretch accounts-header-telegram">Telegram</th>
{{ end }}
{{ if .matrixEnabled }}
<th class="text-center-i grid gap-4 place-items-stretch accounts-header-matrix">Matrix</th>
{{ end }}
{{ if .discordEnabled }}
<th class="text-center-i grid gap-4 place-items-stretch accounts-header-discord">Discord</th>
{{ end }}
{{ if .referralsEnabled }}
<th class="text-center-i grid gap-4 place-items-stretch accounts-header-referrals">{{ .strings.referrals }}</th>
{{ end }}
<th class="grid gap-4 place-items-stretch accounts-header-expiry">{{ .strings.expiry }}</th>
<th class="grid gap-4 place-items-stretch accounts-header-last-active">{{ .strings.lastActiveTime }}</th>
</tr>
</thead>

View File

@@ -86,16 +86,7 @@
<span class="heading"><span id="header-modify-user"></span> <span class="modal-close">&times;</span></span>
<p class="content">{{ .strings.modifySettingsDescription }}</p>
<div class="flex flex-col gap-4">
<div class="flex flex-row gap-2">
<label class="grow">
<input type="radio" name="modify-user-source" class="unfocused" id="radio-use-profile" checked>
<span class="button ~neutral @high supra full-width center">{{ .strings.profile }}</span>
</label>
<label class="grow">
<input type="radio" name="modify-user-source" class="unfocused" id="radio-use-user">
<span class="button ~neutral @low supra full-width center">{{ .strings.user }}</span>
</label>
</div>
<div id="modify-user-profile-source"></div>
<div class="select ~neutral @low">
<select id="modify-user-profiles"></select>
</div>
@@ -134,16 +125,7 @@
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 flex flex-col gap-4" id="form-enable-referrals-user" href="">
<span class="heading"><span id="header-enable-referrals-user"></span> <span class="modal-close">&times;</span></span>
<p class="content">{{ .strings.enableReferralsDescription }}</p>
<div class="flex flex-row gap-2">
<label class="grow">
<input type="radio" name="enable-referrals-user-source" class="unfocused" id="radio-referrals-use-profile" checked>
<span class="button ~neutral @high supra full-width center">{{ .strings.profile }}</span>
</label>
<label class="grow">
<input type="radio" name="enable-referrals-user-source" class="unfocused" id="radio-referrals-use-invite">
<span class="button ~neutral @low supra full-width center">{{ .strings.invite }}</span>
</label>
</div>
<div id="enable-referrals-user-source"></div>
<div class="select ~neutral @low">
<select id="enable-referrals-user-profiles"></select>
</div>
@@ -247,10 +229,10 @@
<label class="switch">
<input type="checkbox" id="expiry-use-previous">
<span>{{ .strings.extendFromPreviousExpiry }}</span>
<div class="tooltip left">
<tool-tip class="left">
<i class="icon ri-information-line align-middle"></i>
<div class="content sm w-max">{{ .strings.extendFromPreviousExpiryDescription }}</div>
</div>
</tool-tip>
</label>
</div>
<label class="switch">
@@ -565,7 +547,7 @@
</form>
</div>
<div id="notification-box"></div>
<div class="page-container m-2 lg:my-20 lg:mx-64 flex flex-col gap-4 overflow-x-hidden">
<div class="page-container m-2 lg:my-20 lg:mx-64 flex flex-col gap-4">
<div class="top-2 inset-x-2 lg:absolute flex flex-row justify-between">
<div class="flex flex-row gap-2">
{{ template "lang-select.html" . }}
@@ -597,16 +579,7 @@
<span class="heading">{{ .strings.create }}</span>
<div class="flex flex-col md:flex-row gap-3" id="create-inv">
<div class="card ~neutral @low flex flex-col gap-2 flex-1">
<div class="flex flex-row gap-2">
<label class="w-1/2">
<input type="radio" name="duration" class="unfocused" id="radio-inv-duration" checked>
<span class="button ~neutral @high supra full-width center">{{ .strings.inviteDuration }}</span>
</label>
<label class="w-1/2">
<input type="radio" name="duration" class="unfocused" id="radio-user-expiry">
<span class="button ~neutral @low supra full-width center">{{ .strings.userExpiry }}</span>
</label>
</div>
<div id="invites-duration-type-tabs"></div>
<div id="inv-duration" class="flex flex-col gap-2">
<div class="flex flex-row gap-2">
<div class="grow flex flex-col gap-4">
@@ -745,13 +718,13 @@
<div class="flex flex-row align-middle w-full gap-2">
<input type="search" class="field ~neutral @low input search" id="accounts-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center inside-input rounded-s-none accounts-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<div class="tooltip left">
<tool-tip class="below">
<button class="button ~info @low center h-full accounts-search-server gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
<i class="ri-search-line"></i>
<span>{{ .strings.searchAll }}</span>
</button>
<span class="content sm">{{ .strings.searchAllRecords }}</span>
</div>
</tool-tip>
<button class="button ~info @low" id="accounts-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
</div>
</div>
@@ -771,6 +744,7 @@
</div>
<div class="supra sm">{{ .strings.actions }}</div>
<div class="flex flex-row flex-wrap gap-3">
<button type="button" title="{{ .strings.back }}" class="button ~neutral @low inline-flex gap-1 unfocused" id="user-details-back" aria-label="{{ .strings.back }}"><i class="icon ri-arrow-left-fill"></i>{{ .strings.back }}</button>
<button class="button ~neutral @low center accounts-load-all">{{ .strings.loadAll }}</button>
<span class="button ~neutral @low center " id="accounts-add-user">{{ .quantityStrings.addUser.Singular }}</span>
<div id="accounts-announce-dropdown" class="dropdown pb-0i " tabindex="0">
@@ -806,54 +780,36 @@
<span class="button ~info @low center unfocused " id="accounts-send-pwr">{{ .strings.sendPWR }}</span>
<span class="button ~critical @low center " id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span>
</div>
<div class="card @low accounts-header overflow-x-scroll">
<table class="table text-base leading-5">
<thead>
<tr>
<th><input type="checkbox" value="" id="accounts-select-all"></th>
<th class="table-inline my-2 grid gap-4 place-items-stretch accounts-header-username">{{ .strings.username }}</th>
{{ if .jellyfinLogin }}
<th class="text-center-i grid gap-4 place-items-stretch accounts-header-access-jfa">{{ .strings.accessJFA }}</th>
{{ end }}
<th class="grid gap-4 place-items-stretch accounts-header-email">{{ .strings.emailAddress }}</th>
{{ if .telegramEnabled }}
<th class="text-center-i grid gap-4 place-items-stretch accounts-header-telegram">Telegram</th>
{{ end }}
{{ if .matrixEnabled }}
<th class="text-center-i grid gap-4 place-items-stretch accounts-header-matrix">Matrix</th>
{{ end }}
{{ if .discordEnabled }}
<th class="text-center-i grid gap-4 place-items-stretch accounts-header-discord">Discord</th>
{{ end }}
{{ if .referralsEnabled }}
<th class="text-center-i grid gap-4 place-items-stretch accounts-header-referrals">{{ .strings.referrals }}</th>
{{ end }}
<th class="grid gap-4 place-items-stretch accounts-header-expiry">{{ .strings.expiry }}</th>
<th class="grid gap-4 place-items-stretch accounts-header-last-active">{{ .strings.lastActiveTime }}</th>
</tr>
</thead>
<tbody id="accounts-list"></tbody>
</table>
<div id="accounts-loader"></div>
<div class="unfocused h-[100%] my-3" id="accounts-not-found">
<div class="flex flex-col gap-2 h-[100%] justify-center items-center">
<span class="text-2xl font-medium italic text-center">{{ .strings.noResultsFound }}</span>
<span class="text-sm font-light italic unfocused text-center" id="accounts-no-local-results">{{ .strings.noResultsFoundLocally }}</span>
<div class="flex flex-row">
<button class="button ~neutral @low accounts-search-clear gap-1">
<i class="ri-close-line"></i>
<span>{{ .strings.clearSearch }}</span>
</button>
<div id="accounts-table-details-container">
<div class="card @low accounts-details transition-opacity transition-display unfocused">
<div id="accounts-loader"></div>
</div>
<div class="card @low accounts-header overflow-x-scroll transition-opacity transition-display" id="accounts-table">
<table class="table text-base leading-5">
{{ template "accounts-table-header.txt" . }}
<tbody id="accounts-list"></tbody>
</table>
<div id="accounts-loader"></div>
<div class="unfocused h-[100%] my-3" id="accounts-not-found">
<div class="flex flex-col gap-2 h-[100%] justify-center items-center">
<span class="text-2xl font-medium italic text-center">{{ .strings.noResultsFound }}</span>
<span class="text-sm font-light italic unfocused text-center" id="accounts-no-local-results">{{ .strings.noResultsFoundLocally }}</span>
<div class="flex flex-row">
<button class="button ~neutral @low accounts-search-clear gap-1">
<i class="ri-close-line"></i>
<span>{{ .strings.clearSearch }}</span>
</button>
</div>
</div>
</div>
</div>
<div class="flex flex-row gap-2 my-3 justify-center">
<button class="button ~neutral @low" id="accounts-load-more">{{ .strings.loadMore }}</button>
<button class="button ~neutral @low accounts-load-all">{{ .strings.loadAll }}</button>
<button class="button ~info @low center accounts-search-server gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
<i class="ri-search-line"></i>
<span>{{ .strings.searchAllRecords }}</span>
</button>
<div class="flex flex-row gap-2 my-3 justify-center">
<button class="button ~neutral @low" id="accounts-load-more">{{ .strings.loadMore }}</button>
<button class="button ~neutral @low accounts-load-all">{{ .strings.loadAll }}</button>
<button class="button ~info @low center accounts-search-server gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
<i class="ri-search-line"></i>
<span>{{ .strings.searchAllRecords }}</span>
</button>
</div>
</div>
</div>
</div>
@@ -872,13 +828,13 @@
<div class="flex flex-row align-middle w-full gap-2">
<input type="search" class="field ~neutral @low input search" id="activity-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center inside-input rounded-s-none activity-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<div class="tooltip left">
<tool-tip class="below">
<button class="button ~info @low center h-full activity-search-server gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
<i class="ri-search-line"></i>
<span>{{ .strings.searchAll }}</span>
</button>
<span class="content sm">{{ .strings.searchAllRecords }}</span>
</div>
</tool-tip>
<button class="button ~info @low" id="activity-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
</div>
</div>

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>
@@ -75,7 +75,7 @@
<span class="button ~warning h-min" alt="{{ .strings.theme }}" id="button-theme"><i class="ri-sun-line"></i></span>
<span class="button ~critical @low unfocused" id="logout-button">{{ .strings.logout }}</span>
</div>
<a class="button ~info unfocused h-min flex flex-row gap-2" href="/" id="admin-back-button"><i class="ri-arrow-left-fill"></i>{{ .strings.admin }}</a>
<a class="button ~info unfocused h-min flex flex-row gap-1" href="/" id="admin-back-button"><i class="ri-arrow-left-fill"></i>{{ .strings.admin }}</a>
</div>
<div class="card @low dark:~d_neutral" id="card-user">
<span class="heading flex flex-row gap-4"></span>

View File

@@ -7,6 +7,8 @@
"invite": "Invite",
"accounts": "Accounts",
"activity": "Activity",
"activityFromJF": "Activity (from Jellyfin)",
"activityFromJfa": "Activity (from jfa-go)",
"settings": "Settings",
"inviteMonths": "Months",
"inviteDays": "Days",
@@ -63,6 +65,8 @@
"disabled": "Disabled",
"run": "Run",
"sendPWR": "Send Password Reset",
"noActivityFound": "No activity found",
"noActivityFoundLocally": "If this account hasn't been used in a while, you might find older entries on Jellyfin directly.",
"noResultsFound": "No Results Found",
"noResultsFoundLocally": "Only loaded records were searched. You can load more, or perform the search over all records on the server.",
"keepSearching": "Keep Searching",
@@ -224,7 +228,13 @@
"restartRequired": "Restart required",
"required": "Required",
"syntax": "Syntax",
"syntaxDescription": "Variables denoted as {variable}. If statements can evaluate truthfulness (e.g. {ifTruth}) or make basic comparisons (e.g. {ifCompare})"
"syntaxDescription": "Variables denoted as {variable}. If statements can evaluate truthfulness (e.g. {ifTruth}) or make basic comparisons (e.g. {ifCompare})",
"info": "Info",
"debug": "Debug",
"warn": "Warn",
"error": "Error",
"fatal": "Fatal",
"severity": "Severity"
},
"notifications": {
"pathCopied": "Full path copied to clipboard.",

View File

@@ -46,7 +46,12 @@
"inviteRemainingUses": "Remaining uses",
"internal": "Internal",
"external": "External",
"failed": "Failed"
"failed": "Failed",
"details": "Details",
"type": "Type",
"other": "Other",
"back": "Back",
"none": "None"
},
"notifications": {
"errorLoginBlank": "The username and/or password were left blank.",

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

@@ -174,15 +174,31 @@ type respUser struct {
ReferralsEnabled bool `json:"referrals_enabled"`
}
// ServerSearchReqDTO is a usual SortablePaginatedReqDTO with added fields for searching and filtering.
type ServerSearchReqDTO struct {
SortablePaginatedReqDTO
ServerFilterReqDTO
}
// ServerFilterReqDTO provides search terms and queries to a search or count route.
type ServerFilterReqDTO struct {
SearchTerms []string `json:"searchTerms"`
Queries []QueryDTO `json:"queries"`
}
type PaginatedDTO struct {
LastPage bool `json:"last_page"`
}
type PaginatedReqDTO struct {
Limit int `json:"limit"`
Page int `json:"page"` // zero-indexed
type SortablePaginatedReqDTO struct {
SortByField string `json:"sortByField"`
Ascending bool `json:"ascending"`
PaginatedReqDTO
}
type PaginatedReqDTO struct {
Limit int `json:"limit"`
Page int `json:"page"` // zero-indexed
}
type getUsersDTO struct {
@@ -520,6 +536,11 @@ type ActivityLogEntriesDTO struct {
Entries []ActivityLogEntryDTO `json:"entries"`
}
type PaginatedActivityLogEntriesDTO struct {
ActivityLogEntriesDTO
PaginatedDTO
}
type ActivityLogEntryDTO struct {
mediabrowser.ActivityLogEntry
Date int64 `json:"Date"`

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

@@ -206,7 +206,10 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
api.POST(p+"/user", app.NewUserFromAdmin)
api.POST(p+"/users/extend", app.ExtendExpiry)
api.DELETE(p+"/users/:id/expiry", app.RemoveExpiry)
api.GET(p+"/users/:id", app.GetUser)
api.GET(p+"/users/:id/activities/jellyfin", app.GetJFActivitesForUser)
api.GET(p+"/users/:id/activities/jellyfin/count", app.CountJFActivitesForUser)
api.POST(p+"/users/:id/activities/jellyfin", app.GetPaginatedJFActivitesForUser)
api.POST(p+"/users/enable", app.EnableDisableUsers)
api.POST(p+"/invites", app.GenerateInvite)
api.GET(p+"/invites", app.GetInvites)

View File

@@ -1,7 +1,7 @@
import { ThemeManager } from "./modules/theme.js";
import { lang, LangFile, loadLangSelector } from "./modules/lang.js";
import { Modal } from "./modules/modal.js";
import { Tabs, Tab } from "./modules/tabs.js";
import { TabManager, isPageEventBindable, isNavigatable } from "./modules/tabs.js";
import { DOMInviteList, createInvite } from "./modules/invites.js";
import { accountsList } from "./modules/accounts.js";
import { settingsList } from "./modules/settings.js";
@@ -10,9 +10,12 @@ import { ProfileEditor, reloadProfileNames } from "./modules/profiles.js";
import { _get, _post, notificationBox, whichAnimationEvent, bindManualDropdowns } from "./modules/common.js";
import { Updater } from "./modules/update.js";
import { Login } from "./modules/login.js";
import { setupTooltips } from "./modules/ui.js";
declare var window: GlobalWindow;
setupTooltips();
const theme = new ThemeManager(document.getElementById("button-theme"));
window.lang = new lang(window.langFile as LangFile);
@@ -129,6 +132,9 @@ window.availableProfiles = window.availableProfiles || [];
});
});*/
// tab content objects will register with this independently, so initialise now
window.tabs = new TabManager();
var inviteCreator = new createInvite();
var accounts = new accountsList();
@@ -143,80 +149,40 @@ var profiles = new ProfileEditor();
window.notifications = new notificationBox(document.getElementById("notification-box") as HTMLDivElement, 5);
/*const modifySettingsSource = function () {
const profile = document.getElementById('radio-use-profile') as HTMLInputElement;
const user = document.getElementById('radio-use-user') as HTMLInputElement;
const profileSelect = document.getElementById('modify-user-profiles') as HTMLDivElement;
const userSelect = document.getElementById('modify-user-users') as HTMLDivElement;
(user.nextElementSibling as HTMLSpanElement).classList.toggle('@low');
(user.nextElementSibling as HTMLSpanElement).classList.toggle('@high');
(profile.nextElementSibling as HTMLSpanElement).classList.toggle('@low');
(profile.nextElementSibling as HTMLSpanElement).classList.toggle('@high');
profileSelect.classList.toggle('unfocused');
userSelect.classList.toggle('unfocused');
}*/
// Determine if url references an invite or account
let isInviteURL = window.invites.isInviteURL();
let isAccountURL = accounts.isAccountURL();
// only use a navigatable URL once
let navigated = false;
// load tabs
const tabs: { id: string; url: string; reloader: () => void; unloader?: () => void }[] = [
{
id: "invites",
url: "",
reloader: () =>
window.invites.reload(() => {
if (isInviteURL) {
window.invites.loadInviteURL();
// Don't keep loading the same item on every tab refresh
isInviteURL = false;
const tabs: { id: string; url: string; reloader: () => void; unloader?: () => void }[] = [];
[window.invites, accounts, activity, settings].forEach((p: AsTab) => {
let t: { id: string; url: string; reloader: (previous?: AsTab) => void; unloader?: () => void } = {
id: p.tabName,
url: p.pagePath,
reloader: (previous: AsTab) => {
if (isPageEventBindable(p)) p.bindPageEvents();
if (!navigated && isNavigatable(p) && p.isURL()) {
navigated = true;
p.navigate();
} else {
if (navigated && previous && isNavigatable(previous)) {
// Clear the query param, as it was likely for a different page
previous.clearURL();
}
}),
},
{
id: "accounts",
url: "accounts",
reloader: () =>
accounts.reload(() => {
if (isAccountURL) {
accounts.loadAccountURL();
// Don't keep loading the same item on every tab refresh
isAccountURL = false;
}
accounts.bindPageEvents();
}),
unloader: accounts.unbindPageEvents,
},
{
id: "activity",
url: "activity",
reloader: () => {
activity.reload();
activity.bindPageEvents();
p.reload(() => {});
}
},
unloader: activity.unbindPageEvents,
},
{
id: "settings",
url: "settings",
reloader: settings.reload,
},
];
const defaultTab = tabs[0];
window.tabs = new Tabs();
for (let tab of tabs) {
};
if (isPageEventBindable(p)) t.unloader = p.unbindPageEvents;
tabs.push(t);
window.tabs.addTab(
tab.id,
window.pages.Base + window.pages.Admin + "/" + tab.url,
t.id,
window.pages.Base + window.pages.Admin + "/" + t.url,
p,
null,
tab.reloader,
tab.unloader || null,
t.reloader,
t.unloader || null,
);
}
});
let matchedTab = false;
for (const tab of tabs) {

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);

File diff suppressed because it is too large Load Diff

View File

@@ -554,18 +554,20 @@ interface ActivitiesReqDTO extends PaginatedReqDTO {
type: string[];
}
interface ActivitiesDTO extends paginatedDTO {
interface ActivitiesDTO extends PaginatedDTO {
activities: activity[];
}
export class activityList extends PaginatedList {
export class activityList extends PaginatedList implements Navigatable, AsTab {
readonly tabName = "activity";
readonly pagePath = "activity";
protected _container: HTMLElement;
protected _sortDirection = document.getElementById("activity-sort-direction") as HTMLButtonElement;
protected _ascending: boolean;
get activities(): { [id: string]: Activity } {
return this._search.items as { [id: string]: Activity };
get activities(): Map<string, Activity> {
return this._search.items as Map<string, Activity>;
}
// set activities(v: { [id: string]: Activity }) { this._search.items = v as SearchableItems; }
@@ -587,21 +589,19 @@ export class activityList extends PaginatedList {
getPageEndpoint: "/activity",
itemsPerPage: 20,
maxItemsLoadedForSearch: 200,
appendNewItems: (resp: paginatedDTO) => {
appendNewItems: (resp: PaginatedDTO) => {
let ordering: string[] = this._search.ordering;
for (let act of (resp as ActivitiesDTO).activities || []) {
this.activities[act.id] = new Activity(act);
this.activities.set(act.id, new Activity(act));
ordering.push(act.id);
}
this._search.setOrdering(ordering, this._c.defaultSortField, this.ascending);
},
replaceWithNewItems: (resp: paginatedDTO) => {
replaceWithNewItems: (resp: PaginatedDTO) => {
// FIXME: Implement updates to existing elements, rather than just wiping each time.
// Remove existing items
for (let id of Object.keys(this.activities)) {
delete this.activities[id];
}
this.activities.clear();
// And wipe their ordering
this._search.setOrdering([], this._c.defaultSortField, this.ascending);
this._c.appendNewItems(resp);
@@ -645,7 +645,7 @@ export class activityList extends PaginatedList {
this._sortDirection.addEventListener("click", () => (this.ascending = !this.ascending));
}
reload = (callback?: (resp: paginatedDTO) => void) => {
reload = (callback?: (resp: PaginatedDTO) => void) => {
this._reload(callback);
};
@@ -653,7 +653,7 @@ export class activityList extends PaginatedList {
this._loadMore(loadAll, callback);
};
loadAll = (callback?: (resp?: paginatedDTO) => void) => {
loadAll = (callback?: (resp?: PaginatedDTO) => void) => {
this._loadAll(callback);
};
@@ -689,4 +689,27 @@ export class activityList extends PaginatedList {
this._keepSearchingDescription.classList.add("unfocused");
}
};*/
isURL = (url?: string) => {
const urlParams = new URLSearchParams(url || window.location.search);
const username = urlParams.get("user");
return Boolean(username) || this._search.isURL(url);
};
navigate = (url?: string) => {
const urlParams = new URLSearchParams(url || window.location.search);
const username = urlParams.get("user");
let search = urlParams.get("search") || "";
if (username) {
search = `user:"${username}" ` + search;
urlParams.set("search", search);
// Get rid of it, as it'll now be included in the "search" param anyway
urlParams.delete("user");
}
this._search.navigate(urlParams.toString());
};
clearURL() {
this._search.clearURL();
}
}

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 } from "./ui.js";
import { HiddenInputField, RadioBasedTabSelector, Tooltip } from "./ui.js";
declare var window: GlobalWindow;
@@ -205,10 +205,9 @@ class DOMInvite implements Invite {
}
set send_to(address: string | null) {
this._send_to = address;
const container = this._infoArea.querySelector(".tooltip") as HTMLDivElement;
const container = this._infoArea.querySelector("tool-tip") as Tooltip;
const icon = container.querySelector("i");
const chip = container.querySelector("span.inv-email-chip");
const tooltip = container.querySelector("span.content") as HTMLSpanElement;
if (!address) {
icon.classList.remove("ri-mail-line");
icon.classList.remove("ri-mail-close-line");
@@ -232,7 +231,7 @@ class DOMInvite implements Invite {
}
}
// innerHTML as the newer sent_to re-uses this with HTML.
tooltip.innerHTML = address;
container.content.innerHTML = address;
}
private _sendToDialog: SendToDialog;
private _sent_to: SentToList;
@@ -603,10 +602,10 @@ class DOMInvite implements Invite {
this._header.appendChild(this._infoArea);
this._infoArea.classList.add("inv-infoarea", "flex", "flex-row", "items-center", "gap-2");
this._infoArea.innerHTML = `
<div class="tooltip below darker" tabindex="0">
<tool-tip class="below darker" tabindex="0">
<span class="inv-email-chip h-full"><i></i></span>
<span class="content sm p-1"></span>
</div>
</tool-tip>
<span class="button ~critical @low inv-delete h-full">${window.lang.strings("delete")}</span>
<label>
<i class="icon px-2.5 py-2 ri-arrow-down-s-line text-xl not-rotated"></i>
@@ -784,6 +783,8 @@ class DOMInvite implements Invite {
}
export class DOMInviteList implements InviteList {
readonly tabName = "invites";
readonly pagePath = "";
private _list: HTMLDivElement;
private _empty: boolean;
// since invite reload sends profiles, this event it broadcast so the createInvite object can load them.
@@ -804,18 +805,26 @@ export class DOMInviteList implements InviteList {
this.focusInvite(event.detail);
});
isInviteURL = () => {
const urlParams = new URLSearchParams(window.location.search);
isURL = (url?: string) => {
const urlParams = new URLSearchParams(url || window.location.search);
const inviteCode = urlParams.get("invite");
return Boolean(inviteCode);
};
loadInviteURL = () => {
const urlParams = new URLSearchParams(window.location.search);
navigate = (url?: string) => {
const urlParams = new URLSearchParams(url || window.location.search);
const inviteCode = urlParams.get("invite");
this.focusInvite(inviteCode, window.lang.notif("errorInviteNotFound"));
};
clearURL() {
const url = new URL(window.location.href);
if (!url.searchParams.has("invite")) return;
url.searchParams.delete("invite");
console.log("pushing", url.toString());
window.history.pushState(null, "", url.toString());
}
constructor() {
this._list = document.getElementById("invites") as HTMLDivElement;
this.empty = true;
@@ -945,8 +954,8 @@ export class createInvite {
private _userHours = document.getElementById("user-hours") as HTMLSelectElement;
private _userMinutes = document.getElementById("user-minutes") as HTMLSelectElement;
private _invDurationButton = document.getElementById("radio-inv-duration") as HTMLInputElement;
private _userExpiryButton = document.getElementById("radio-user-expiry") as HTMLInputElement;
// private _invDurationButton = document.getElementById("radio-inv-duration") as HTMLInputElement;
// private _userExpiryButton = document.getElementById("radio-user-expiry") as HTMLInputElement;
private _invDuration = document.getElementById("inv-duration");
private _userExpiry = document.getElementById("user-expiry");
@@ -1191,30 +1200,18 @@ export class createInvite {
this.uses = 1;
this.label = "";
const checkDuration = () => {
const invSpan = this._invDurationButton.nextElementSibling as HTMLSpanElement;
const userSpan = this._userExpiryButton.nextElementSibling as HTMLSpanElement;
if (this._invDurationButton.checked) {
this._invDuration.classList.remove("unfocused");
this._userExpiry.classList.add("unfocused");
invSpan.classList.add("@high");
invSpan.classList.remove("@low");
userSpan.classList.add("@low");
userSpan.classList.remove("@high");
} else if (this._userExpiryButton.checked) {
this._userExpiry.classList.remove("unfocused");
this._invDuration.classList.add("unfocused");
invSpan.classList.add("@low");
invSpan.classList.remove("@high");
userSpan.classList.add("@high");
userSpan.classList.remove("@low");
}
};
this._userExpiryButton.checked = false;
this._invDurationButton.checked = true;
this._userExpiryButton.onchange = checkDuration;
this._invDurationButton.onchange = checkDuration;
new RadioBasedTabSelector(
document.getElementById("invites-duration-type-tabs"),
"invites-duration-type",
{
name: window.lang.strings("inviteDuration"),
content: this._invDuration,
},
{
name: window.lang.strings("userExpiry"),
content: this._userExpiry,
},
);
this._days.onchange = this._checkDurationValidity;
this._months.onchange = this._checkDurationValidity;

View File

@@ -18,18 +18,20 @@ export class RecordCounter {
private _loaded: number;
private _shown: number;
private _selected: number;
constructor(container: HTMLElement) {
this._container = container;
this._container.innerHTML = `
constructor(container?: HTMLElement) {
if (container) {
this._container = container;
this._container.innerHTML = `
<span class="records-total"></span>
<span class="records-loaded"></span>
<span class="records-shown"></span>
<span class="records-selected"></span>
`;
this._totalRecords = this._container.getElementsByClassName("records-total")[0] as HTMLElement;
this._loadedRecords = this._container.getElementsByClassName("records-loaded")[0] as HTMLElement;
this._shownRecords = this._container.getElementsByClassName("records-shown")[0] as HTMLElement;
this._selectedRecords = this._container.getElementsByClassName("records-selected")[0] as HTMLElement;
this._totalRecords = this._container.getElementsByClassName("records-total")[0] as HTMLElement;
this._loadedRecords = this._container.getElementsByClassName("records-loaded")[0] as HTMLElement;
this._shownRecords = this._container.getElementsByClassName("records-shown")[0] as HTMLElement;
this._selectedRecords = this._container.getElementsByClassName("records-selected")[0] as HTMLElement;
}
this.total = 0;
this.loaded = 0;
this.shown = 0;
@@ -55,7 +57,7 @@ export class RecordCounter {
}
set total(v: number) {
this._total = v;
this._totalRecords.textContent = window.lang.var("strings", "totalRecords", `${v}`);
if (this._totalRecords) this._totalRecords.textContent = window.lang.var("strings", "totalRecords", `${v}`);
}
get loaded(): number {
@@ -63,7 +65,7 @@ export class RecordCounter {
}
set loaded(v: number) {
this._loaded = v;
this._loadedRecords.textContent = window.lang.var("strings", "loadedRecords", `${v}`);
if (this._loadedRecords) this._loadedRecords.textContent = window.lang.var("strings", "loadedRecords", `${v}`);
}
get shown(): number {
@@ -71,7 +73,7 @@ export class RecordCounter {
}
set shown(v: number) {
this._shown = v;
this._shownRecords.textContent = window.lang.var("strings", "shownRecords", `${v}`);
if (this._shownRecords) this._shownRecords.textContent = window.lang.var("strings", "shownRecords", `${v}`);
}
get selected(): number {
@@ -79,8 +81,10 @@ export class RecordCounter {
}
set selected(v: number) {
this._selected = v;
if (v == 0) this._selectedRecords.textContent = ``;
else this._selectedRecords.textContent = window.lang.var("strings", "selectedRecords", `${v}`);
if (this._selectedRecords) {
if (v == 0) this._selectedRecords.textContent = ``;
else this._selectedRecords.textContent = window.lang.var("strings", "selectedRecords", `${v}`);
}
}
}
@@ -88,23 +92,25 @@ export interface PaginatedListConfig {
loader: HTMLElement;
loadMoreButtons: Array<HTMLButtonElement>;
loadAllButtons: Array<HTMLButtonElement>;
refreshButton: HTMLButtonElement;
filterArea: HTMLElement;
searchOptionsHeader: HTMLElement;
searchBox: HTMLInputElement;
recordCounter: HTMLElement;
totalEndpoint: string;
getPageEndpoint: string;
refreshButton?: HTMLButtonElement;
filterArea?: HTMLElement;
searchOptionsHeader?: HTMLElement;
searchBox?: HTMLInputElement;
recordCounter?: HTMLElement;
totalEndpoint: string | (() => string);
getPageEndpoint: string | (() => string);
itemsPerPage: number;
maxItemsLoadedForSearch: number;
appendNewItems: (resp: paginatedDTO) => void;
replaceWithNewItems: (resp: paginatedDTO) => void;
defaultSortField: string;
defaultSortAscending: boolean;
appendNewItems: (resp: PaginatedDTO) => void;
replaceWithNewItems: (resp: PaginatedDTO) => void;
defaultSortField?: string;
defaultSortAscending?: boolean;
pageLoadCallback?: (req: XMLHttpRequest) => void;
disableSearch?: boolean;
hideButtonsOnLastPage?: boolean;
}
export abstract class PaginatedList {
export abstract class PaginatedList implements PageEventBindable {
protected _c: PaginatedListConfig;
// Container to append items to.
@@ -143,11 +149,17 @@ export abstract class PaginatedList {
this._c.loadAllButtons.forEach((v) => v.classList.add("unfocused"));
this._c.loadMoreButtons.forEach((v) => {
v.textContent = window.lang.strings("noMoreResults");
if (this._c.hideButtonsOnLastPage) {
v.classList.add("unfocused");
}
v.disabled = true;
});
} else {
this._c.loadMoreButtons.forEach((v) => {
v.textContent = window.lang.strings("loadMore");
if (this._c.hideButtonsOnLastPage) {
v.classList.remove("unfocused");
}
v.disabled = false;
});
this._c.loadAllButtons.forEach((v) => v.classList.remove("unfocused"));
@@ -186,10 +198,11 @@ export abstract class PaginatedList {
this.loadMore(() => removeLoader(this._keepSearchingButton, true));
}; */
// Since this.reload doesn't exist, we need an arrow function to wrap it.
this._c.refreshButton.onclick = () => this.reload();
if (this._c.refreshButton) this._c.refreshButton.onclick = () => this.reload();
}
autoSetServerSearchButtonsDisabled = () => {
if (!this._search) return;
const serverSearchSortChanged =
this._search.inServerSearch &&
(this._searchParams.sortByField != this._search.sortField ||
@@ -218,7 +231,7 @@ export abstract class PaginatedList {
searchConfig.onSearchCallback = (
newItems: boolean,
loadAll: boolean,
callback?: (resp: paginatedDTO) => void,
callback?: (resp: PaginatedDTO) => void,
) => {
// if (this._search.inSearch && !this.lastPage) this._c.loadAllButton.classList.remove("unfocused");
// else this._c.loadAllButton.classList.add("unfocused");
@@ -245,12 +258,12 @@ export abstract class PaginatedList {
if (previousCallback) previousCallback(newItems, loadAll);
};
const previousServerSearch = searchConfig.searchServer;
searchConfig.searchServer = (params: PaginatedReqDTO, newSearch: boolean) => {
searchConfig.searchServer = (params: PaginatedReqDTO, newSearch: boolean, then?: () => void) => {
this._searchParams = params;
if (newSearch) this.reload();
else this.loadMore(false);
if (newSearch) this.reload(then);
else this.loadMore(false, then);
if (previousServerSearch) previousServerSearch(params, newSearch);
if (previousServerSearch) previousServerSearch(params, newSearch, then);
};
searchConfig.clearServerSearch = () => {
console.trace("Clearing server search");
@@ -259,7 +272,7 @@ export abstract class PaginatedList {
};
searchConfig.setVisibility = this.setVisibility;
this._search = new Search(searchConfig);
this._search.generateFilterList();
if (!this._c.disableSearch) this._search.generateFilterList();
this.lastPage = false;
};
@@ -270,7 +283,7 @@ export abstract class PaginatedList {
// else this._visible = this._search.ordering.filter(v => !elements.includes(v));
// const frag = document.createDocumentFragment()
// for (let i = 0; i < this._visible.length; i++) {
// frag.appendChild(this._search.items[this._visible[i]].asElement())
// frag.appendChild(this._search.items.get(this._visible[i]).asElement())
// }
// this._container.replaceChildren(frag);
// if (this._search.timeSearches) {
@@ -295,7 +308,7 @@ export abstract class PaginatedList {
if (!appendedItems) {
// Wipe old elements and render 1 new one, so we can take the element height.
this._container.replaceChildren(this._search.items[this._visible[0]].asElement());
this._container.replaceChildren(this._search.items.get(this._visible[0]).asElement());
}
this._computeScrollInfo();
@@ -317,7 +330,7 @@ export abstract class PaginatedList {
}
const frag = document.createDocumentFragment();
for (let i = baseIndex; i < this._scroll.initialRenderCount; i++) {
frag.appendChild(this._search.items[this._visible[i]].asElement());
frag.appendChild(this._search.items.get(this._visible[i]).asElement());
}
this._scroll.rendered = Math.max(baseIndex, this._scroll.initialRenderCount);
// appendChild over replaceChildren because there's already elements on the DOM
@@ -335,7 +348,7 @@ export abstract class PaginatedList {
this._scroll.screenHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0);
this._scroll.rowHeight = this._search.items[this._visible[0]].asElement().offsetHeight;
this._scroll.rowHeight = this._search.items.get(this._visible[0]).asElement().offsetHeight;
};
// returns the item index to render up to for the given scroll position.
@@ -346,15 +359,27 @@ export abstract class PaginatedList {
return bottomIdx;
};
private _loadLock: boolean = false;
private _loadQueue: (() => void)[] = [];
private _load = (
itemLimit: number,
page: number,
appendFunc: (resp: paginatedDTO) => void, // Function to append/put items in storage.
pre?: (resp: paginatedDTO) => void,
post?: (resp: paginatedDTO) => void,
appendFunc: (resp: PaginatedDTO) => void, // Function to append/put items in storage.
pre?: (resp: PaginatedDTO) => void,
post?: (resp: PaginatedDTO) => void,
failCallback?: (req: XMLHttpRequest) => void,
) => {
if (this._loadLock) {
console.debug("Queuing load, position:", this._loadQueue.length);
const now = Date.now();
this._loadQueue.push(() => {
console.debug("Queued load running, appended at:", now);
this._load(itemLimit, page, appendFunc, pre, post, failCallback);
});
return;
}
this._lastLoad = Date.now();
this._loadLock = true;
let params = this._search.inServerSearch ? this._searchParams : this.defaultParams();
params.limit = itemLimit;
params.page = page;
@@ -364,7 +389,7 @@ export abstract class PaginatedList {
}
_post(
this._c.getPageEndpoint,
typeof this._c.getPageEndpoint === "string" ? this._c.getPageEndpoint : this._c.getPageEndpoint(),
params,
(req: XMLHttpRequest) => {
if (req.readyState != 4) return;
@@ -375,7 +400,7 @@ export abstract class PaginatedList {
}
this._hasLoaded = true;
let resp = req.response as paginatedDTO;
let resp = req.response as PaginatedDTO;
if (pre) pre(resp);
this.lastPage = resp.last_page;
@@ -384,20 +409,28 @@ export abstract class PaginatedList {
this._counter.loaded = this._search.ordering.length;
this._loadLock = false;
if (post) post(resp);
if (this._c.pageLoadCallback) this._c.pageLoadCallback(req);
const next = this._loadQueue.shift();
if (next) next();
},
true,
);
};
// Removes all elements, and reloads the first page.
public abstract reload: (callback?: (resp: paginatedDTO) => void) => void;
protected _reload = (callback?: (resp: paginatedDTO) => void) => {
public abstract reload: (callback?: (resp: PaginatedDTO) => void) => void;
protected _reload = (callback?: (resp: PaginatedDTO) => void) => {
console.trace("reloading");
this.lastPage = false;
this._counter.reset();
this._counter.getTotal(this._c.totalEndpoint);
this._counter.getTotal(
typeof this._c.totalEndpoint === "string" ? this._c.totalEndpoint : this._c.totalEndpoint(),
);
// Reload all currently visible elements, i.e. Load a new page of size (limit*(page+1)).
let limit = this._c.itemsPerPage;
if (this._page != 0) {
@@ -407,12 +440,14 @@ export abstract class PaginatedList {
limit,
0,
this._c.replaceWithNewItems,
(_0: paginatedDTO) => {
(_0: PaginatedDTO) => {
// Allow refreshes every 15s
this._c.refreshButton.disabled = true;
setTimeout(() => (this._c.refreshButton.disabled = false), 15000);
if (this._c.refreshButton) {
this._c.refreshButton.disabled = true;
setTimeout(() => (this._c.refreshButton.disabled = false), 15000);
}
},
(resp: paginatedDTO) => {
(resp: PaginatedDTO) => {
this._search.onSearchBoxChange(true, false, false);
if (this._search.inSearch) {
// this._c.loadAllButton.classList.remove("unfocused");
@@ -427,8 +462,8 @@ export abstract class PaginatedList {
};
// Loads the next page. If "loadAll", all pages will be loaded until the last is reached.
public abstract loadMore: (loadAll?: boolean, callback?: (resp?: paginatedDTO) => void) => void;
protected _loadMore = (loadAll: boolean = false, callback?: (resp: paginatedDTO) => void) => {
public abstract loadMore: (loadAll?: boolean, callback?: (resp?: PaginatedDTO) => void) => void;
protected _loadMore = (loadAll: boolean = false, callback?: (resp: PaginatedDTO) => void) => {
this._c.loadMoreButtons.forEach((v) => (v.disabled = true));
const timeout = setTimeout(() => {
this._c.loadMoreButtons.forEach((v) => (v.disabled = false));
@@ -439,14 +474,14 @@ export abstract class PaginatedList {
this._c.itemsPerPage,
this._page,
this._c.appendNewItems,
(resp: paginatedDTO) => {
(resp: PaginatedDTO) => {
// Check before setting this.lastPage so we have a chance to cancel the timeout.
if (resp.last_page) {
clearTimeout(timeout);
this._c.loadAllButtons.forEach((v) => removeLoader(v));
}
},
(resp: paginatedDTO) => {
(resp: PaginatedDTO) => {
if (this._search.inSearch || loadAll) {
if (this.lastPage) {
loadAll = false;
@@ -464,8 +499,8 @@ export abstract class PaginatedList {
);
};
public abstract loadAll: (callback?: (resp?: paginatedDTO) => void) => void;
protected _loadAll = (callback?: (resp?: paginatedDTO) => void) => {
public abstract loadAll: (callback?: (resp?: PaginatedDTO) => void) => void;
protected _loadAll = (callback?: (resp?: PaginatedDTO) => void) => {
this._c.loadAllButtons.forEach((v) => {
addLoader(v, true);
});
@@ -494,6 +529,7 @@ export abstract class PaginatedList {
this._scroll.lastScrollY = scrollY;
// If you've scrolled back up, do nothing
if (scrollSpeed < 0) return;
if (this._scroll.rowHeight == 0) this.computeScrollInfo();
let endIdx = this.maximumItemsToRender(scrollY);
// Throttling this function means we might not catch up in time if the user scrolls fast,
@@ -507,7 +543,7 @@ export abstract class PaginatedList {
const realEndIdx = Math.min(endIdx, this._visible.length);
const frag = document.createDocumentFragment();
for (let i = this._scroll.rendered; i < realEndIdx; i++) {
frag.appendChild(this._search.items[this._visible[i]].asElement());
frag.appendChild(this._search.items.get(this._visible[i]).asElement());
}
this._scroll.rendered = realEndIdx;
this._container.appendChild(frag);

View File

@@ -1,26 +1,19 @@
export interface Page {
name: string;
title: string;
url: string;
show: () => boolean;
hide: () => boolean;
shouldSkip: () => boolean;
index?: number;
}
export interface PageConfig {
hideOthersOnPageShow: boolean;
defaultName: string;
defaultTitle: string;
}
export class PageManager {
export class PageManager implements Pages {
pages: Map<string, Page>;
pageList: string[];
hideOthers: boolean;
defaultName: string = "";
defaultTitle: string = "";
private _listeners: Map<string, { params: string[]; func: (qp: URLSearchParams) => void }> = new Map();
private _previousParams = new URLSearchParams();
private _overridePushState = () => {
const pushState = window.history.pushState;
window.history.pushState = function (data: any, __: string, _: string | URL) {
@@ -32,6 +25,8 @@ export class PageManager {
};
private _onpopstate = (event: PopStateEvent) => {
const prevParams = this._previousParams;
this._previousParams = new URLSearchParams(window.location.search);
let name = event.state;
if (name == null) {
// Attempt to use hash from URL, if it isn't there, try the last part of the URL.
@@ -48,6 +43,14 @@ export class PageManager {
if (!success) {
return;
}
if (this._listeners.has(name)) {
for (let qp of this._listeners.get(name).params) {
if (prevParams.get(qp) != this._previousParams.get(qp)) {
this._listeners.get(name).func(this._previousParams);
break;
}
}
}
if (!this.hideOthers) {
return;
}
@@ -115,4 +118,17 @@ export class PageManager {
}
this.loadPage(p);
}
// FIXME: Make PageManager global.
// registerParamListener allows registering a listener which will be called when one or many of the given query param names are changed. It will only be called once per navigation.
registerParamListener(pageName: string, func: (qp: URLSearchParams) => void, ...qps: string[]) {
const p: { params: string[]; func: (qp: URLSearchParams) => void } = this._listeners.get(pageName) || {
params: [],
func: null,
};
if (func) p.func = func;
p.params.push(...qps);
this._listeners.set(pageName, p);
}
}

15
ts/modules/row.ts Normal file
View File

@@ -0,0 +1,15 @@
export abstract class TableRow {
protected _row: HTMLTableRowElement;
remove() {
this._row.remove();
}
asElement(): HTMLTableRowElement {
return this._row;
}
constructor() {
this._row = document.createElement("tr");
this._row.classList.add("border-b", "border-dashed", "dark:border-dotted", "dark:border-stone-700");
}
}

View File

@@ -33,20 +33,20 @@ export interface QueryType {
}
export interface SearchConfiguration {
filterArea: HTMLElement;
filterArea?: HTMLElement;
sortingByButton?: HTMLButtonElement;
searchOptionsHeader: HTMLElement;
notFoundPanel: HTMLElement;
notFoundLocallyText: HTMLElement;
searchOptionsHeader?: HTMLElement;
notFoundPanel?: HTMLElement;
notFoundLocallyText?: HTMLElement;
notFoundCallback?: (notFound: boolean) => void;
filterList: HTMLElement;
clearSearchButtonSelector: string;
serverSearchButtonSelector: string;
search: HTMLInputElement;
filterList?: HTMLElement;
clearSearchButtonSelector?: string;
serverSearchButtonSelector?: string;
search?: HTMLInputElement;
queries: { [field: string]: QueryType };
setVisibility: (items: string[], visible: boolean, appendedItems: boolean) => void;
onSearchCallback: (newItems: boolean, loadAll: boolean, callback?: (resp: paginatedDTO) => void) => void;
searchServer: (params: PaginatedReqDTO, newSearch: boolean) => void;
onSearchCallback: (newItems: boolean, loadAll: boolean, callback?: (resp: PaginatedDTO) => void) => void;
searchServer: (params: PaginatedReqDTO, newSearch: boolean, then?: () => void) => void;
clearServerSearch: () => void;
loadMore?: () => void;
}
@@ -68,9 +68,11 @@ export abstract class Query {
protected _subject: QueryType;
protected _operator: QueryOperator;
protected _card: HTMLElement;
protected _id: string;
public type: string;
constructor(subject: QueryType | null, operator: QueryOperator) {
constructor(id: string, subject: QueryType | null, operator: QueryOperator) {
this._id = id;
this._subject = subject;
this._operator = operator;
if (subject != null) {
@@ -116,8 +118,8 @@ export abstract class Query {
export class BoolQuery extends Query {
protected _value: boolean;
constructor(subject: QueryType, value: boolean) {
super(subject, QueryOperator.Equal);
constructor(id: string, subject: QueryType, value: boolean) {
super(id, subject, QueryOperator.Equal);
this.type = "bool";
this._value = value;
this._card.classList.add("button", "@high", "center", "flex", "flex-row", "gap-2");
@@ -165,8 +167,8 @@ export class BoolQuery extends Query {
export class StringQuery extends Query {
protected _value: string;
constructor(subject: QueryType, value: string) {
super(subject, QueryOperator.Equal);
constructor(id: string, subject: QueryType, value: string) {
super(id, subject, QueryOperator.Equal);
this.type = "string";
this._value = value.toLowerCase();
this._card.classList.add("button", "~neutral", "@low", "center", "flex", "flex-row", "gap-2");
@@ -214,8 +216,8 @@ const dateSetters: Map<string, (v: number) => void> = (() => {
export class DateQuery extends Query {
protected _value: ParsedDate;
constructor(subject: QueryType, operator: QueryOperator, value: ParsedDate) {
super(subject, operator);
constructor(id: string, subject: QueryType, operator: QueryOperator, value: ParsedDate) {
super(id, subject, operator);
this.type = "date";
this._value = value;
this._card.classList.add("button", "~neutral", "@low", "center", "flex", "flex-row", "gap-2");
@@ -276,14 +278,14 @@ export interface SearchableItem extends ListItem {
export const SearchableItemDataAttribute = "data-search-item";
export type SearchableItems = { [id: string]: SearchableItem };
export type SearchableItems = Map<string, SearchableItem>;
export class Search {
export class Search implements Navigatable {
private _c: SearchConfiguration;
private _sortField: string = "";
private _ascending: boolean = true;
private _ordering: string[] = [];
private _items: SearchableItems = {};
private _items: SearchableItems = new Map<string, SearchableItem>();
// Search queries (filters)
private _queries: Query[] = [];
// Plain-text search terms
@@ -368,7 +370,7 @@ export class Search {
if (queryFormat.bool) {
let [boolState, isBool] = BoolQuery.paramsFromString(split[1]);
if (isBool) {
q = new BoolQuery(queryFormat, boolState);
q = new BoolQuery(split[0], queryFormat, boolState);
q.onclick = () => {
for (let quote of [`"`, `'`, ``]) {
this._c.search.value = this._c.search.value.replace(
@@ -383,7 +385,7 @@ export class Search {
}
}
if (queryFormat.string) {
q = new StringQuery(queryFormat, split[1]);
q = new StringQuery(split[0], queryFormat, split[1]);
q.onclick = () => {
for (let quote of [`"`, `'`, ``]) {
@@ -398,7 +400,7 @@ export class Search {
if (queryFormat.date) {
let [parsedDate, op, isDate] = DateQuery.paramsFromString(split[1]);
if (!isDate) continue;
q = new DateQuery(queryFormat, op, parsedDate);
q = new DateQuery(split[0], queryFormat, op, parsedDate);
q.onclick = () => {
for (let quote of [`"`, `'`, ``]) {
@@ -435,7 +437,7 @@ export class Search {
for (let term of searchTerms) {
let cachedResult = [...result];
for (let id of cachedResult) {
const u = this.items[id];
const u = this.items.get(id);
if (!u.matchesSearch(term)) {
result.splice(result.indexOf(id), 1);
}
@@ -444,14 +446,14 @@ export class Search {
}
for (let q of queries) {
this._c.filterArea.appendChild(q.asElement());
this._c.filterArea?.appendChild(q.asElement());
// Skip if this query has already been performed by the server.
if (this.inServerSearch && !q.localOnly) continue;
let cachedResult = [...result];
if (q.type == "bool") {
for (let id of cachedResult) {
const u = this.items[id];
const u = this.items.get(id);
// Remove from result if not matching query
if (!q.compareItem(u)) {
// console.log("not matching, result is", result);
@@ -460,7 +462,7 @@ export class Search {
}
} else if (q.type == "string") {
for (let id of cachedResult) {
const u = this.items[id];
const u = this.items.get(id);
// We want to compare case-insensitively, so we get value, lower-case it then compare,
// rather than doing both with compareItem.
const value = q.getValueFromItem(u).toLowerCase();
@@ -470,7 +472,7 @@ export class Search {
}
} else if (q.type == "date") {
for (let id of cachedResult) {
const u = this.items[id];
const u = this.items.get(id);
// Getter here returns a unix timestamp rather than a date, so we can't use compareItem.
const unixValue = q.getValueFromItem(u);
if (unixValue == 0) {
@@ -491,7 +493,7 @@ export class Search {
// Returns a list of identifiers (used as keys in items, values in ordering).
search = (query: string): string[] => {
let timer = this.timeSearches ? performance.now() : null;
this._c.filterArea.textContent = "";
if (this._c.filterArea) this._c.filterArea.textContent = "";
const [searchTerms, queries] = this.parseTokens(Search.tokenizeSearch(query));
@@ -515,16 +517,16 @@ export class Search {
showHideSearchOptionsHeader = () => {
let sortingBy = false;
if (this._c.sortingByButton) sortingBy = !this._c.sortingByButton.classList.contains("hidden");
const hasFilters = this._c.filterArea.textContent != "";
const hasFilters = this._c.filterArea ? this._c.filterArea.textContent != "" : false;
if (sortingBy || hasFilters) {
this._c.searchOptionsHeader.classList.remove("hidden");
this._c.searchOptionsHeader?.classList.remove("hidden");
} else {
this._c.searchOptionsHeader.classList.add("hidden");
this._c.searchOptionsHeader?.classList.add("hidden");
}
};
// -all- elements.
get items(): { [id: string]: SearchableItem } {
get items(): Map<string, SearchableItem> {
return this._items;
}
// set items(v: { [id: string]: SearchableItem }) {
@@ -550,11 +552,12 @@ export class Search {
return this._ascending;
}
// FIXME: This is being called by navigate, and triggering a "load more" when we haven't loaded at all, and loading without a searchc when we have one!
onSearchBoxChange = (
newItems: boolean = false,
appendedItems: boolean = false,
loadAll: boolean = false,
callback?: (resp: paginatedDTO) => void,
callback?: (resp: PaginatedDTO) => void,
) => {
const query = this._c.search.value;
if (!query) {
@@ -585,14 +588,14 @@ export class Search {
setNotFoundPanelVisibility = (visible: boolean) => {
if (this._inServerSearch || !this.inSearch) {
this._c.notFoundLocallyText.classList.add("unfocused");
this._c.notFoundLocallyText?.classList.add("unfocused");
} else if (this.inSearch) {
this._c.notFoundLocallyText.classList.remove("unfocused");
this._c.notFoundLocallyText?.classList.remove("unfocused");
}
if (visible) {
this._c.notFoundPanel.classList.remove("unfocused");
this._c.notFoundPanel?.classList.remove("unfocused");
} else {
this._c.notFoundPanel.classList.add("unfocused");
this._c.notFoundPanel?.classList.add("unfocused");
}
};
@@ -606,6 +609,7 @@ export class Search {
};
generateFilterList = () => {
if (!this._c.filterList) return;
const filterListContainer = document.createElement("div");
filterListContainer.classList.add("flex", "flex-row", "flex-wrap", "gap-2");
// Generate filter buttons
@@ -691,14 +695,15 @@ export class Search {
this._c.filterList.appendChild(filterListContainer);
};
onServerSearch = () => {
onServerSearch = (then?: () => void) => {
const newServerSearch = !this.inServerSearch;
this.inServerSearch = true;
this.searchServer(newServerSearch);
// this.setQueryParam();
this.searchServer(newServerSearch, then);
};
searchServer = (newServerSearch: boolean) => {
this._c.searchServer(this.serverSearchParams(this._searchTerms, this._queries), newServerSearch);
searchServer = (newServerSearch: boolean, then?: () => void) => {
this._c.searchServer(this.serverSearchParams(this._searchTerms, this._queries), newServerSearch, then);
};
serverSearchParams = (searchTerms: string[], queries: Query[]): PaginatedReqDTO => {
@@ -717,40 +722,100 @@ export class Search {
return req;
};
private _qps: URLSearchParams = new URLSearchParams();
private _clearWithoutNavigate = false;
// clearQueryParam removes the "search" query parameter --without-- triggering a navigate call.
clearQueryParam = () => {
if (!this._qps.has("search")) return;
this._clearWithoutNavigate = true;
this.setQueryParam("");
};
clearURL() {
this.clearQueryParam();
}
// setQueryParam sets the ?search query param to the current searchbox content,
// or value if given. If everything is set up correctly, this should trigger a search when it is
// set to a new value.
setQueryParam = (value?: string) => {
let triggerManually = false;
if (value === undefined || value == null) value = this._c.search.value;
const url = new URL(window.location.href);
// FIXME: do better and make someone else clear this
if (value.trim()) {
url.searchParams.delete("user");
url.searchParams.set("search", value);
} else {
// If the query param is already blank, no change will mean no call to navigate()
triggerManually = !url.searchParams.has("search");
url.searchParams.delete("search");
}
// console.log("pushing", url.toString());
window.history.pushState(null, "", url.toString());
if (triggerManually) this.navigate();
};
setServerSearchButtonsDisabled = (disabled: boolean) => {
this._serverSearchButtons.forEach((v: HTMLButtonElement) => (v.disabled = disabled));
};
isURL = (url?: string) => {
const urlParams = new URLSearchParams(url || window.location.search);
const searchContent = urlParams.get("search");
return Boolean(searchContent);
};
// navigate pulls the current "search" query param, puts it in the search box and searches it.
navigate = (url?: string, then?: () => void) => {
this._qps = new URLSearchParams(url || window.location.search);
if (this._clearWithoutNavigate) {
this._clearWithoutNavigate = false;
return;
}
const searchContent = this._qps.get("search") || "";
this._c.search.value = searchContent;
this.onSearchBoxChange();
this.onServerSearch(then);
};
constructor(c: SearchConfiguration) {
this._c = c;
if (!this._c.search) {
// Make a dummy one
this._c.search = document.createElement("input") as HTMLInputElement;
}
this._c.search.oninput = () => {
this.inServerSearch = false;
this.clearQueryParam();
this.onSearchBoxChange();
};
this._c.search.addEventListener("keyup", (ev: KeyboardEvent) => {
if (ev.key == "Enter") {
this.onServerSearch();
this.setQueryParam();
}
});
const clearSearchButtons = Array.from(
document.querySelectorAll(this._c.clearSearchButtonSelector),
) as Array<HTMLSpanElement>;
for (let b of clearSearchButtons) {
b.addEventListener("click", () => {
this._c.search.value = "";
this.inServerSearch = false;
this.onSearchBoxChange();
});
if (this._c.clearSearchButtonSelector) {
const clearSearchButtons = Array.from(
document.querySelectorAll(this._c.clearSearchButtonSelector),
) as Array<HTMLSpanElement>;
for (let b of clearSearchButtons) {
b.addEventListener("click", () => {
this.inServerSearch = false;
this.setQueryParam("");
});
}
}
this._serverSearchButtons = Array.from(
document.querySelectorAll(this._c.serverSearchButtonSelector),
) as Array<HTMLSpanElement>;
this._serverSearchButtons = this._c.serverSearchButtonSelector
? (Array.from(document.querySelectorAll(this._c.serverSearchButtonSelector)) as Array<HTMLSpanElement>)
: [];
for (let b of this._serverSearchButtons) {
b.addEventListener("click", () => {
this.onServerSearch();
this.setQueryParam();
});
}
}

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
declare var window: GlobalWindow;
export interface HiddenInputConf {
container: HTMLElement;
onSet: () => void;
@@ -109,3 +111,228 @@ export class HiddenInputField {
this.setEditing(!this.editing, false, noSave);
}
}
export interface RadioBasedTab {
name: string;
id?: string;
// If passed, will be put inside the button instead of the name.
buttonHTML?: string;
// You must at least pass a content element or an onShow function.
content?: HTMLElement;
onShow?: () => void;
onHide?: () => void;
}
interface RadioBasedTabItem {
tab: RadioBasedTab;
input: HTMLInputElement;
button: HTMLElement;
}
export class RadioBasedTabSelector {
private _id: string;
private _container: HTMLElement;
private _tabs: RadioBasedTabItem[];
private _selected: string;
constructor(container: HTMLElement, id: string, ...tabs: RadioBasedTab[]) {
this._container = container;
this._container.classList.add("flex", "flex-row", "gap-2");
this._tabs = [];
this._id = id;
let i = 0;
const frag = document.createDocumentFragment();
for (let tab of tabs) {
if (!tab.id) tab.id = tab.name;
const label = document.createElement("label");
label.classList.add("grow");
label.innerHTML = `
<input type="radio" name="${this._id}" value="${tab.name}" class="unfocused" ${i == 0 ? "checked" : ""}>
<span class="button ~neutral ${i == 0 ? "@high" : "@low"} radio-tab-button supra w-full text-center">${tab.buttonHTML || tab.name}</span>
`;
let ft: RadioBasedTabItem = {
tab: tab,
input: label.getElementsByTagName("input")[0] as HTMLInputElement,
button: label.getElementsByClassName("radio-tab-button")[0] as HTMLElement,
};
ft.input.onclick = () => {
ft.input.checked = true;
this.checkSource();
};
frag.appendChild(label);
this._tabs.push(ft);
i++;
}
this._container.replaceChildren(frag);
this.selected = 0;
}
checkSource = () => {
for (let tab of this._tabs) {
if (tab.input.checked) {
this._selected = tab.tab.id;
tab.tab.content?.classList.remove("unfocused");
tab.button.classList.add("@high");
tab.button.classList.remove("@low");
if (tab.tab.onShow) tab.tab.onShow();
} else {
tab.tab.content?.classList.add("unfocused");
tab.button.classList.add("@low");
tab.button.classList.remove("@high");
if (tab.tab.onHide) tab.tab.onHide();
}
}
};
get selected(): string {
return this._selected;
}
set selected(id: string | number) {
if (typeof id !== "string") {
id = this._tabs[id as number].tab.id;
}
for (let tab of this._tabs) {
if (tab.tab.id == id) {
this._selected = tab.tab.id;
tab.input.checked = true;
tab.tab.content?.classList.remove("unfocused");
tab.button.classList.add("@high");
tab.button.classList.remove("@low");
if (tab.tab.onShow) tab.tab.onShow();
} else {
tab.input.checked = false;
tab.tab.content?.classList.add("unfocused");
tab.button.classList.add("@low");
tab.button.classList.remove("@high");
if (tab.tab.onHide) tab.tab.onHide();
}
}
}
}
type TooltipPosition = "above" | "below" | "below-center" | "left" | "right";
export class Tooltip extends HTMLElement {
private _content: HTMLElement;
get content(): HTMLElement {
if (!this._content) return this.getElementsByClassName("content")[0] as HTMLElement;
return this._content;
}
connectedCallback() {
this.setup();
}
get visible(): boolean {
return this.classList.contains("shown");
}
get position(): TooltipPosition {
return window.getComputedStyle(this).getPropertyValue("--tooltip-position").trim() as TooltipPosition;
}
toggle() {
console.log("toggle!");
this.visible ? this.close() : this.open();
}
clicked: boolean = false;
private _listener = (event: MouseEvent | TouchEvent) => {
if (event.target !== this && !this.contains(event.target as HTMLElement)) {
this.close();
document.removeEventListener("mousedown", this._listener);
// document.removeEventListener("touchstart", this._listener);
}
};
open() {
this.fixWidth(() => {
this.classList.add("shown");
if (this.clicked) {
document.addEventListener("mousedown", this._listener);
// document.addEventListener("touchstart", this._listener);
}
});
}
close() {
this.clicked = false;
this.classList.remove("shown");
}
setup() {
this._content = this.getElementsByClassName("content")[0] as HTMLElement;
const clickEvent = () => {
if (this.clicked) {
console.log("clicked again!");
this.toggle();
} else {
console.log("clicked!");
this.clicked = true;
this.open();
}
};
/// this.addEventListener("touchstart", clickEvent);
this.addEventListener("click", clickEvent);
this.addEventListener("mouseover", () => {
this.open();
});
this.addEventListener("mouseleave", () => {
if (this.clicked) return;
console.log("mouseleave");
this.close();
});
}
fixWidth(after?: () => void) {
this._content.style.left = "";
this._content.style.right = "";
if (this.position == "below-center") {
const offset = this.offsetLeft;
const pw = (this.offsetParent as HTMLElement).offsetWidth;
const cw = this._content.offsetWidth;
const pos = -1 * offset + (pw - cw) / 2.0;
this._content.style.left = pos + "px";
}
const [leftObscured, rightObscured] = wherePartiallyObscuredX(this._content);
if (rightObscured) {
const rect = this._content.getBoundingClientRect();
this._content.style.left =
"calc(-1rem + " + ((window.innerWidth || document.documentElement.clientHeight) - rect.right) + "px)";
}
if (leftObscured) {
const rect = this._content.getBoundingClientRect();
this._content.style.right = "calc(-1rem + " + rect.left + "px)";
"calc(-1rem + " + ((window.innerWidth || document.documentElement.clientHeight) - rect.right) + "px)";
}
if (after) after();
}
}
export function setupTooltips() {
customElements.define("tool-tip", Tooltip);
}
export function isPartiallyObscuredX(el: HTMLElement): boolean {
const rect = el.getBoundingClientRect();
return rect.left < 0 || rect.right > (window.innerWidth || document.documentElement.clientWidth);
}
export function wherePartiallyObscuredX(el: HTMLElement): [boolean, boolean] {
const rect = el.getBoundingClientRect();
return [Boolean(rect.left < 0), Boolean(rect.right > (window.innerWidth || document.documentElement.clientWidth))];
}
export function isPartiallyObscuredY(el: HTMLElement): boolean {
const rect = el.getBoundingClientRect();
return rect.top < 0 || rect.bottom > (window.innerHeight || document.documentElement.clientHeight);
}
export function wherePartiallyObscuredY(el: HTMLElement): [boolean, boolean] {
const rect = el.getBoundingClientRect();
return [
Boolean(rect.top < 0),
Boolean(rect.bottom > (window.innerHeight || document.documentElement.clientHeight)),
];
}

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

@@ -61,13 +61,30 @@ declare interface GlobalWindow extends Window {
loginAppearance: string;
}
declare interface InviteList {
declare interface PageEventBindable {
bindPageEvents(): void;
unbindPageEvents(): void;
}
declare interface AsTab {
readonly tabName: string;
readonly pagePath: string;
reload(callback: () => void): void;
}
declare interface Navigatable {
// isURL will return whether the given url (or the current page url if not passed) is a valid link to some resource(s) in the class.
isURL(url?: string): boolean;
// clearURL will remove related query params from the current URL. It will likely be called when switching pages.
clearURL(): void;
// navigate will load and focus the resource(s) in the class referenced by the given url (or current page url if not passed).
navigate(url?: string): void;
}
declare interface InviteList extends Navigatable, AsTab {
empty: boolean;
invites: { [code: string]: Invite };
add: (invite: Invite) => void;
reload: (callback?: () => void) => void;
isInviteURL: () => boolean;
loadInviteURL: () => void;
}
declare interface Invite {
@@ -135,12 +152,6 @@ declare interface NotificationBox {
customSuccess: (type: string, message: string) => void;
}
declare interface Tabs {
current: string;
addTab: (tabID: string, url: string, preFunc?: () => void, postFunc?: () => void, unloadFunc?: () => void) => void;
switch: (tabID: string, noRun?: boolean, keepURL?: boolean) => void;
}
declare interface Modals {
about: Modal;
login: Modal;
@@ -173,18 +184,66 @@ declare interface Modals {
backups?: Modal;
}
interface paginatedDTO {
declare interface Page {
name: string;
title: string;
url: string;
show: () => boolean;
hide: () => boolean;
shouldSkip: () => boolean;
index?: number;
}
declare interface Tab {
page: Page;
tabEl: HTMLDivElement;
buttonEl: HTMLSpanElement;
contentObject?: AsTab;
preFunc?: (previous?: AsTab) => void;
postFunc?: (previous?: AsTab) => void;
}
declare interface Tabs {
tabs: Map<string, Tab>;
pages: Pages;
addTab(
tabID: string,
url: string,
contentObject: AsTab | null,
preFunc: () => void,
postFunc: () => void,
unloadFunc: () => void,
): void;
current: string;
switch(tabID: string, noRun?: boolean): void;
}
declare interface Pages {
pages: Map<string, Page>;
pageList: string[];
hideOthers: boolean;
defaultName: string;
defaultTitle: string;
setPage(p: Page): void;
load(name?: string): void;
loadPage(p: Page): void;
prev(name?: string): void;
next(name?: string): void;
registerParamListener(pageName: string, func: (qp: URLSearchParams) => void, ...qps: string[]): void;
}
declare interface PaginatedDTO {
last_page: boolean;
}
interface PaginatedReqDTO {
declare interface PaginatedReqDTO {
limit: number;
page: number;
sortByField: string;
ascending: boolean;
}
interface DateAttempt {
declare interface DateAttempt {
year?: number;
month?: number;
day?: number;
@@ -193,7 +252,7 @@ interface DateAttempt {
offsetMinutesFromUTC?: number;
}
interface ParsedDate {
declare interface ParsedDate {
attempt: DateAttempt;
date: Date;
text: string;

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>
`;
}

View File

@@ -599,18 +599,6 @@ func (q *QueryDTO) UnmarshalJSON(data []byte) error {
return nil
}
// ServerSearchReqDTO is a usual PaginatedReqDTO with added fields for searching and filtering.
type ServerSearchReqDTO struct {
PaginatedReqDTO
ServerFilterReqDTO
}
// ServerFilterReqDTO provides search terms and queries to a search or count route.
type ServerFilterReqDTO struct {
SearchTerms []string `json:"searchTerms"`
Queries []QueryDTO `json:"queries"`
}
// Filter reduces the passed slice of *respUsers
// by searching for each term of terms[] with respUser.MatchesSearch,
// and by evaluating Queries with Query.AsFilter().