From b66c6f173d2dde35250d40c405f567ea5f0d33cd Mon Sep 17 00:00:00 2001 From: Corban-Lee Jones Date: Wed, 19 Feb 2025 00:29:20 +0000 Subject: [PATCH] add filters --- src/client/public/css/tailwind.css | 34 +- src/client/public/js/guild/filters.js | 266 +++++++++++++++ src/client/public/js/guild/subscriptions.js | 11 +- src/client/views/guild/filters.ejs | 310 +++++++++++++++++- src/client/views/guild/subscriptions.ejs | 14 +- .../20250213221612_create_filters.ts | 25 ++ src/db/models/filters.model.ts | 12 + .../guild/filter.api.controller.ts | 63 ++++ src/server/routes/guild.api.routes.ts | 7 + 9 files changed, 690 insertions(+), 52 deletions(-) create mode 100644 src/client/public/js/guild/filters.js create mode 100644 src/db/migrations/20250213221612_create_filters.ts create mode 100644 src/db/models/filters.model.ts create mode 100644 src/server/controllers/guild/filter.api.controller.ts diff --git a/src/client/public/css/tailwind.css b/src/client/public/css/tailwind.css index 53953b7..b7e4843 100644 --- a/src/client/public/css/tailwind.css +++ b/src/client/public/css/tailwind.css @@ -1383,6 +1383,10 @@ video { resize: both; } +.grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); +} + .flex-row { flex-direction: row; } @@ -1806,11 +1810,6 @@ video { padding-bottom: 0.375rem; } -.py-10 { - padding-top: 2.5rem; - padding-bottom: 2.5rem; -} - .py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; @@ -2813,11 +2812,6 @@ hs-accordion-toggle w-full text-start flex items-center gap-x-3.5 py-2 px-2.5 te background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1)); } -.hover\:bg-gray-200:hover { - --tw-bg-opacity: 1; - background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1)); -} - .hover\:bg-gray-300:hover { --tw-bg-opacity: 1; background-color: rgb(209 213 219 / var(--tw-bg-opacity, 1)); @@ -2858,11 +2852,6 @@ hs-accordion-toggle w-full text-start flex items-center gap-x-3.5 py-2 px-2.5 te background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1)); } -.focus\:bg-gray-200:focus { - --tw-bg-opacity: 1; - background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1)); -} - .focus\:bg-gray-50:focus { --tw-bg-opacity: 1; background-color: rgb(249 250 251 / var(--tw-bg-opacity, 1)); @@ -3449,11 +3438,6 @@ hs-accordion-toggle w-full text-start flex items-center gap-x-3.5 py-2 px-2.5 te padding-right: 2rem; } - .lg\:py-14 { - padding-top: 3.5rem; - padding-bottom: 3.5rem; - } - .lg\:ps-64 { padding-inline-start: 16rem; } @@ -3656,11 +3640,6 @@ hs-accordion-toggle w-full text-start flex items-center gap-x-3.5 py-2 px-2.5 te background-color: rgb(191 219 254 / var(--tw-bg-opacity, 1)); } -.dark\:hover\:bg-neutral-600:hover:where(.dark, .dark *) { - --tw-bg-opacity: 1; - background-color: rgb(82 82 82 / var(--tw-bg-opacity, 1)); -} - .dark\:hover\:bg-neutral-700:hover:where(.dark, .dark *) { --tw-bg-opacity: 1; background-color: rgb(64 64 64 / var(--tw-bg-opacity, 1)); @@ -3696,11 +3675,6 @@ hs-accordion-toggle w-full text-start flex items-center gap-x-3.5 py-2 px-2.5 te color: rgb(115 115 115 / var(--tw-text-opacity, 1)); } -.dark\:focus\:bg-neutral-600:focus:where(.dark, .dark *) { - --tw-bg-opacity: 1; - background-color: rgb(82 82 82 / var(--tw-bg-opacity, 1)); -} - .dark\:focus\:bg-neutral-700:focus:where(.dark, .dark *) { --tw-bg-opacity: 1; background-color: rgb(64 64 64 / var(--tw-bg-opacity, 1)); diff --git a/src/client/public/js/guild/filters.js b/src/client/public/js/guild/filters.js new file mode 100644 index 0000000..267c268 --- /dev/null +++ b/src/client/public/js/guild/filters.js @@ -0,0 +1,266 @@ + +const formatTimestamp = timestamp => { + let d; + if (typeof timestamp === "string") { + d = new Date(timestamp.replace(" ", "T")); + } + else { + d = new Date(timestamp); + } + + const now = new Date(); + + // If younger than a year, show time + // otherwise show the year + return now - d < 31536000000 + ? `${d.getDate()} ${d.toLocaleString("en-GB", { month: "short" })}, ${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}` + : `${d.getDate()} ${d.toLocaleString("en-GB", { month: "short" })} ${d.getFullYear()}`; +} + +const emptyTableHtml = ` +
+
+ +
+

+ No results found +

+

+ Create a filter and it will appear here. +

+ +
+ + +
+
+`; + + +var table; +const defineTable = () => { + table = new HSDataTable("#table", { + ajax: { + url: `/guild/${guildId}/filters/api/datatable`, + dataSrc: "data", + data: (d) => { + if (d === undefined) { return ;} + + d.filters = {}; + const is_whitelist = $("input[name='filterType']:checked").val(); + d.filters.is_whitelist = is_whitelist; + } + }, + serverSide: true, + processing: true, + selecting: true, + pagingOptions: { + pageBtnClasses: "hidden" + }, + rowSelectingOptions: { + selectAllSelector: "#selectAllBox" + }, + language: { + zeroRecords: emptyTableHtml, + emptyTable: emptyTableHtml, + loading: "Placeholder Loading Message...", + }, + rowCallback: (row, data, index) => { + $(row).addClass("bg-white dark:bg-neutral-900"); + }, + drawCallback: () => { + HSDropdown.autoInit(); + }, + select: { + style: "multi", + selector: "td:first-child input[type='checkbox']" + }, + columnDefs: [ + { + // Row select checkbox + targets: 0, + orderable: false, + searchable: false, + render: (data, type, row) => { + return ` + +
+ +
+ + `; + } + }, + { + // Name + targets: 1, + data: "name", + orderable: true, + searchable: true, + render: data => { + return ` + + + ${data} + + + `; + } + }, + { + // Match + targets: 2, + data: "match", + orderable: true, + searchable: true, + render: data => { + return ` + + + ${data} + + + `; + } + }, + { + // Algorithm + target: 3, + data: "algorithm", + orderable: true, + searchable: true, + render: (data, type, row) => { + return ` + + + ${data} + + + `; + } + }, + { + // Filters + target: 4, + data: "is_insensitive", + orderable: true, + searchable: true, + render: (data, type, row) => { + return ` + + + ${data} + + + `; + } + }, + { + // Whitelist + target: 5, + data: "is_whitelist", + orderable: true, + searchable: true, + render: (data, type, row) => { + return ` + + + ${data} + + + `; + } + }, + { + // Created At + target: 6, + data: "created_at", + orderable: true, + searchable: true, + render: data => { + return ` + +
+ + ${formatTimestamp(data)} + +
+ + `; + } + } + ] + }) + + table.dataTable + .on("select", onTableSelectChange) + .on("deselect", onTableSelectChange) + .on("draw", onTableSelectChange); +} + +// Ensure the datatable recognises when all rows are selected, otherwise rows are only visually selected +$("#selectAllBox").on("change", function() { + this.checked ? table.dataTable.rows().select() : table.dataTable.rows().deselect(); +}); + +const onTableSelectChange = () => { + const selectedRowCount = table.dataTable.rows({ selected: true }).count(); + $("#deleteRowsBtn").prop("disabled", selectedRowCount === 0); + $(".rows-selected-count-js").text(selectedRowCount); + + const $elem = $(".rows-selected-count-js.zero-empty-js"); + selectedRowCount === 0 ? $elem.hide() : $elem.show(); +} + +$(window).ready(() => { + setTimeout(defineTable, 500); +}); + +$("input[name='filterType']").on("change", () => { + table.dataTable.draw(); +}); + +const openFilterForm = () => { + $("#filterForm").removeClass("submitted"); + HSOverlay.open($("#filterModal").get(0)); +} + +const closeFilterForm = () => { + $("#filterForm").removeClass("submitted"); + HSOverlay.close($("#filterModal").get(0)); +} + +$(document).on("click", ".openfilterModal-js", openFilterForm); + +const submitForm = async event => { + event.preventDefault(); + + const form = $(event.target).get(0); + $(form).addClass("submitted"); + + if (!form.checkValidity()) { return; } + + await $.ajax({ + url: `/guild/${guildId}/filters/api`, + method: "post", + dataType: "json", + data: $(event.target).serializeArray(), + success: () => { + table.dataTable.draw(); + closeFilterForm(); + }, + error: error => { + alert(JSON.stringify(error, null, 4)); + } + }); +} + +$("#filterForm").on("submit", submitForm); diff --git a/src/client/public/js/guild/subscriptions.js b/src/client/public/js/guild/subscriptions.js index 2f508be..a3ec25f 100644 --- a/src/client/public/js/guild/subscriptions.js +++ b/src/client/public/js/guild/subscriptions.js @@ -217,8 +217,7 @@ const defineTable = () => { `; } - } - , + }, { // Status target: 7, @@ -297,13 +296,13 @@ $(window).ready(() => { $("input[name='filterActive']").on("change", () => { table.dataTable.draw(); -}) +}); const openSubForm = () => { $("#subForm").removeClass("submitted"); $("#formPublishedThreshold").val(new Date().toISOString().slice(0, 16)); $("#formActive").prop("checked", true); - HSOverlay.open($("#subModal").get(0)) + HSOverlay.open($("#subModal").get(0)); } const closeSubForm = () => { @@ -316,7 +315,7 @@ $(document).on("click", ".openSubModal-js", openSubForm); const submitForm = async event => { event.preventDefault(); - const form = $("#subForm").get(0); + const form = $(event.target).get(0); $(form).addClass("submitted"); if (!form.checkValidity()) { return; } @@ -331,7 +330,7 @@ const submitForm = async event => { closeSubForm(); }, error: error => { - alert(error); + alert(JSON.stringify(error, null, 4)); } }); } diff --git a/src/client/views/guild/filters.ejs b/src/client/views/guild/filters.ejs index 16ef9e4..873f42f 100644 --- a/src/client/views/guild/filters.ejs +++ b/src/client/views/guild/filters.ejs @@ -2,6 +2,310 @@ <%- include("guildHeader") -%> -
- Filters -
\ No newline at end of file + +
+ +
+
+
+
+ +
+ + + + +
+
+ + + +
+ + +
+ + + +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ + Name + + + + + +
+
+
+ + Match + + + + + +
+
+
+ + Algorithm + + + + + +
+
+
+ + Case-Sensitive + + + + + +
+
+
+ + Type + + + + + +
+
+
+ + Created at + + + + + +
+
+ +
+ + +
+
+ +
+ + + +
+ +
+ +
+
+ +
+
+
+
+ + +
+ + + + + + + + +<% block("scripts").append(''); %> \ No newline at end of file diff --git a/src/client/views/guild/subscriptions.ejs b/src/client/views/guild/subscriptions.ejs index 2e35c4d..5d2ecdd 100644 --- a/src/client/views/guild/subscriptions.ejs +++ b/src/client/views/guild/subscriptions.ejs @@ -3,7 +3,7 @@ <%- include("guildHeader") -%> -
+
@@ -238,18 +238,6 @@ Manage your RSS feeds with filters and channel targets.

-
diff --git a/src/db/migrations/20250213221612_create_filters.ts b/src/db/migrations/20250213221612_create_filters.ts new file mode 100644 index 0000000..66089b4 --- /dev/null +++ b/src/db/migrations/20250213221612_create_filters.ts @@ -0,0 +1,25 @@ +import type { Knex } from "knex"; + +const TABLE = "filters"; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable(TABLE, table => { + table.increments("id").primary(); + table.string("guild_id").notNullable(); + table.string("name").notNullable(); + table.string("match").notNullable(); + table.enum("algorithm", [ + "any", "all", "exact", + "regex", "fuzzy" + ]).notNullable(); + table.boolean("is_insensitive").notNullable(); + table.boolean("is_whitelist").notNullable(); + table.timestamps(true, true); + }); +} + + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TABLE); +} + diff --git a/src/db/models/filters.model.ts b/src/db/models/filters.model.ts new file mode 100644 index 0000000..662eec0 --- /dev/null +++ b/src/db/models/filters.model.ts @@ -0,0 +1,12 @@ + +export interface Filter { + id: number; + guild_id: string; + name: string; + match: string; + algorithm: string; + is_insensitive: boolean; + is_whitelist: boolean; + created_at: Date; + updated_at: Date; +} \ No newline at end of file diff --git a/src/server/controllers/guild/filter.api.controller.ts b/src/server/controllers/guild/filter.api.controller.ts new file mode 100644 index 0000000..ba76131 --- /dev/null +++ b/src/server/controllers/guild/filter.api.controller.ts @@ -0,0 +1,63 @@ +import { Request, Response } from "express"; +import { buildDatatableQuery } from "@utils/datatable"; +import { db } from "@db/db"; +import { Filter } from "@db/models/filters.model"; + +const isPostgres = db.client.config.client === "pg"; +const TABLE = "filters"; + +export const datatable = async (request: Request, response: Response) => { + try { + + let query = db(TABLE).where({ guild_id: request.params.guildId }); + + const datatableQuery = await buildDatatableQuery(request as any, query, TABLE); + const { recordsTotal, recordsFiltered } = datatableQuery; + const data = await datatableQuery.query; + + console.debug(`total: ${recordsTotal} filtered: ${recordsFiltered} filtered+paged: ${data.length}`); + + response.json({ + data, + recordsFiltered, + recordsTotal, + }); + } + catch (error) { + console.error(error); + response.status(500).json({ error: "Failed to fetch datatable for filters" }); + } +} + +export const post = async (request: Request, response: Response) => { + try { + console.debug(JSON.stringify(request.body, null, 4)); + + const guild_id = request.params.guildId; + const { name, match, algorithm, is_insensitive, is_whitelist } = request.body; + + if (!name || !match || !algorithm) { + response.status(400).json({ error: "Missing required fields" }); + return; + } + + const [filter] = await db(TABLE) + .insert({ + guild_id, + name, + match, + algorithm, + is_insensitive: is_insensitive == "on", + is_whitelist: is_whitelist == "on" + }) + .returning("*"); + + response.status(201).json(filter); + } + catch (error) { + console.error(error); + response.status(500).json({ error: "Failed to create Filter" }); + } +} + +export default { datatable, post } diff --git a/src/server/routes/guild.api.routes.ts b/src/server/routes/guild.api.routes.ts index 39eec6e..e908c63 100644 --- a/src/server/routes/guild.api.routes.ts +++ b/src/server/routes/guild.api.routes.ts @@ -1,11 +1,18 @@ import { Router } from "express"; import subApiController from "@server/controllers/guild/sub.api.controller"; +import filterApiController from "@server/controllers/guild/filter.api.controller"; const router = Router(); + router.get("/:guildId/subscriptions/api/datatable", subApiController.datatable); router.get("/:guildId/subscriptions/api", subApiController.get); router.post("/:guildId/subscriptions/api", subApiController.post); router.delete("/:guildId/subscriptions/api", subApiController.del); +router.get("/:guildId/filters/api/datatable", filterApiController.datatable); +// router.get("/:guildId/filters/api", filterApiController.get); +router.post("/:guildId/filters/api", filterApiController.post); +// router.delete("/:guildId/filters/api", filterApiController.del); + export default router; \ No newline at end of file