1
0
mirror of https://github.com/hrfee/jfa-go.git synced 2024-11-12 21:30:10 +00:00

Compare commits

..

5 Commits

Author SHA1 Message Date
a0db685af2
activity: functional search (client-side)
search with filters for each type of card, and all the info in them.
Gonna somehow need to figure out what to do about pagination.
2023-10-21 16:24:14 +01:00
4fa0630aef
accounts: modularize search
now part of ts/modules/search.ts, UI of the activity page is gonna be
very similar so it made sense to.
2023-10-21 14:33:09 +01:00
3cad30a8e5
activity: add delete button 2023-10-21 13:38:11 +01:00
44172074b9
activity: render all activities correctly
the activity type, usernames, time, referrer, and invite code are
displayed correctly for all types of activity.
2023-10-21 13:00:06 +01:00
1032e4e747
activity: more presentable cards, fixes
fixed some missing data (being stored and being shown), improved layout,
also usernames are now injected by the route.
2023-10-20 22:16:40 +01:00
12 changed files with 876 additions and 445 deletions

View File

@ -89,7 +89,7 @@ func activitySourceToString(v ActivitySource) string {
// @Produce json
// @Param GetActivitiesDTO body GetActivitiesDTO true "search parameters"
// @Success 200 {object} GetActivitiesRespDTO
// @Router /activity [get]
// @Router /activity [post]
// @Security Bearer
// @tags Activity
func (app *appContext) GetActivities(gc *gin.Context) {
@ -138,7 +138,32 @@ func (app *appContext) GetActivities(gc *gin.Context) {
Value: act.Value,
Time: act.Time.Unix(),
}
if act.Type == ActivityDeletion || act.Type == ActivityCreation {
resp.Activities[i].Username = act.Value
resp.Activities[i].Value = ""
} else if user, status, err := app.jf.UserByID(act.UserID, false); status == 200 && err == nil {
resp.Activities[i].Username = user.Name
}
if (act.SourceType == ActivityUser || act.SourceType == ActivityAdmin) && act.Source != "" {
user, status, err := app.jf.UserByID(act.Source, false)
if status == 200 && err == nil {
resp.Activities[i].SourceUsername = user.Name
}
}
}
gc.JSON(200, resp)
}
// @Summary Delete the activity with the given ID. No-op if non-existent, always succeeds.
// @Produce json
// @Param id path string true "ID of activity to delete"
// @Success 200 {object} boolResponse
// @Router /activity/{id} [delete]
// @Security Bearer
// @tags Activity
func (app *appContext) DeleteActivity(gc *gin.Context) {
app.storage.DeleteActivityKey(gc.Param("id"))
respondBool(200, true, gc)
}

View File

@ -90,6 +90,7 @@ func (app *appContext) checkInvites() {
Type: ActivityDeleteInvite,
SourceType: ActivityDaemon,
InviteCode: data.Code,
Value: data.Label,
Time: time.Now(),
})
}
@ -141,6 +142,7 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
Type: ActivityDeleteInvite,
SourceType: ActivityDaemon,
InviteCode: code,
Value: inv.Label,
Time: time.Now(),
})
} else if used {
@ -153,6 +155,7 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
Type: ActivityDeleteInvite,
SourceType: ActivityDaemon,
InviteCode: code,
Value: inv.Label,
Time: time.Now(),
})
} else if newInv.RemainingUses != 0 {
@ -460,8 +463,7 @@ func (app *appContext) DeleteInvite(gc *gin.Context) {
var req deleteInviteDTO
gc.BindJSON(&req)
app.debug.Printf("%s: Deletion requested", req.Code)
var ok bool
_, ok = app.storage.GetInvitesKey(req.Code)
inv, ok := app.storage.GetInvitesKey(req.Code)
if ok {
app.storage.DeleteInvitesKey(req.Code)
@ -470,6 +472,8 @@ func (app *appContext) DeleteInvite(gc *gin.Context) {
Type: ActivityDeleteInvite,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
InviteCode: req.Code,
Value: inv.Label,
Time: time.Now(),
})

View File

@ -53,6 +53,7 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) {
UserID: id,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
Value: user.Name,
Time: time.Now(),
})
@ -328,6 +329,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
SourceType: sourceType,
Source: source,
InviteCode: invite.Code,
Value: user.Name,
Time: time.Now(),
})
@ -648,6 +650,12 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
}
}
}
username := ""
if user, status, err := app.jf.UserByID(userID, false); status == 200 && err == nil {
username = user.Name
}
status, err := app.jf.DeleteUser(userID)
if !(status == 200 || status == 204) || err != nil {
msg := fmt.Sprintf("%d: %v", status, err)
@ -664,6 +672,7 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
UserID: userID,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
Value: username,
Time: time.Now(),
})
@ -1151,8 +1160,12 @@ func (app *appContext) ModifyEmails(gc *gin.Context) {
emailStore.Addr = address
app.storage.SetEmailsKey(id, emailStore)
activityType := ActivityContactLinked
if address == "" {
activityType = ActivityContactUnlinked
}
app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: ActivityContactLinked,
Type: activityType,
UserID: id,
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),

View File

