diff --git a/ts/admin.ts b/ts/admin.ts index ba0e6d9..e7fee76 100644 --- a/ts/admin.ts +++ b/ts/admin.ts @@ -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 { Tabs, Tab, 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"; @@ -143,91 +143,37 @@ 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(); -let isActivityURL = activity.isActivityURL(); +// 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; - } - }), - }, - { - id: "accounts", - url: "accounts", - reloader: () => - accounts.reload(() => { - if (isAccountURL) { - accounts.loadAccountURL(); - // Don't keep loading the same item on every tab refresh - isAccountURL = false; - // Since accounts and activity accept ?user=x, wipe the other one. - isActivityURL = false; - } - accounts.bindPageEvents(); - }), - unloader: accounts.unbindPageEvents, - }, - { - id: "activity", - url: "activity", - reloader: () => { - activity.reload(() => { - if (isActivityURL) { - activity.loadActivityURL(); - // Don't keep loading the same item on every tab refresh - isActivityURL = false; - // Since accounts and activity accept ?user=x, wipe the other one. - isAccountURL = false; - } - }); - activity.bindPageEvents(); - }, - unloader: activity.unbindPageEvents, - }, - { - id: "settings", - url: "settings", - reloader: settings.reload, - }, -]; - -const defaultTab = tabs[0]; - +const tabs: { id: string; url: string; reloader: () => void; unloader?: () => void }[] = []; window.tabs = new Tabs(); - -for (let tab of tabs) { +[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: () => + p.reload(() => { + if (!navigated && isNavigatable(p)) { + if (p.isURL()) { + navigated = true; + p.navigate(); + } + } + if (isPageEventBindable(p)) p.bindPageEvents(); + }), + }; + 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) { diff --git a/ts/modules/accounts.ts b/ts/modules/accounts.ts index c09ee28..f20258c 100644 --- a/ts/modules/accounts.ts +++ b/ts/modules/accounts.ts @@ -246,7 +246,7 @@ class User extends TableRow implements UserDTO, SearchableItem { setSelected(state: boolean, dispatchEvent: boolean) { this._selected = state; this._check.checked = state; - if (dispatchEvent && !(this.notInList)) + if (dispatchEvent && !this.notInList) state ? document.dispatchEvent(this._checkEvent()) : document.dispatchEvent(this._uncheckEvent()); } @@ -933,7 +933,7 @@ class User extends TableRow implements UserDTO, SearchableItem { }; remove = () => { - if (this.selected && !(this.notInList)) { + if (this.selected && !this.notInList) { document.dispatchEvent(this._uncheckEvent()); } super.remove(); @@ -956,7 +956,9 @@ declare interface ExtendExpiryDTO { try_extend_from_previous_expiry?: boolean; } -export class accountsList extends PaginatedList { +export class accountsList extends PaginatedList implements Navigatable, AsTab { + readonly tabName = "accounts"; + readonly pagePath = "accounts"; private _details: UserInfo; private _table = document.getElementById("accounts-table") as HTMLTableElement; protected _container = document.getElementById("accounts-list") as HTMLTableSectionElement; @@ -1216,7 +1218,7 @@ export class accountsList extends PaginatedList { this._applyJellyseerr?.parentElement.classList.add("unfocused"); }, }, - ) + ); if (window.referralsEnabled) { this._enableReferralsSource = new RadioBasedTabSelector( @@ -2467,16 +2469,23 @@ export class accountsList extends PaginatedList { this.focusAccount(event.detail); }); - isAccountURL = () => { - const urlParams = new URLSearchParams(window.location.search); + isURL = (url?: string) => { + const urlParams = new URLSearchParams(url || window.location.search); const userID = urlParams.get("user"); - return Boolean(userID); + return Boolean(userID) || this._search.isURL(url); }; - loadAccountURL = () => { - const urlParams = new URLSearchParams(window.location.search); + navigate = (url?: string) => { + const urlParams = new URLSearchParams(url || window.location.search); const userID = urlParams.get("user"); - this.focusAccount(userID); + let search = urlParams.get("search") || ""; + if (userID) { + search = `id:${userID}" ` + 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()); }; } @@ -2981,10 +2990,10 @@ class UserInfo extends PaginatedList { this.jfId = user.id; const clone = new User(user); clone.notInList = true; - clone.setSelected(true, false) + clone.setSelected(true, false); this._rowArea.replaceChildren(clone.asElement()); - this._link.setAttribute("data-id", this.jfId) - this._link.href = `${window.pages.Base}${window.pages.Admin}/activity?user=${this.username}` + this._link.setAttribute("data-id", this.jfId); + this._link.href = `${window.pages.Base}${window.pages.Admin}/activity?user=${this.username}`; if (onBack) { this._back.classList.remove("unfocused"); diff --git a/ts/modules/activity.ts b/ts/modules/activity.ts index d7d6b79..4720170 100644 --- a/ts/modules/activity.ts +++ b/ts/modules/activity.ts @@ -558,7 +558,9 @@ 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; @@ -687,18 +689,23 @@ export class activityList extends PaginatedList { this._keepSearchingDescription.classList.add("unfocused"); } };*/ - - isActivityURL = () => { - const urlParams = new URLSearchParams(window.location.search); + + isURL = (url?: string) => { + const urlParams = new URLSearchParams(url || window.location.search); const username = urlParams.get("user"); - return Boolean(username); + return Boolean(username) || this._search.isURL(url); }; - loadActivityURL = () => { - const urlParams = new URLSearchParams(window.location.search); + navigate = (url?: string) => { + const urlParams = new URLSearchParams(url || window.location.search); const username = urlParams.get("user"); - this._c.searchBox.value = `user:"${username}"`; - this._search.onSearchBoxChange(); - this._search.onServerSearch(); + 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()); }; } diff --git a/ts/modules/invites.ts b/ts/modules/invites.ts index bd4b0e8..c9f85d5 100644 --- a/ts/modules/invites.ts +++ b/ts/modules/invites.ts @@ -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")); }; diff --git a/ts/modules/list.ts b/ts/modules/list.ts index 3e95055..93cf1a4 100644 --- a/ts/modules/list.ts +++ b/ts/modules/list.ts @@ -110,7 +110,7 @@ export interface PaginatedListConfig { hideButtonsOnLastPage?: boolean; } -export abstract class PaginatedList { +export abstract class PaginatedList implements PageEventBindable { protected _c: PaginatedListConfig; // Container to append items to. diff --git a/ts/modules/search.ts b/ts/modules/search.ts index e09bac1..b4f8226 100644 --- a/ts/modules/search.ts +++ b/ts/modules/search.ts @@ -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 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"); @@ -278,7 +280,7 @@ export const SearchableItemDataAttribute = "data-search-item"; export type SearchableItems = Map; -export class Search { +export class Search implements Navigatable { private _c: SearchConfiguration; private _sortField: string = ""; private _ascending: boolean = true; @@ -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 [`"`, `'`, ``]) { @@ -695,6 +697,7 @@ export class Search { onServerSearch = () => { const newServerSearch = !this.inServerSearch; this.inServerSearch = true; + this.setQueryParam(); this.searchServer(newServerSearch); }; @@ -718,10 +721,33 @@ export class Search { return req; }; + // setQueryParam sets the ?search query param to the current searchbox content. + setQueryParam = () => { + const url = new URL(window.location.href); + // FIXME: do better and make someone else clear this + url.searchParams.delete("user"); + url.searchParams.set("search", this._c.search.value); + window.history.pushState(null, "", url.toString()); + }; + 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 = (url?: string) => { + const urlParams = new URLSearchParams(url || window.location.search); + const searchContent = urlParams.get("search"); + this._c.search.value = searchContent; + this.onSearchBoxChange(); + this.onServerSearch(); + }; + constructor(c: SearchConfiguration) { this._c = c; if (!this._c.search) { diff --git a/ts/modules/settings.ts b/ts/modules/settings.ts index 6974451..14e0d67 100644 --- a/ts/modules/settings.ts +++ b/ts/modules/settings.ts @@ -1067,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; diff --git a/ts/modules/tabs.ts b/ts/modules/tabs.ts index d688d4b..64b1c0a 100644 --- a/ts/modules/tabs.ts +++ b/ts/modules/tabs.ts @@ -1,5 +1,13 @@ import { PageManager, Page } from "../modules/pages.js"; +export function isPageEventBindable(object: any): object is PageEventBindable { + return "bindPageEvents" in object; +} + +export function isNavigatable(object: any): object is Navigatable { + return "isURL" in object && "navigate" in object; +} + export interface Tab { page: Page; tabEl: HTMLDivElement; diff --git a/ts/modules/ui.ts b/ts/modules/ui.ts index 5dcaa10..2c6f1f0 100644 --- a/ts/modules/ui.ts +++ b/ts/modules/ui.ts @@ -112,7 +112,6 @@ export class HiddenInputField { } } - export interface RadioBasedTab { name: string; id?: string; @@ -135,7 +134,7 @@ export class RadioBasedTabSelector { private _container: HTMLElement; private _tabs: RadioBasedTabItem[]; private _selected: string; - constructor(container: HTMLElement, id: string, ...tabs: RadioBasedTab[]) { + constructor(container: HTMLElement, id: string, ...tabs: RadioBasedTab[]) { this._container = container; this._container.classList.add("flex", "flex-row", "gap-2"); this._tabs = []; @@ -143,7 +142,7 @@ export class RadioBasedTabSelector { let i = 0; const frag = document.createDocumentFragment(); for (let tab of tabs) { - if (!(tab.id)) tab.id = tab.name; + if (!tab.id) tab.id = tab.name; const label = document.createElement("label"); label.classList.add("grow"); label.innerHTML = ` @@ -153,7 +152,7 @@ export class RadioBasedTabSelector { let ft: RadioBasedTabItem = { tab: tab, input: label.getElementsByTagName("input")[0] as HTMLInputElement, - button: label.getElementsByClassName("radio-tab-button")[0] as HTMLElement + button: label.getElementsByClassName("radio-tab-button")[0] as HTMLElement, }; ft.input.onclick = () => { ft.input.checked = true; @@ -185,8 +184,10 @@ export class RadioBasedTabSelector { } }; - get selected(): string { return this._selected; } - set selected(id: string|number) { + get selected(): string { + return this._selected; + } + set selected(id: string | number) { if (typeof id !== "string") { id = this._tabs[id as number].tab.id; } diff --git a/ts/typings/d.ts b/ts/typings/d.ts index 46370ec..62603c3 100644 --- a/ts/typings/d.ts +++ b/ts/typings/d.ts @@ -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 {