mirror of
https://github.com/hrfee/jfa-go.git
synced 2026-03-18 21:50:33 +01:00
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.
This commit is contained in:
106
ts/admin.ts
106
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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
@@ -278,7 +280,7 @@ export const SearchableItemDataAttribute = "data-search-item";
|
||||
|
||||
export type SearchableItems = Map<string, SearchableItem>;
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user