@ -725,48 +725,20 @@
<div class="flex-expand align-middle">
<span class="text-3xl font-bold mr-4">{{ .strings.activity }}</span>
<div id="activity-filter-dropdown" class="dropdown z-10" tabindex="0">
<span class="h-100 button ~neutral @low center" id="accounts-filter-button">{{ .strings.filters }}</span>
<span class="h-100 button ~neutral @low center" id="activity-filter-button">{{ .strings.filters }}</span>
<div class="dropdown-display">
<div class="card ~neutral @low mt-2" id="accounts-filter-list">
<div class="card ~neutral @low mt-2" id="activity-filter-list">
<p class="supra pb-2">{{ .strings.filters }}</p>
</div>
</div>
</div>
<input type="search" class="field ~neutral @low input search ml-2 mr-2" id="accounts-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none accounts-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<input type="search" class="field ~neutral @low input search ml-2 mr-2" id="activity-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none activity-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
</div>
<div class="supra py-1 sm hidden" id="accounts-search-options-header">{{ .strings.searchOptions }}</div>
<div class="supra py-1 sm hidden" id="activity-search-options-header">{{ .strings.searchOptions }}</div>
<div class="row -mx-2 mb-2">
<button type="button" class="button ~neutral @low center mx-2 hidden"><span id="accounts-sort-by-field"></span> <i class="ri-close-line ml-2 text-2xl"></i></button>
<span id="accounts-filter-area"></span>
</div>
<div class="supra py-1 sm">{{ .strings.actions }}</div>
<div class="row -mx-2">
<span class="col button ~neutral @low center max-w-[20%]" id="accounts-add-user">{{ .quantityStrings.addUser.Singular }}</span>
<div id="accounts-announce-dropdown" class="col dropdown pb-0i max-w-[20%]" tabindex="0">
<span class="w-100 button ~info @low center" id="accounts-announce">{{ .strings.announce }}</span>
<div class="dropdown-display">
<div class="card ~neutral @low">
<span class="supra sm">{{ .strings.templates }}</span>
<div id="accounts-announce-templates"></div>
</div>
</div>
</div>
<span class="col button ~urge @low center max-w-[20%]" id="accounts-modify-user">{{ .strings.modifySettings }}</span>
{{ if .referralsEnabled }}
<span class="col button ~urge @low center max-w-[20%]" id="accounts-enable-referrals">{{ .strings.enableReferrals }}</span>
{{ end }}
<span class="col button ~warning @low center max-w-[20%]" id="accounts-extend-expiry">{{ .strings.extendExpiry }}</span>
<div id="accounts-disable-enable-dropdown" class="col dropdown manual pb-0i max-w-[20%]" tabindex="0">
<span class="w-100 button ~positive @low center" id="accounts-disable-enable">{{ .strings.disable }}</span>
<div class="dropdown-display">
<div class="card ~neutral @low">
<span class="button ~urge full-width accounts-announce-template-button" id="accounts-enable-expiry">{{ .strings.setExpiry }}</span>
</div>
</div>
</div>
<span class="col button ~info @low center unfocused max-w-[20%]" id="accounts-send-pwr">{{ .strings.sendPWR }}</span>
<span class="col button ~critical @low center max-w-[20%]" id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span>
<button type="button" class="button ~neutral @low center mx-2 hidden"><span id="activity-sort-by-field"></span> <i class="ri-close-line ml-2 text-2xl"></i></button>
<span id="activity-filter-area"></span>
</div>
<div class="flex flex-col md:flex-row gap-3">
<div class="card @low dark:~d_neutral col max-w-[20%]">
@ -778,19 +750,14 @@
<span class="button ~neutral @low settings-section-button justify-between mb-2" id="setting-about"><span class="flex">{{ .strings.aboutProgram }} <i class="ri-information-line ml-2"></i></span></span>
<span class="button ~neutral @low settings-section-button justify-between mb-2" id="setting-profiles"><span class="flex">{{ .strings.userProfiles }} <i class="ri-user-line ml-2"></i></span></span>
</div>
<div class="card ~neutral @low col overflow" id="activity-card-list">
<div class="card ~urge @low">
<div class="flex justify-between mb-2">
<span class="heading text-2xl activity-title">Account Created: <a href="/fixme" class="activity-url">"hrfee"</a></span>
<span class="text-sm font-medium activity-time">26/10/23 14:32</span>
</div>
<div class="flex justify-between">
<div>
<span class="content supra mr-2 activity-source-type">From Invite</span><a href="fixme" class="activity-source">BdBmpGDzuJhHSsboAsYgrE</a>
</div>
<div>
<span class="content supra mr-2">Referrer</span><a href="fixme" class="activity-referrer">username</a>
</div>
<div class="card ~neutral @low col">
<div id="activity-card-list"></div>
<div class="unfocused h-[100%] my-3" id="activity-not-found">
<div class="flex flex-col h-[100%] justify-center items-center">
<span class="text-2xl font-medium italic mb-3">{{ .strings.noResultsFound }}</span>
<button class="button ~neutral @low activity-search-clear">
<span class="mr-2">{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
</button>
</div>
</div>
</div>

View File

@ -134,8 +134,8 @@
"builtBy": "Built By",
"loginNotAdmin": "Not an Admin?",
"referrer": "Referrer",
"accountLinked": "{user}: {contactMethod} linked",
"accountUnlinked": "{user}: {contactMethod} removed",
"accountLinked": "{contactMethod} linked: {user}",
"accountUnlinked": "{contactMethod} removed: {user}",
"accountResetPassword": "{user} reset their password",
"accountChangedPassword": "{user} changed their password",
"accountCreated": "Account created: {user}",
@ -146,7 +146,27 @@
"userDeleted": "User was deleted.",
"userDisabled": "User was disabled",
"inviteCreated": "Invite created: {invite}",
"inviteDeleted": "Invite deleted: {invite}"
"inviteDeleted": "Invite deleted: {invite}",
"inviteExpired": "Invite expired: {invite}",
"fromInvite": "From Invite",
"byAdmin": "By Admin",
"byUser": "By User",
"byJfaGo": "By jfa-go",
"activityID": "Activity ID",
"title": "Title",
"usersMentioned": "User mentioned",
"actor": "Actor",
"actorDescription": "The thing that caused this action. <hr class=\"sep\"> \"user\"/\"admin\"/\"daemon\" or a username.",
"accountCreationFilter": "Account Creation",
"accountDeletionFilter": "Account Deletion",
"accountDisabledFilter": "Account Disabled",
"accountEnabledFilter": "Account Enabled",
"contactLinkedFilter": "Contact Linked",
"contactUnlinkedFilter": "Contact Unlinked",
"passwordChangeFilter": "Password Changed",
"passwordResetFilter": "Password Reset",
"inviteCreatedFilter": "Invite Created",
"inviteDeletedFilter": "Invite Deleted/Expired"
},
"notifications": {
"changedEmailAddress": "Changed email address of {n}.",
@ -162,6 +182,7 @@
"telegramVerified": "Telegram account verified.",
"accountConnected": "Account connected.",
"referralsEnabled": "Referrals enabled.",
"activityDeleted": "Activity Deleted.",
"errorSettingsAppliedNoHomescreenLayout": "Settings were applied, but applying homescreen layout may have failed.",
"errorHomescreenAppliedNoSettings": "Homescreen layout was applied, but applying settings may have failed.",
"errorSettingsFailed": "Application failed.",

View File

@ -432,14 +432,16 @@ type EnableDisableReferralDTO struct {
}
type ActivityDTO struct {
ID string `json:"id"`
Type string `json:"type"`
UserID string `json:"user_id"`
SourceType string `json:"source_type"`
Source string `json:"source"`
InviteCode string `json:"invite_code"`
Value string `json:"value"`
Time int64 `json:"time"`
ID string `json:"id"`
Type string `json:"type"`
UserID string `json:"user_id"`
Username string `json:"username"`
SourceType string `json:"source_type"`
Source string `json:"source"`
SourceUsername string `json:"source_username"`
InviteCode string `json:"invite_code"`
Value string `json:"value"`
Time int64 `json:"time"`
}
type GetActivitiesDTO struct {

View File

@ -232,7 +232,8 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
api.DELETE(p+"/profiles/referral/:profile", app.DisableReferralForProfile)
}
api.GET(p+"/activity", app.GetActivities)
api.POST(p+"/activity", app.GetActivities)
api.DELETE(p+"/activity/:id", app.DeleteActivity)
if userPageEnabled {
user.GET("/details", app.MyDetails)

View File

@ -52,8 +52,8 @@ type Activity struct {
UserID string // ID of target user. For account creation, this will be the newly created account
SourceType ActivitySource
Source string
InviteCode string // Only set for ActivityCreation
Value string // Used for ActivityContactLinked, "email/discord/telegram/matrix", and Create/DeleteInvite, where it's the label.
InviteCode string // Set for ActivityCreation, create/deleteInvite
Value string // Used for ActivityContactLinked where it's "email/discord/telegram/matrix", Create/DeleteInvite, where it's the label, and Creation/Deletion, where it's the Username.
Time time.Time
}

View File

@ -3,6 +3,7 @@ import { templateEmail } from "../modules/settings.js";
import { Marked } from "@ts-stack/markdown";
import { stripMarkdown } from "../modules/stripmd.js";
import { DiscordUser, newDiscordSearch } from "../modules/discord.js";
import { Search, SearchConfiguration, QueryType, SearchableItem } from "../modules/search.js";
const dateParser = require("any-date-parser");
interface User {
@ -39,7 +40,7 @@ interface announcementTemplate {
var addDiscord: (passData: string) => void;
class user implements User {
class user implements User, SearchableItem {
private _id = "";
private _row: HTMLTableRowElement;
private _check: HTMLInputElement;
@ -269,7 +270,7 @@ class user implements User {
<span class="chip btn @low"><i class="ri-link" alt="${window.lang.strings("add")}"></i></span>
<input type="text" class="input ~neutral @low stealth-input unfocused" placeholder="@user:riot.im">
</div>
`;
`;
(this._matrix.querySelector("span") as HTMLSpanElement).onclick = this._addMatrix;
} else {
this._notifyDropdown.querySelector(".accounts-area-matrix").classList.remove("unfocused");
@ -780,13 +781,13 @@ export class accountsList {
private _userSelect = document.getElementById("modify-user-users") as HTMLSelectElement;
private _referralsProfileSelect = document.getElementById("enable-referrals-user-profiles") as HTMLSelectElement;
private _referralsInviteSelect = document.getElementById("enable-referrals-user-invites") as HTMLSelectElement;
private _search = document.getElementById("accounts-search") as HTMLInputElement;
private _searchBox = document.getElementById("accounts-search") as HTMLInputElement;
private _search: Search;
private _selectAll = document.getElementById("accounts-select-all") as HTMLInputElement;
private _users: { [id: string]: user };
private _ordering: string[] = [];
private _checkCount: number = 0;
private _inSearch = false;
// Whether the enable/disable button should enable or not.
private _shouldEnable = false;
@ -836,7 +837,7 @@ export class accountsList {
}
}
private _queries: { [field: string]: { name: string, getter: string, bool: boolean, string: boolean, date: boolean, dependsOnTableHeader?: string, show?: boolean }} = {
private _queries: { [field: string]: QueryType } = {
"id": {
// We don't use a translation here to circumvent the name substitution feature.
name: "Jellyfin/Emby ID",
@ -887,7 +888,7 @@ export class accountsList {
bool: true,
string: false,
date: false,
dependsOnTableHeader: "accounts-header-access-jfa"
dependsOnElement: ".accounts-header-access-jfa"
},
"email": {
name: window.lang.strings("emailAddress"),
@ -895,7 +896,7 @@ export class accountsList {
bool: true,
string: true,
date: false,
dependsOnTableHeader: "accounts-header-email"
dependsOnElement: ".accounts-header-email"
},
"telegram": {
name: "Telegram",
@ -903,7 +904,7 @@ export class accountsList {
bool: true,
string: true,
date: false,
dependsOnTableHeader: "accounts-header-telegram"
dependsOnElement: ".accounts-header-telegram"
},
"matrix": {
name: "Matrix",
@ -911,7 +912,7 @@ export class accountsList {
bool: true,
string: true,
date: false,
dependsOnTableHeader: "accounts-header-matrix"
dependsOnElement: ".accounts-header-matrix"
},
"discord": {
name: "Discord",
@ -919,7 +920,7 @@ export class accountsList {
bool: true,
string: true,
date: false,
dependsOnTableHeader: "accounts-header-discord"
dependsOnElement: ".accounts-header-discord"
},
"expiry": {
name: window.lang.strings("expiry"),
@ -927,7 +928,7 @@ export class accountsList {
bool: true,
string: false,
date: true,
dependsOnTableHeader: "accounts-header-expiry"
dependsOnElement: ".accounts-header-expiry"
},
"last-active": {
name: window.lang.strings("lastActiveTime"),
@ -942,229 +943,12 @@ export class accountsList {
bool: true,
string: false,
date: false,
dependsOnTableHeader: "accounts-header-referrals"
dependsOnElement: ".accounts-header-referrals"
}
}
private _notFoundPanel: HTMLElement = document.getElementById("accounts-not-found");
search = (query: String): string[] => {
console.log(this._queries);
this._filterArea.textContent = "";
query = query.toLowerCase();
let result: string[] = [...this._ordering];
// console.log("initial:", result);
// const words = query.split(" ");
let words: string[] = [];
let quoteSymbol = ``;
let queryStart = -1;
let lastQuote = -1;
for (let i = 0; i < query.length; i++) {
if (queryStart == -1 && query[i] != " " && query[i] != `"` && query[i] != `'`) {
queryStart = i;
}
if ((query[i] == `"` || query[i] == `'`) && (quoteSymbol == `` || query[i] == quoteSymbol)) {
if (lastQuote != -1) {
lastQuote = -1;
quoteSymbol = ``;
} else {
lastQuote = i;
quoteSymbol = query[i];
}
}
if (query[i] == " " || i == query.length-1) {
if (lastQuote != -1) {
continue;
} else {
let end = i+1;
if (query[i] == " ") {
end = i;
while (i+1 < query.length && query[i+1] == " ") {
i += 1;
}
}
words.push(query.substring(queryStart, end).replace(/['"]/g, ""));
console.log("pushed", words);
queryStart = -1;
}
}
}
query = "";
for (let word of words) {
if (!word.includes(":")) {
let cachedResult = [...result];
for (let id of cachedResult) {
const u = this._users[id];
if (!u.matchesSearch(word)) {
result.splice(result.indexOf(id), 1);
}
}
continue;
}
const split = [word.substring(0, word.indexOf(":")), word.substring(word.indexOf(":")+1)];
if (!(split[0] in this._queries)) continue;
const queryFormat = this._queries[split[0]];
if (queryFormat.bool) {
let isBool = false;
let boolState = false;
if (split[1] == "true" || split[1] == "yes" || split[1] == "t" || split[1] == "y") {
isBool = true;
boolState = true;
} else if (split[1] == "false" || split[1] == "no" || split[1] == "f" || split[1] == "n") {
isBool = true;
boolState = false;
}
if (isBool) {
const filterCard = document.createElement("span");
filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter");
filterCard.classList.add("button", "~" + (boolState ? "positive" : "critical"), "@high", "center", "mx-2", "h-full");
filterCard.innerHTML = `
<span class="font-bold mr-2">${queryFormat.name}</span>
<i class="text-2xl ri-${boolState? "checkbox" : "close"}-circle-fill"></i>
`;
filterCard.addEventListener("click", () => {
for (let quote of [`"`, `'`, ``]) {
this._search.value = this._search.value.replace(split[0] + ":" + quote + split[1] + quote, "");
}
this._search.oninput((null as Event));
})
this._filterArea.appendChild(filterCard);
// console.log("is bool, state", boolState);
// So removing elements doesn't affect us
let cachedResult = [...result];
for (let id of cachedResult) {
const u = this._users[id];
const value = Object.getOwnPropertyDescriptor(user.prototype, queryFormat.getter).get.call(u);
// console.log("got", queryFormat.getter + ":", value);
// Remove from result if not matching query
if (!((value && boolState) || (!value && !boolState))) {
// console.log("not matching, result is", result);
result.splice(result.indexOf(id), 1);
}
}
continue
}
}
if (queryFormat.string) {
const filterCard = document.createElement("span");
filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter");
filterCard.classList.add("button", "~neutral", "@low", "center", "mx-2", "h-full");
filterCard.innerHTML = `
<span class="font-bold mr-2">${queryFormat.name}:</span> "${split[1]}"
`;
filterCard.addEventListener("click", () => {
for (let quote of [`"`, `'`, ``]) {
let regex = new RegExp(split[0] + ":" + quote + split[1] + quote, "ig");
this._search.value = this._search.value.replace(regex, "");
}
this._search.oninput((null as Event));
})
this._filterArea.appendChild(filterCard);
let cachedResult = [...result];
for (let id of cachedResult) {
const u = this._users[id];
const value = Object.getOwnPropertyDescriptor(user.prototype, queryFormat.getter).get.call(u);
if (!(value.includes(split[1]))) {
result.splice(result.indexOf(id), 1);
}
}
continue;
}
if (queryFormat.date) {
// -1 = Before, 0 = On, 1 = After, 2 = No symbol, assume 0
let compareType = (split[1][0] == ">") ? 1 : ((split[1][0] == "<") ? -1 : ((split[1][0] == "=") ? 0 : 2));
let unmodifiedValue = split[1];
if (compareType != 2) {
split[1] = split[1].substring(1);
}
if (compareType == 2) compareType = 0;
let attempt: { year?: number, month?: number, day?: number, hour?: number, minute?: number } = dateParser.attempt(split[1]);
// Month in Date objects is 0-based, so make our parsed date that way too
if ("month" in attempt) attempt.month -= 1;
let date: Date = (Date as any).fromString(split[1]) as Date;
console.log("Read", attempt, "and", date);
if ("invalid" in (date as any)) continue;
const filterCard = document.createElement("span");
filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter");
filterCard.classList.add("button", "~neutral", "@low", "center", "m-2", "h-full");
filterCard.innerHTML = `
<span class="font-bold mr-2">${queryFormat.name}:</span> ${(compareType == 1) ? window.lang.strings("after")+" " : ((compareType == -1) ? window.lang.strings("before")+" " : "")}${split[1]}
`;
filterCard.addEventListener("click", () => {
for (let quote of [`"`, `'`, ``]) {
let regex = new RegExp(split[0] + ":" + quote + unmodifiedValue + quote, "ig");
this._search.value = this._search.value.replace(regex, "");
}
this._search.oninput((null as Event));
})
this._filterArea.appendChild(filterCard);
let cachedResult = [...result];
for (let id of cachedResult) {
const u = this._users[id];
const unixValue = Object.getOwnPropertyDescriptor(user.prototype, queryFormat.getter).get.call(u);
if (unixValue == 0) {
result.splice(result.indexOf(id), 1);
continue;
}
let value = new Date(unixValue*1000);
const getterPairs: [string, () => number][] = [["year", Date.prototype.getFullYear], ["month", Date.prototype.getMonth], ["day", Date.prototype.getDate], ["hour", Date.prototype.getHours], ["minute", Date.prototype.getMinutes]];
// When doing > or < <time> with no date, we need to ignore the rest of the Date object
if (compareType != 0 && Object.keys(attempt).length == 2 && "hour" in attempt && "minute" in attempt) {
const temp = new Date(date.valueOf());
temp.setHours(value.getHours(), value.getMinutes());
value = temp;
console.log("just hours/minutes workaround, value set to", value);
}
let match = true;
if (compareType == 0) {
for (let pair of getterPairs) {
if (pair[0] in attempt) {
if (compareType == 0 && attempt[pair[0]] != pair[1].call(value)) {
match = false;
break;
}
}
}
} else if (compareType == -1) {
match = (value < date);
} else if (compareType == 1) {
match = (value > date);
}
if (!match) {
result.splice(result.indexOf(id), 1);
}
}
}
}
return result;
};
get selectAll(): boolean { return this._selectAll.checked; }
set selectAll(state: boolean) {
let count = 0;
@ -2014,34 +1798,23 @@ export class accountsList {
this._deleteNotify.checked = false;
}*/
const onchange = () => {
const query = this._search.value;
if (!query) {
// this.setVisibility(this._ordering, true);
this._inSearch = false;
} else {
this._inSearch = true;
// this.setVisibility(this.search(query), true);
}
const results = this.search(query);
this.setVisibility(results, true);
this._checkCheckCount();
this.showHideSearchOptionsHeader();
if (results.length == 0) {
this._notFoundPanel.classList.remove("unfocused");
} else {
this._notFoundPanel.classList.add("unfocused");
let conf: SearchConfiguration = {
filterArea: this._filterArea,
sortingByButton: this._sortingByButton,
searchOptionsHeader: this._searchOptionsHeader,
notFoundPanel: this._notFoundPanel,
filterList: document.getElementById("accounts-filter-list"),
search: this._searchBox,
queries: this._queries,
setVisibility: this.setVisibility,
clearSearchButtonSelector: ".accounts-search-clear",
onSearchCallback: () => {
this._checkCheckCount();
}
};
this._search.oninput = onchange;
const clearSearchButtons = Array.from(document.getElementsByClassName("accounts-search-clear")) as Array<HTMLSpanElement>;
for (let b of clearSearchButtons) {
b.addEventListener("click", () => {
this._search.value = "";
onchange();
});
}
this._search = new Search(conf);
this._search.items = this._users;
this._announceTextarea.onkeyup = this.loadPreview;
addDiscord = newDiscordSearch(window.lang.strings("linkDiscord"), window.lang.strings("searchDiscordUser"), window.lang.strings("add"), (user: DiscordUser, id: string) => {
@ -2088,15 +1861,16 @@ export class accountsList {
document.addEventListener("header-click", (event: CustomEvent) => {
this._ordering = this._columns[event.detail].sort(this._users);
this._search.ordering = this._ordering;
this._activeSortColumn = event.detail;
this._sortingByButton.innerHTML = this._columns[event.detail].buttonContent;
this._sortingByButton.parentElement.classList.remove("hidden");
// console.log("ordering by", event.detail, ": ", this._ordering);
if (!(this._inSearch)) {
if (!(this._search.inSearch)) {
this.setVisibility(this._ordering, true);
this._notFoundPanel.classList.add("unfocused");
} else {
const results = this.search(this._search.value);
const results = this._search.search(this._searchBox.value);
this.setVisibility(results, true);
if (results.length == 0) {
this._notFoundPanel.classList.remove("unfocused");
@ -2110,84 +1884,7 @@ export class accountsList {
defaultSort();
this.showHideSearchOptionsHeader();
const filterList = document.getElementById("accounts-filter-list");
const fillInFilter = (name: string, value: string, offset?: number) => {
this._search.value = name + ":" + value + " " + this._search.value;
this._search.focus();
let newPos = name.length + 1 + value.length;
if (typeof offset !== 'undefined')
newPos += offset;
this._search.setSelectionRange(newPos, newPos);
this._search.oninput(null as any);
};
// Generate filter buttons
for (let queryName of Object.keys(this._queries)) {
const query = this._queries[queryName];
if ("show" in query && !query.show) continue;
if ("dependsOnTableHeader" in query && query.dependsOnTableHeader) {
const el = document.querySelector("."+query.dependsOnTableHeader);
if (el === null) continue;
}
const container = document.createElement("span") as HTMLSpanElement;
container.classList.add("button", "button-xl", "~neutral", "@low", "mb-1", "mr-2");
container.innerHTML = `<span class="mr-2">${query.name}</span>`;
if (query.bool) {
const pos = document.createElement("button") as HTMLButtonElement;
pos.type = "button";
pos.ariaLabel = `Filter by "${query.name}": True`;
pos.classList.add("button", "~positive", "ml-2");
pos.innerHTML = `<i class="ri-checkbox-circle-fill"></i>`;
pos.addEventListener("click", () => fillInFilter(queryName, "true"));
const neg = document.createElement("button") as HTMLButtonElement;
neg.type = "button";
neg.ariaLabel = `Filter by "${query.name}": False`;
neg.classList.add("button", "~critical", "ml-2");
neg.innerHTML = `<i class="ri-close-circle-fill"></i>`;
neg.addEventListener("click", () => fillInFilter(queryName, "false"));
container.appendChild(pos);
container.appendChild(neg);
}
if (query.string) {
const button = document.createElement("button") as HTMLButtonElement;
button.type = "button";
button.classList.add("button", "~urge", "ml-2");
button.innerHTML = `<i class="ri-equal-line mr-2"></i>${window.lang.strings("matchText")}`;
// Position cursor between quotes
button.addEventListener("click", () => fillInFilter(queryName, `""`, -1));
container.appendChild(button);
}
if (query.date) {
const onDate = document.createElement("button") as HTMLButtonElement;
onDate.type = "button";
onDate.classList.add("button", "~urge", "ml-2");
onDate.innerHTML = `<i class="ri-calendar-check-line mr-2"></i>On Date`;
onDate.addEventListener("click", () => fillInFilter(queryName, `"="`, -1));
const beforeDate = document.createElement("button") as HTMLButtonElement;
beforeDate.type = "button";
beforeDate.classList.add("button", "~urge", "ml-2");
beforeDate.innerHTML = `<i class="ri-calendar-check-line mr-2"></i>Before Date`;
beforeDate.addEventListener("click", () => fillInFilter(queryName, `"<"`, -1));
const afterDate = document.createElement("button") as HTMLButtonElement;
afterDate.type = "button";
afterDate.classList.add("button", "~urge", "ml-2");
afterDate.innerHTML = `<i class="ri-calendar-check-line mr-2"></i>After Date`;
afterDate.addEventListener("click", () => fillInFilter(queryName, `">"`, -1));
container.appendChild(onDate);
container.appendChild(beforeDate);
container.appendChild(afterDate);
}
filterList.appendChild(container);
}
this._search.generateFilterList();
}
reload = () => {
@ -2210,11 +1907,12 @@ export class accountsList {
}
// console.log("reload, so sorting by", this._activeSortColumn);
this._ordering = this._columns[this._activeSortColumn].sort(this._users);
if (!(this._inSearch)) {
this._search.ordering = this._ordering;
if (!(this._search.inSearch)) {
this.setVisibility(this._ordering, true);
this._notFoundPanel.classList.add("unfocused");
} else {
const results = this.search(this._search.value);
const results = this._search.search(this._searchBox.value);
if (results.length == 0) {
this._notFoundPanel.classList.remove("unfocused");
} else {

View File

@ -1,14 +1,17 @@
import { _get, toDateString } from "../modules/common.js";
import { _post, _delete, toDateString } from "../modules/common.js";
import { Search, SearchConfiguration, QueryType, SearchableItem } from "../modules/search.js";
export interface activity {
id: string;
type: string;
user_id: string;
source_type: string;
source: string;
invite_code: string;
value: string;
time: number;
id: string;
type: string;
user_id: string;
source_type: string;
source: string;
invite_code: string;
value: string;
time: number;
username: string;
source_username: string;
}
var activityTypeMoods = {
@ -24,9 +27,11 @@ var activityTypeMoods = {
"deleteInvite": -1
};
var moodColours = ["~warning", "~neutral", "~urge"];
// var moodColours = ["~warning", "~neutral", "~urge"];
export class Activity { // FIXME: Add "implements"
export var activityReload = new CustomEvent("activity-reload");
export class Activity implements activity, SearchableItem { // FIXME: Add "implements"
private _card: HTMLElement;
private _title: HTMLElement;
private _time: HTMLElement;
@ -35,27 +40,90 @@ export class Activity { // FIXME: Add "implements"
private _source: HTMLElement;
private _referrer: HTMLElement;
private _expiryTypeBadge: HTMLElement;
private _delete: HTMLElement;
private _act: activity;
_genUserText = (): string => {
return `<span class="font-medium">${this._act.username || this._act.user_id.substring(0, 5)}</span>`;
}
_genSrcUserText = (): string => {
return `<span class="font-medium">${this._act.source_username || this._act.source.substring(0, 5)}</span>`;
}
_genUserLink = (): string => {
return `<a class="hover:underline" href="/accounts/user/${this._act.user_id}">${this._genUserText()}</a>`;
}
_genSrcUserLink = (): string => {
return `<a class="hover:underline" href="/accounts/user/${this._act.source}">${this._genSrcUserText()}</a>`;
}
private _renderInvText = (): string => { return `<span class="font-medium font-mono">${this.value || this.invite_code || "???"}</span>`; }
private _genInvLink = (): string => {
return `<a class="hover:underline" href="/accounts/invites/${this.invite_code}">${this._renderInvText()}</a>`;
}
get accountCreation(): boolean { return this.type == "creation"; }
get accountDeletion(): boolean { return this.type == "deletion"; }
get accountDisabled(): boolean { return this.type == "disabled"; }
get accountEnabled(): boolean { return this.type == "enabled"; }
get contactLinked(): boolean { return this.type == "contactLinked"; }
get contactUnlinked(): boolean { return this.type == "contactUnlinked"; }
get passwordChange(): boolean { return this.type == "changePassword"; }
get passwordReset(): boolean { return this.type == "resetPassword"; }
get inviteCreated(): boolean { return this.type == "createInvite"; }
get inviteDeleted(): boolean { return this.type == "deleteInvite"; }
get mentionedUsers(): string {
return (this.username + " " + this.source_username).toLowerCase();
}
get actor(): string {
let out = this.source_type + " ";
if (this.source_type == "admin" || this.source_type == "user") out += this.source_username;
return out.toLowerCase();
}
get referrer(): string {
if (this.type != "creation" || this.source_type != "user") return "";
return this.source_username.toLowerCase();
}
get type(): string { return this._act.type; }
set type(v: string) {
this._act.type = v;
let mood = activityTypeMoods[v]; // 1 = positive, 0 = neutral, -1 = negative
for (let i = 0; i < moodColours.length; i++) {
for (let el of [this._card, this._delete]) {
el.classList.remove("~warning");
el.classList.remove("~neutral");
el.classList.remove("~urge");
if (mood == -1) {
el.classList.add("~warning");
} else if (mood == 0) {
el.classList.add("~neutral");
} else if (mood == 1) {
el.classList.add("~urge");
}
}
/* for (let i = 0; i < moodColours.length; i++) {
if (i-1 == mood) this._card.classList.add(moodColours[i]);
else this._card.classList.remove(moodColours[i]);
}
} */
if (this.type == "changePassword" || this.type == "resetPassword") {
let innerHTML = ``;
if (this.type == "changePassword") innerHTML = window.lang.strings("accountChangedPassword");
else innerHTML = window.lang.strings("accountResetPassword");
innerHTML = innerHTML.replace("{user}", `<a href="/accounts/user/${this._act.user_id}">FIXME</a>`);
innerHTML = innerHTML.replace("{user}", this._genUserLink());
this._title.innerHTML = innerHTML;
} else if (this.type == "contactLinked" || this.type == "contactUnlinked") {
let platform = this._act.type;
let platform = this.value;
if (platform == "email") {
platform = window.lang.strings("emailAddress");
} else {
@ -64,40 +132,46 @@ export class Activity { // FIXME: Add "implements"
let innerHTML = ``;
if (this.type == "contactLinked") innerHTML = window.lang.strings("accountLinked");
else innerHTML = window.lang.strings("accountUnlinked");
innerHTML = innerHTML.replace("{user}", `<a href="/accounts/user/${this._act.user_id}">FIXME</a>`).replace("{contactMethod}", platform);
innerHTML = innerHTML.replace("{user}", this._genUserLink()).replace("{contactMethod}", platform);
this._title.innerHTML = innerHTML;
} else if (this.type == "creation") {
this._title.innerHTML = window.lang.strings("accountCreated").replace("{user}", `<a href="/accounts/user/${this._act.user_id}">FIXME</a>`);
this._title.innerHTML = window.lang.strings("accountCreated").replace("{user}", this._genUserLink());
if (this.source_type == "user") {
this._referrer.innerHTML = `<span class="supra mr-2">${window.lang.strings("referrer")}</span><a href="/accounts/${this._source}">FIXME</a>`;
this._referrer.innerHTML = `<span class="supra mr-2">${window.lang.strings("referrer")}</span>${this._genSrcUserLink()}`;
} else {
this._referrer.textContent = ``;
}
} else if (this.type == "deletion") {
if (this.source_type == "daemon") {
this._title.innerHTML = window.lang.strings("accountExpired").replace("{user}", `<a href="/accounts/user/${this._act.user_id}">FIXME</a>`);
this._title.innerHTML = window.lang.strings("accountExpired").replace("{user}", this._genUserText());
this._expiryTypeBadge.classList.add("~critical");
this._expiryTypeBadge.classList.remove("~warning");
this._expiryTypeBadge.classList.remove("~info");
this._expiryTypeBadge.textContent = window.lang.strings("deleted");
} else {
this._title.innerHTML = window.lang.strings("accountDeleted").replace("{user}", `<a href="/accounts/user/${this._act.user_id}">FIXME</a>`);
this._title.innerHTML = window.lang.strings("accountDeleted").replace("{user}", this._genUserText());
}
} else if (this.type == "enabled") {
this._title.innerHTML = window.lang.strings("accountReEnabled").replace("{user}", `<a href="/accounts/user/${this._act.user_id}">FIXME</a>`);
this._title.innerHTML = window.lang.strings("accountReEnabled").replace("{user}", this._genUserLink());
} else if (this.type == "disabled") {
if (this.source_type == "daemon") {
this._title.innerHTML = window.lang.strings("accountExpired").replace("{user}", `<a href="/accounts/user/${this._act.user_id}">FIXME</a>`);
this._expiryTypeBadge.classList.add("~warning");
this._title.innerHTML = window.lang.strings("accountExpired").replace("{user}", this._genUserLink());
this._expiryTypeBadge.classList.add("~info");
this._expiryTypeBadge.classList.remove("~critical");
this._expiryTypeBadge.textContent = window.lang.strings("disabled");
} else {
this._title.innerHTML = window.lang.strings("accountDisabled").replace("{user}", `<a href="/accounts/user/${this._act.user_id}">FIXME</a>`);
this._title.innerHTML = window.lang.strings("accountDisabled").replace("{user}", this._genUserLink());
}
} else if (this.type == "createInvite") {
this._title.innerHTML = window.lang.strings("inviteCreated").replace("{invite}", `<a href="/accounts/user/${this.invite_code}">${this.value || this.invite_code}</a>`);
this._title.innerHTML = window.lang.strings("inviteCreated").replace("{invite}", this._genInvLink());
} else if (this.type == "deleteInvite") {
let innerHTML = ``;
if (this.source_type == "daemon") {
innerHTML = window.lang.strings("inviteExpired");
} else {
innerHTML = window.lang.strings("inviteDeleted");
}
this._title.innerHTML = window.lang.strings("inviteDeleted").replace("{invite}", this.value || this.invite_code);
this._title.innerHTML = innerHTML.replace("{invite}", this._renderInvText());
}
/*} else if (this.source_type == "admin") {
@ -118,6 +192,15 @@ export class Activity { // FIXME: Add "implements"
get source_type(): string { return this._act.source_type; }
set source_type(v: string) {
this._act.source_type = v;
if ((this.source_type == "anon" || this.source_type == "user") && this.type == "creation") {
this._sourceType.textContent = window.lang.strings("fromInvite");
} else if (this.source_type == "admin") {
this._sourceType.textContent = window.lang.strings("byAdmin");
} else if (this.source_type == "user" && this.type != "creation") {
this._sourceType.textContent = window.lang.strings("byUser");
} else if (this.source_type == "daemon") {
this._sourceType.textContent = window.lang.strings("byJfaGo");
}
}
get invite_code(): string { return this._act.invite_code; }
@ -130,22 +213,61 @@ export class Activity { // FIXME: Add "implements"
this._act.value = v;
}
get source(): string { return this._act.source; }
set source(v: string) {
this._act.source = v;
if ((this.source_type == "anon" || this.source_type == "user") && this.type == "creation") {
this._source.innerHTML = this._genInvLink();
} else if ((this.source_type == "admin" || this.source_type == "user") && this._act.source != "" && this._act.source_username != "") {
this._source.innerHTML = this._genSrcUserLink();
}
}
get id(): string { return this._act.id; }
set id(v: string) { this._act.id = v; }
get user_id(): string { return this._act.user_id; }
set user_id(v: string) { this._act.user_id = v; }
get username(): string { return this._act.username; }
set username(v: string) { this._act.username = v; }
get source_username(): string { return this._act.source_username; }
set source_username(v: string) { this._act.source_username = v; }
get title(): string { return this._title.textContent; }
matchesSearch = (query: string): boolean => {
// console.log(this.title, "matches", query, ":", this.title.includes(query));
return (
this.title.toLowerCase().includes(query) ||
this.username.toLowerCase().includes(query) ||
this.source_username.toLowerCase().includes(query)
);
}
constructor(act: activity) {
this._card = document.createElement("div");
this._card.classList.add("card", "@low");
this._card.classList.add("card", "@low", "my-2");
this._card.innerHTML = `
<div class="flex justify-between mb-2">
<span class="heading text-2xl activity-title"></span><span class="activity-expiry-type badge"></span>
<span class="text-sm font-medium activity-time" aria-label="${window.lang.strings("date")}"></span>
<div class="flex flex-col md:flex-row justify-between mb-2">
<span class="heading truncate flex-initial md:text-2xl text-xl activity-title"></span>
<div class="flex flex-col flex-none ml-0 md:ml-2">
<span class="font-medium md:text-sm text-xs activity-time" aria-label="${window.lang.strings("date")}"></span>
<span class="activity-expiry-type badge self-start md:self-end mt-1"></span>
</div>
</div>
<div class="flex justify-between">
<div class="flex flex-col md:flex-row justify-between">
<div>
<span class="content supra mr-2 activity-source-type"></span><span class="activity-source"></span>
</div>
<div>
<span class="content activity-referrer"></span>
</div>
<div>
<button class="button @low hover:~critical rounded-full px-1 py-px activity-delete" aria-label="${window.lang.strings("delete")}"><i class="ri-close-line"></i></button>
</div>
</div>
`;
@ -155,6 +277,13 @@ export class Activity { // FIXME: Add "implements"
this._source = this._card.querySelector(".activity-source");
this._referrer = this._card.querySelector(".activity-referrer");
this._expiryTypeBadge = this._card.querySelector(".activity-expiry-type");
this._delete = this._card.querySelector(".activity-delete");
document.addEventListener("timefmt-change", () => {
this.time = this.time;
});
this._delete.addEventListener("click", this.delete);
this.update(act);
}
@ -164,10 +293,20 @@ export class Activity { // FIXME: Add "implements"
this._act = act;
this.source_type = act.source_type;
this.invite_code = act.invite_code;
this.time = act.time;
this.source = act.source;
this.value = act.value;
this.type = act.type;
}
delete = () => _delete("/activity/" + this._act.id, null, (req: XMLHttpRequest) => {
if (req.readyState != 4) return;
if (req.status == 200) {
window.notifications.customSuccess("activityDeleted", window.lang.notif("activityDeleted"));
}
document.dispatchEvent(activityReload);
});
asElement = () => { return this._card; };
}
@ -177,9 +316,35 @@ interface ActivitiesDTO {
export class activityList {
private _activityList: HTMLElement;
private _activities: { [id: string]: Activity } = {};
private _ordering: string[] = [];
private _filterArea = document.getElementById("activity-filter-area");
private _searchOptionsHeader = document.getElementById("activity-search-options-header");
private _sortingByButton = document.getElementById("activity-sort-by-field") as HTMLButtonElement;
private _notFoundPanel = document.getElementById("activity-not-found");
private _searchBox = document.getElementById("activity-search") as HTMLInputElement;
private _search: Search;
setVisibility = (activities: string[], visible: boolean) => {
this._activityList.textContent = ``;
for (let id of this._ordering) {
if (visible && activities.indexOf(id) != -1) {
this._activityList.appendChild(this._activities[id].asElement());
} else if (!visible && activities.indexOf(id) == -1) {
this._activityList.appendChild(this._activities[id].asElement());
}
}
}
reload = () => {
_get("/activity", null, (req: XMLHttpRequest) => {
let send = {
"type": [],
"limit": 60,
"page": 0,
"ascending": false
}
_post("/activity", send, (req: XMLHttpRequest) => {
if (req.readyState != 4) return;
if (req.status != 200) {
window.notifications.customError("loadActivitiesError", window.lang.notif("errorLoadActivities"));
@ -187,16 +352,165 @@ export class activityList {
}
let resp = req.response as ActivitiesDTO;
this._activityList.textContent = ``;
// FIXME: Don't destroy everything each reload!
this._activities = {};
for (let act of resp.activities) {
const activity = new Activity(act);
this._activityList.appendChild(activity.asElement());
this._activities[act.id] = new Activity(act);
this._activityList.appendChild(this._activities[act.id].asElement());
}
});
this._search.items = this._activities;
// FIXME: Actually implement sorting
this._ordering = Object.keys(this._activities);
this._search.ordering = this._ordering;
if (this._search.inSearch) {
const results = this._search.search(this._searchBox.value);
this.setVisibility(results, true);
if (results.length == 0) {
this._notFoundPanel.classList.remove("unfocused");
} else {
this._notFoundPanel.classList.add("unfocused");
}
} else {
this.setVisibility(this._ordering, true);
this._notFoundPanel.classList.add("unfocused");
}
}, true);
}
private _queries: { [field: string]: QueryType } = {
"id": {
name: window.lang.strings("activityID"),
getter: "id",
bool: false,
string: true,
date: false
},
"title": {
name: window.lang.strings("title"),
getter: "title",
bool: false,
string: true,
date: false
},
"user": {
name: window.lang.strings("usersMentioned"),
getter: "mentionedUsers",
bool: false,
string: true,
date: false
},
"actor": {
name: window.lang.strings("actor"),
description: window.lang.strings("actorDescription"),
getter: "actor",
bool: false,
string: true,
date: false
},
"referrer": {
name: window.lang.strings("referrer"),
getter: "referrer",
bool: true,
string: true,
date: false
},
"date": {
name: window.lang.strings("date"),
getter: "date",
bool: false,
string: false,
date: true
},
"account-creation": {
name: window.lang.strings("accountCreationFilter"),
getter: "accountCreation",
bool: true,
string: false,
date: false
},
"account-deletion": {
name: window.lang.strings("accountDeletionFilter"),
getter: "accountDeletion",
bool: true,
string: false,
date: false
},
"account-disabled": {
name: window.lang.strings("accountDisabledFilter"),
getter: "accountDisabled",
bool: true,
string: false,
date: false
},
"account-enabled": {
name: window.lang.strings("accountEnabledFilter"),
getter: "accountEnabled",
bool: true,
string: false,
date: false
},
"contact-linked": {
name: window.lang.strings("contactLinkedFilter"),
getter: "contactLinked",
bool: true,
string: false,
date: false
},
"contact-unlinked": {
name: window.lang.strings("contactUnlinkedFilter"),
getter: "contactUnlinked",
bool: true,
string: false,
date: false
},
"password-change": {
name: window.lang.strings("passwordChangeFilter"),
getter: "passwordChange",
bool: true,
string: false,
date: false
},
"password-reset": {
name: window.lang.strings("passwordResetFilter"),
getter: "passwordReset",
bool: true,
string: false,
date: false
},
"invite-created": {
name: window.lang.strings("inviteCreatedFilter"),
getter: "inviteCreated",
bool: true,
string: false,
date: false
},
"invite-deleted": {
name: window.lang.strings("inviteDeletedFilter"),
getter: "inviteDeleted",
bool: true,
string: false,
date: false
}
};
constructor() {
this._activityList = document.getElementById("activity-card-list");
document.addEventListener("activity-reload", this.reload);
let conf: SearchConfiguration = {
filterArea: this._filterArea,
sortingByButton: this._sortingByButton,
searchOptionsHeader: this._searchOptionsHeader,
notFoundPanel: this._notFoundPanel,
search: this._searchBox,
clearSearchButtonSelector: ".activity-search-clear",
queries: this._queries,
setVisibility: this.setVisibility,
filterList: document.getElementById("activity-filter-list"),
onSearchCallback: () => {}
}
this._search = new Search(conf);
this._search.generateFilterList();
}
}

385
ts/modules/search.ts Normal file
View File

@ -0,0 +1,385 @@
const dateParser = require("any-date-parser");
export interface QueryType {
name: string;
description?: string;
getter: string;
bool: boolean;
string: boolean;
date: boolean;
dependsOnElement?: string; // Format for querySelector
show?: boolean;
}
export interface SearchConfiguration {
filterArea: HTMLElement;
sortingByButton: HTMLButtonElement;
searchOptionsHeader: HTMLElement;
notFoundPanel: HTMLElement;
filterList: HTMLElement;
clearSearchButtonSelector: string;
search: HTMLInputElement;
queries: { [field: string]: QueryType };
setVisibility: (items: string[], visible: boolean) => void;
onSearchCallback: () => void;
}
export interface SearchableItem {
matchesSearch: (query: string) => boolean;
}
export class Search {
private _c: SearchConfiguration;
private _ordering: string[] = [];
private _items: { [id: string]: SearchableItem };
inSearch: boolean;
search = (query: String): string[] => {
this._c.filterArea.textContent = "";
query = query.toLowerCase();
let result: string[] = [...this._ordering];
let words: string[] = [];
let quoteSymbol = ``;
let queryStart = -1;
let lastQuote = -1;
for (let i = 0; i < query.length; i++) {
if (queryStart == -1 && query[i] != " " && query[i] != `"` && query[i] != `'`) {
queryStart = i;
}
if ((query[i] == `"` || query[i] == `'`) && (quoteSymbol == `` || query[i] == quoteSymbol)) {
if (lastQuote != -1) {
lastQuote = -1;
quoteSymbol = ``;
} else {
lastQuote = i;
quoteSymbol = query[i];
}
}
if (query[i] == " " || i == query.length-1) {
if (lastQuote != -1) {
continue;
} else {
let end = i+1;
if (query[i] == " ") {
end = i;
while (i+1 < query.length && query[i+1] == " ") {
i += 1;
}
}
words.push(query.substring(queryStart, end).replace(/['"]/g, ""));
console.log("pushed", words);
queryStart = -1;
}
}
}
query = "";
for (let word of words) {
if (!word.includes(":")) {
let cachedResult = [...result];
for (let id of cachedResult) {
const u = this._items[id];
if (!u.matchesSearch(word)) {
result.splice(result.indexOf(id), 1);
}
}
continue;
}
const split = [word.substring(0, word.indexOf(":")), word.substring(word.indexOf(":")+1)];
if (!(split[0] in this._c.queries)) continue;
const queryFormat = this._c.queries[split[0]];
if (queryFormat.bool) {
let isBool = false;
let boolState = false;
if (split[1] == "true" || split[1] == "yes" || split[1] == "t" || split[1] == "y") {
isBool = true;
boolState = true;
} else if (split[1] == "false" || split[1] == "no" || split[1] == "f" || split[1] == "n") {
isBool = true;
boolState = false;
}
if (isBool) {
const filterCard = document.createElement("span");
filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter");
filterCard.classList.add("button", "~" + (boolState ? "positive" : "critical"), "@high", "center", "mx-2", "h-full");
filterCard.innerHTML = `
<span class="font-bold mr-2">${queryFormat.name}</span>
<i class="text-2xl ri-${boolState? "checkbox" : "close"}-circle-fill"></i>
`;
filterCard.addEventListener("click", () => {
for (let quote of [`"`, `'`, ``]) {
this._c.search.value = this._c.search.value.replace(split[0] + ":" + quote + split[1] + quote, "");
}
this._c.search.oninput((null as Event));
})
this._c.filterArea.appendChild(filterCard);
// console.log("is bool, state", boolState);
// So removing elements doesn't affect us
let cachedResult = [...result];
for (let id of cachedResult) {
const u = this._items[id];
const value = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u);
// console.log("got", queryFormat.getter + ":", value);
// Remove from result if not matching query
if (!((value && boolState) || (!value && !boolState))) {
// console.log("not matching, result is", result);
result.splice(result.indexOf(id), 1);
}
}
continue
}
}
if (queryFormat.string) {
const filterCard = document.createElement("span");
filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter");
filterCard.classList.add("button", "~neutral", "@low", "center", "mx-2", "h-full");
filterCard.innerHTML = `
<span class="font-bold mr-2">${queryFormat.name}:</span> "${split[1]}"
`;
filterCard.addEventListener("click", () => {
for (let quote of [`"`, `'`, ``]) {
let regex = new RegExp(split[0] + ":" + quote + split[1] + quote, "ig");
this._c.search.value = this._c.search.value.replace(regex, "");
}
this._c.search.oninput((null as Event));
})
this._c.filterArea.appendChild(filterCard);
let cachedResult = [...result];
for (let id of cachedResult) {
const u = this._items[id];
const value = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u).toLowerCase();
if (!(value.includes(split[1]))) {
result.splice(result.indexOf(id), 1);
}
}
continue;
}
if (queryFormat.date) {
// -1 = Before, 0 = On, 1 = After, 2 = No symbol, assume 0
let compareType = (split[1][0] == ">") ? 1 : ((split[1][0] == "<") ? -1 : ((split[1][0] == "=") ? 0 : 2));
let unmodifiedValue = split[1];
if (compareType != 2) {
split[1] = split[1].substring(1);
}
if (compareType == 2) compareType = 0;
let attempt: { year?: number, month?: number, day?: number, hour?: number, minute?: number } = dateParser.attempt(split[1]);
// Month in Date objects is 0-based, so make our parsed date that way too
if ("month" in attempt) attempt.month -= 1;
let date: Date = (Date as any).fromString(split[1]) as Date;
console.log("Read", attempt, "and", date);
if ("invalid" in (date as any)) continue;
const filterCard = document.createElement("span");
filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter");
filterCard.classList.add("button", "~neutral", "@low", "center", "m-2", "h-full");
filterCard.innerHTML = `
<span class="font-bold mr-2">${queryFormat.name}:</span> ${(compareType == 1) ? window.lang.strings("after")+" " : ((compareType == -1) ? window.lang.strings("before")+" " : "")}${split[1]}
`;
filterCard.addEventListener("click", () => {
for (let quote of [`"`, `'`, ``]) {
let regex = new RegExp(split[0] + ":" + quote + unmodifiedValue + quote, "ig");
this._c.search.value = this._c.search.value.replace(regex, "");
}
this._c.search.oninput((null as Event));
})
this._c.filterArea.appendChild(filterCard);
let cachedResult = [...result];
for (let id of cachedResult) {
const u = this._items[id];
const unixValue = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u);
if (unixValue == 0) {
result.splice(result.indexOf(id), 1);
continue;
}
let value = new Date(unixValue*1000);
const getterPairs: [string, () => number][] = [["year", Date.prototype.getFullYear], ["month", Date.prototype.getMonth], ["day", Date.prototype.getDate], ["hour", Date.prototype.getHours], ["minute", Date.prototype.getMinutes]];
// When doing > or < <time> with no date, we need to ignore the rest of the Date object
if (compareType != 0 && Object.keys(attempt).length == 2 && "hour" in attempt && "minute" in attempt) {
const temp = new Date(date.valueOf());
temp.setHours(value.getHours(), value.getMinutes());
value = temp;
console.log("just hours/minutes workaround, value set to", value);
}
let match = true;
if (compareType == 0) {
for (let pair of getterPairs) {
if (pair[0] in attempt) {
if (compareType == 0 && attempt[pair[0]] != pair[1].call(value)) {
match = false;
break;
}
}
}
} else if (compareType == -1) {
match = (value < date);
} else if (compareType == 1) {
match = (value > date);
}
if (!match) {
result.splice(result.indexOf(id), 1);
}
}
}
}
return result;
}
showHideSearchOptionsHeader = () => {
const sortingBy = !(this._c.sortingByButton.parentElement.classList.contains("hidden"));
const hasFilters = this._c.filterArea.textContent != "";
console.log("sortingBy", sortingBy, "hasFilters", hasFilters);
if (sortingBy || hasFilters) {
this._c.searchOptionsHeader.classList.remove("hidden");
} else {
this._c.searchOptionsHeader.classList.add("hidden");
}
}
get items(): { [id: string]: SearchableItem } { return this._items; }
set items(v: { [id: string]: SearchableItem }) {
this._items = v;
}
get ordering(): string[] { return this._ordering; }
set ordering(v: string[]) { this._ordering = v; }
onSearchBoxChange = () => {
const query = this._c.search.value;
if (!query) {
this.inSearch = false;
} else {
this.inSearch = true;
}
const results = this.search(query);
this._c.setVisibility(results, true);
this._c.onSearchCallback();
this.showHideSearchOptionsHeader();
if (results.length == 0) {
this._c.notFoundPanel.classList.remove("unfocused");
} else {
this._c.notFoundPanel.classList.add("unfocused");
}
}
fillInFilter = (name: string, value: string, offset?: number) => {
this._c.search.value = name + ":" + value + " " + this._c.search.value;
this._c.search.focus();
let newPos = name.length + 1 + value.length;
if (typeof offset !== 'undefined')
newPos += offset;
this._c.search.setSelectionRange(newPos, newPos);
this._c.search.oninput(null as any);
};
generateFilterList = () => {
// Generate filter buttons
for (let queryName of Object.keys(this._c.queries)) {
const query = this._c.queries[queryName];
if ("show" in query && !query.show) continue;
if ("dependsOnElement" in query && query.dependsOnElement) {
const el = document.querySelector(query.dependsOnElement);
if (el === null) continue;
}
const container = document.createElement("span") as HTMLSpanElement;
container.classList.add("button", "button-xl", "~neutral", "@low", "mb-1", "mr-2");
container.innerHTML = `
<div class="flex flex-col mr-2">
<span>${query.name}</span>
<span class="support">${query.description || ""}</span>
</div>
`;
if (query.bool) {
const pos = document.createElement("button") as HTMLButtonElement;
pos.type = "button";
pos.ariaLabel = `Filter by "${query.name}": True`;
pos.classList.add("button", "~positive", "ml-2");
pos.innerHTML = `<i class="ri-checkbox-circle-fill"></i>`;
pos.addEventListener("click", () => this.fillInFilter(queryName, "true"));
const neg = document.createElement("button") as HTMLButtonElement;
neg.type = "button";
neg.ariaLabel = `Filter by "${query.name}": False`;
neg.classList.add("button", "~critical", "ml-2");
neg.innerHTML = `<i class="ri-close-circle-fill"></i>`;
neg.addEventListener("click", () => this.fillInFilter(queryName, "false"));
container.appendChild(pos);
container.appendChild(neg);
}
if (query.string) {
const button = document.createElement("button") as HTMLButtonElement;
button.type = "button";
button.classList.add("button", "~urge", "ml-2");
button.innerHTML = `<i class="ri-equal-line mr-2"></i>${window.lang.strings("matchText")}`;
// Position cursor between quotes
button.addEventListener("click", () => this.fillInFilter(queryName, `""`, -1));
container.appendChild(button);
}
if (query.date) {
const onDate = document.createElement("button") as HTMLButtonElement;
onDate.type = "button";
onDate.classList.add("button", "~urge", "ml-2");
onDate.innerHTML = `<i class="ri-calendar-check-line mr-2"></i>On Date`;
onDate.addEventListener("click", () => this.fillInFilter(queryName, `"="`, -1));
const beforeDate = document.createElement("button") as HTMLButtonElement;
beforeDate.type = "button";
beforeDate.classList.add("button", "~urge", "ml-2");
beforeDate.innerHTML = `<i class="ri-calendar-check-line mr-2"></i>Before Date`;
beforeDate.addEventListener("click", () => this.fillInFilter(queryName, `"<"`, -1));
const afterDate = document.createElement("button") as HTMLButtonElement;
afterDate.type = "button";
afterDate.classList.add("button", "~urge", "ml-2");
afterDate.innerHTML = `<i class="ri-calendar-check-line mr-2"></i>After Date`;
afterDate.addEventListener("click", () => this.fillInFilter(queryName, `">"`, -1));
container.appendChild(onDate);
container.appendChild(beforeDate);
container.appendChild(afterDate);
}
this._c.filterList.appendChild(container);
}
}
constructor(c: SearchConfiguration) {
this._c = c;
this._c.search.oninput = this.onSearchBoxChange;
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.onSearchBoxChange();
});
}
}
}

View File

@ -61,10 +61,10 @@ func (app *appContext) checkUsers() {
return
}
mode := "disable"
termPlural := "Disabling"
term := "Disabling"
if app.config.Section("user_expiry").Key("behaviour").MustString("disable_user") == "delete_user" {
mode = "delete"
termPlural = "Deleting"
term = "Deleting"
}
contact := false
if messagesEnabled && app.config.Section("user_expiry").Key("send_email").MustBool(true) {
@ -95,7 +95,7 @@ func (app *appContext) checkUsers() {
app.storage.DeleteUserExpiryKey(expiry.JellyfinID)
continue
}
app.info.Printf("%s expired user \"%s\"", termPlural, user.Name)
app.info.Printf("%s expired user \"%s\"", term, user.Name)
// Record activity
activity := Activity{
@ -107,6 +107,7 @@ func (app *appContext) checkUsers() {
if mode == "delete" {
status, err = app.jf.DeleteUser(id)
activity.Type = ActivityDeletion
activity.Value = user.Name
} else if mode == "disable" {
user.Policy.IsDisabled = true
// Admins can't be disabled