Compare commits

...

8 Commits

Author SHA1 Message Date
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
23 changed files with 1312 additions and 500 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)
}

2
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

2
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=

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>
@@ -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">
@@ -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>

View File

@@ -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

@@ -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

@@ -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";
@@ -129,6 +129,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 +146,36 @@ 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: "",
const tabs: { id: string; url: string; reloader: () => void; unloader?: () => void }[] = [];
[window.invites, accounts, activity, settings].forEach((p: AsTab) => {
let t: { id: string; url: string; reloader: () => void; unloader?: () => void } = {
id: p.tabName,
url: p.pagePath,
reloader: () =>
window.invites.reload(() => {
if (isInviteURL) {
window.invites.loadInviteURL();
// Don't keep loading the same item on every tab refresh
isInviteURL = false;
p.reload(() => {
if (!navigated && isNavigatable(p)) {
if (p.isURL()) {
navigated = true;
p.navigate();
}
}
if (isPageEventBindable(p)) p.bindPageEvents();
}),
},
{
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();
},
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,
null,
tab.reloader,
tab.unloader || null,
t.reloader,
t.unloader || null,
);
}
});
let matchedTab = false;
for (const tab of tabs) {

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,23 @@ 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());
};
}

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 } from "./ui.js";
declare var window: GlobalWindow;
@@ -784,6 +784,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,14 +806,14 @@ 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"));
};
@@ -945,8 +947,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 +1193,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.
@@ -349,9 +362,9 @@ export abstract class PaginatedList {
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,
) => {
this._lastLoad = Date.now();
@@ -364,7 +377,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 +388,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;
@@ -393,11 +406,13 @@ export abstract class PaginatedList {
};
// 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) => {
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 +422,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 +444,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 +456,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 +481,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);
});
@@ -507,7 +524,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 }) {
@@ -554,7 +556,7 @@ export class Search {
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 +587,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 +608,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 +694,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,12 +721,54 @@ export class Search {
return req;
};
// 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) => {
(window as any).s = this;
const urlParams = new URLSearchParams(url || window.location.search);
const searchContent = urlParams.get("search") || "";
console.log("navigate!, setting search box to ", searchContent);
this._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;
@@ -730,27 +776,28 @@ export class Search {
};
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,6 @@ import {
} from "../modules/common.js";
import { Marked } from "@ts-stack/markdown";
import { stripMarkdown } from "../modules/stripmd.js";
import { PDT } from "src/data/timezoneNames";
declare var window: GlobalWindow;
@@ -1068,7 +1067,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;

View File

@@ -1,14 +1,14 @@
import { PageManager, Page } from "../modules/pages.js";
import { PageManager } from "../modules/pages.js";
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>;

View File

@@ -1,3 +1,5 @@
declare var window: GlobalWindow;
export interface HiddenInputConf {
container: HTMLElement;
onSet: () => void;
@@ -109,3 +111,101 @@ 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();
}
}
}
}

View File

@@ -61,13 +61,28 @@ 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;
// 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 +150,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 +182,58 @@ 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;
preFunc?: () => void;
postFunc?: () => void;
}
declare interface Tabs {
tabs: Map<string, Tab>;
pages: Pages;
addTab(tabID: string, url: string, 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 +242,7 @@ interface DateAttempt {
offsetMinutesFromUTC?: number;
}
interface ParsedDate {
declare interface ParsedDate {
attempt: DateAttempt;
date: Date;
text: string;

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().