refactor: entire feeds front-end js
Some checks failed
Build / build (push) Has been cancelled

This commit is contained in:
Corban-Lee Jones 2025-05-09 15:56:30 +01:00
parent 816da70229
commit 31c4779bf2
3 changed files with 119 additions and 794 deletions

View File

@ -1,45 +1,18 @@
import $ from "jquery";
import HSDropdown from "@preline/dropdown";
import HSOverlay, { IOverlayOptions } from "@preline/overlay";
import HSSelect, { ISelectOptions } from "@preline/select";
import HSDataTable, { IDataTableOptions } from "@preline/datatable";
import DataTable, { Api, ConfigColumnDefs, AjaxSettings } from "datatables.net-dt";
import { autoUpdate, computePosition, offset } from "@floating-ui/dom";
import { formatTimestamp, verifyChannels } from "../../../src/ts/main";
import { formatTimestamp, verifyChannels } from "../main";
import HSDropdown from "preline/dist/dropdown";
import HSDataTable, { IDataTableOptions } from "preline/dist/datatable";
import HSSelect, { ISelectOptions } from "preline/dist/select";
import HSOverlay, { IOverlayOptions } from "preline/dist/overlay";
import HSDatepicker, { ICustomDatepickerOptions } from "preline/dist/datepicker";
import { AjaxSettings, ConfigColumnDefs } from "datatables.net-dt";
import prisma from "../../../../../generated/prisma";
import HSDatepicker, { ICustomDatepickerOptions } from "@preline/datepicker";
// import "preline";
// import _ from "lodash";
// import noUiSlider from "nouislider";
// import "datatables.net";
// import "dropzone/dist/dropzone-min.js";
// import * as VanillaCalendarPro from "vanilla-calendar-pro";
// // Preline requirements
// window._ = _;
// window.$ = $;
// window.jQuery = $;
// window.DataTable = $.fn.dataTable;
// window.noUiSlider = noUiSlider;
// window.VanillaCalendarPro = VanillaCalendarPro;
// window.HSStaticMethods.autoInit();
// // document.addEventListener("DOMContentLoaded", () => {
// // if (window.HSStaticMethods && typeof window.HSStaticMethods.autoInit === "function") {
// // } else {
// // console.warn("Preline is not available on window.HSStaticMethods.");
// // }
// // });
import { ISingleOption } from "preline";
import { TextChannel } from "discord.js";
declare let guildId: string;
declare let channels: Array<any>;
// #region DataTable
//
const emptyTableHtml: string = `
<div class="max-w-md w-full min-h-[400px] flex flex-col justify-center mx-auto px-6 py-4">
@ -70,8 +43,6 @@ const columnDefs: ConfigColumnDefs[] = [
// Select checkbox column
{
target: 0,
orderable: false,
searchable: false,
className: "size-px whitespace-nowrap",
render: (_data: unknown, _type: unknown, row: prisma.Feed) => { return `
<div class="ps-6 py-4">
@ -109,8 +80,6 @@ const columnDefs: ConfigColumnDefs[] = [
{
target: 3,
data: "channels",
orderable: false,
searchable: false,
className: "size-px",
render: (data: prisma.Channel[], type: string, row: prisma.Feed) => {
if (type !== "display") { return data; }
@ -159,8 +128,6 @@ const columnDefs: ConfigColumnDefs[] = [
{
target: 4,
data: "filters",
orderable: false,
searchable: false,
className: "size-px whitespace-nowrap",
render: (data: prisma.Filter[], type: string, row: prisma.Feed) => {
if (type !== "display") return data;
@ -200,8 +167,6 @@ const columnDefs: ConfigColumnDefs[] = [
{
target: 5,
data: null, // "message_style_id"
orderable: false,
searchable: false,
className: "size-px whitespace-nowrap",
render: (_data: unknown, type: string, row: any) => {
if (!row.message_style || type !== "display") return null;
@ -221,7 +186,6 @@ const columnDefs: ConfigColumnDefs[] = [
target: 6,
data: "created_at",
orderable: true,
searchable: false,
className: "size-px whitespace-nowrap",
render: (data: string) => { return `
<div class="px-6 py-4">
@ -235,7 +199,6 @@ const columnDefs: ConfigColumnDefs[] = [
target: 7,
data: "active",
orderable: true,
searchable: false,
className: "size-px whitespace-nowrap",
render: (data: boolean) => {
const wrapper = $("<div>").addClass("px-6 py-4");
@ -292,68 +255,18 @@ const tableOptions: IDataTableOptions = {
}
};
const table: HSDataTable = new HSDataTable(
$("#table").get(0) as HTMLElement,
tableOptions
);
let table: HSDataTable;
const onTableSelectChange = () => {
const selectedRowsCount = (table as any).dataTable.rows({ selected: true }).count();
$("#deleteRowsBtn").prop("disabled", selectedRowsCount === 0);
$(".rows-selected-count-js").text(selectedRowsCount);
const $elem = $(".rows-selected-count-js.zero-empty-js");
selectedRowsCount === 0 ? $elem.hide() : $elem.show();
};
(table as any).dataTable
.on("select", onTableSelectChange)
.on("deselect", onTableSelectChange)
.on("draw", onTableSelectChange);
$("#selectAllBox").on("change", function() {
(this as HTMLInputElement).checked
? (table as any).dataTable.rows().select()
: (table as any).dataTable.rows().deselect();
});
$("#deleteRowsBtn").on("click", async () => {
const dt: Api = (table as any).dataTable;
const rowsData = dt.rows({ selected: true }).data().toArray();
const rowIds = rowsData.map((row: prisma.Feed) => row.id);
await $.ajax({
url: `/guild/${guildId}/feeds/api`,
method: "delete",
dataType: "json",
data: { ids: rowIds },
success: () => {
dt.draw();
dt.rows().deselect();
},
error: error => {
alert(typeof error === "object" ? JSON.stringify(error, null, 4) : error);
}
});
window.addEventListener("preline:ready", () => {
const tableEl = $("#table").get(0);
if (!HSDataTable.getInstance(tableEl, true)) {
table = new HSDataTable(tableEl, tableOptions);
}
});
// #endregion
// #region Page Size Select
// https://preline.co/plugins/html/advanced-select.html
(window as any)["FloatingUIDOM"] = {
computePosition: computePosition,
autoUpdate: autoUpdate,
offset: offset
};
// Close on click.
window.addEventListener('click', (evt) => {
const evtTarget = evt.target;
HSSelect.closeCurrentlyOpened(evtTarget as HTMLElement);
});
// #region Table Paging Select
const pageSelectOptions: ISelectOptions = {
toggleTag: '<button type="button" aria-expanded="false"></button>',
@ -373,31 +286,50 @@ const pageSelectOptions: ISelectOptions = {
dropdownVerticalFixedPlacement: null
};
const pageSizeSelect: HSSelect = new HSSelect(
$("#selectPageSize-js").get(0) as HTMLElement,
pageSelectOptions
);
window.addEventListener("preline:ready", () => {
const selectEl = $("#selectPageSize-js").get(0);
if (!HSSelect.getInstance(selectEl, true)) {
new HSSelect(selectEl, pageSelectOptions);
}
});
// #endregion
// #region Edit Modal
(window as any).$hsOverlayCollection = [];
const closeEditModal = () => { editModal.close() };
const editModalOptions: IOverlayOptions = {};
const openEditModal = async (id: number | undefined) => {
$("#editForm").removeClass("submitted");
editModal.open();
const editModal: HSOverlay = new HSOverlay(
$("#editModal").get(0) as HTMLElement,
editModalOptions
);
id === undefined
? clearEditModalData()
: loadEditModalData(id);
};
$(document).on("click", ".open-edit-modal-js", async event => {
await openEditModal($(event.target).data("id"));
});
const editModalOptions: IOverlayOptions = {};
let editModal: HSOverlay;
window.addEventListener("preline:ready", () => {
const modalEl = $("#editModal").get(0);
if (!HSOverlay.getInstance(modalEl, true)) {
editModal = new HSOverlay(modalEl, editModalOptions);
}
});
// #endregion
// #region Edit Form
interface ExpandedFeed extends prisma.Feed {
channels: prisma.Channel[],
filters: prisma.Feed[]
channels: prisma.Channel[];
filters: prisma.Feed[];
}
const clearEditModalData = () => {
@ -405,6 +337,7 @@ const clearEditModalData = () => {
$("#formName").val("");
$("#formUrl").val("");
$("#formPublishedThreshold").val(new Date().toISOString().slice(0, 16));
$("#formActive").prop("checked", true);
channelSelect.setValue([]);
filterSelect.setValue([]);
@ -419,8 +352,11 @@ const loadEditModalData = async (id: number) => {
$(editModal.el).data("id", feed.id);
const publishedThreshold = new Date(feed.published_threshold as unknown as string)
$("#formName").val(feed.name);
$("#formUrl").val(feed.url);
$("#formPublishedThreshold").val(publishedThreshold.toISOString().slice(0, 16));
$("#formActive").prop("checked", feed.active);
channelSelect.setValue(feed.channels.map(channel => channel.channel_id));
@ -428,25 +364,44 @@ const loadEditModalData = async (id: number) => {
styleSelect.setValue(`${feed.message_style_id}`);
}
const openEditModal = async (id: number | undefined) => {
// ISSUE:
// The calculation with `channelSelect.setValue([])` assumes components are visible
// when determining the width of the input placeholder 'Select option...'. This
// requires the modal to be opened before running the calculation, which could be
// bad.
$("#editForm").removeClass("submitted");
editModal.open();
$("#editForm").on("submit", async event => {
event.preventDefault();
id === undefined
? clearEditModalData()
: loadEditModalData(id);
};
const form = $(event.target).get(0) as HTMLFormElement;
$(form).addClass("submitted");
const closeEditModal = () => {
editModal.close();
};
const validity = form.checkValidity();
if (!validity) {
console.debug(`Submit form invalid: ${validity}`);
return;
};
const channelsSelectOptions: ISelectOptions = {
let method = "post";
const data = $(event.target).serializeArray();
// If 'id' has a value, we are patching an existing entry
const id: number | undefined = $(editModal.el).data("id");
if (id !== undefined) {
data.push({ name: "id", value: `${id}` });
method = "patch";
}
await $.ajax({
url: `/guild/${guildId}/feeds/api`,
dataType: "json",
method: method,
data: data,
success: () => {
(table as any).dataTable.draw()
closeEditModal();
},
error: error => {
alert(JSON.stringify(error, null, 4));
}
});
});
const channelSelectOptions: ISelectOptions = {
placeholder: "Select option....",
mode: "tags",
@ -487,11 +442,6 @@ const channelsSelectOptions: ISelectOptions = {
searchNoResultClasses: "cj-tag-select-search-no-results",
};
const channelSelect = new HSSelect(
$("#formChannels").get(0) as HTMLElement,
channelsSelectOptions
);
const filterSelectOptions: ISelectOptions = {
placeholder: "Select option....",
mode: "tags",
@ -549,11 +499,6 @@ const filterSelectOptions: ISelectOptions = {
};
const filterSelect = new HSSelect(
$("#formFilters").get(0),
filterSelectOptions
);
const styleSelectOptions: ISelectOptions = {
placeholder: "Select option...",
@ -588,56 +533,40 @@ const styleSelectOptions: ISelectOptions = {
optionAllowEmptyOption: true
};
const styleSelect = new HSSelect(
$("#formMessageStyle").get(0),
styleSelectOptions
);
let channelSelect: HSSelect;
let filterSelect: HSSelect;
let styleSelect: HSSelect;
const publishedThresholdOptions: ICustomDatepickerOptions = {
type: "default",
dateMax: "2050-00-00",
mode: "default",
layouts: {
window.addEventListener("preline:ready", () => {
const exists = (element: HTMLElement) => HSSelect.getInstance(element, true);
},
inputModeOptions: { itemsSeparator: " / " },
templates: {
arrowPrev: '<button data-vc-arrow="prev"><svg class="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"></path></svg></button>',
arrowNext: '<button data-vc-arrow="next"><svg class="shrink-0 size-4" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"></path></svg></button>'
}
};
const channelEl = $("#formChannels").get(0);
const filterEl = $("#formFilters").get(0);
const styleEl = $("#formMessageStyle").get(0);
$("#editForm").on("submit", async event => {
event.preventDefault();
if (exists(channelEl) || exists(filterEl) || exists(styleEl)) return;
const form = $(event.target).get(0) as HTMLFormElement;
$(form).addClass("submitted");
channelSelect = new HSSelect(channelEl, channelSelectOptions);
filterSelect = new HSSelect(filterEl, filterSelectOptions);
styleSelect = new HSSelect(styleEl, styleSelectOptions);
if (!form.checkValidity()) return;
let method = "post";
const data = $(event.target).serializeArray();
// If 'id' has a value, we are patching an existing entry
const id: number | undefined = $(editModal.el).data("id");
if (id !== undefined) {
data.push({ name: "id", value: `${id}` });
method = "patch";
}
await $.ajax({
url: `/guild/${guildId}/feeds/api`,
dataType: "json",
method: method,
data: data,
success: () => {
(table as any).dataTable.draw() // is this okay? dataTable is private, but there is no other method I know of to redraw...
closeEditModal();
},
error: error => {
alert(JSON.stringify(error, null, 4));
}
});
// Add options to the channel select
channels.forEach((channel: TextChannel) => {
channelSelect.addOption({
title: channel.name,
val: channel.id,
options: {
description: channel.id,
icon: `
<svg class="shrink-0 size-[16px]" viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" y1="9" x2="20" y2="9"></line>
<line x1="4" y1="15" x2="20" y2="15"></line>
<line x1="10" y1="3" x2="8" y2="21"></line>
<line x1="16" y1="3" x2="14" y2="21"></line>
</svg>` // hashtag icon
}
});
})
});
// #endregion

View File

@ -1,593 +0,0 @@
import { formatTimestamp, verifyChannels } from "../main";
import HSDropdown from "preline/dist/dropdown";
import HSDataTable, { IDataTableOptions } from "preline/dist/datatable";
import HSSelect, { ISelectOptions } from "preline/dist/select";
import HSOverlay, { IOverlayOptions } from "preline/dist/overlay";
import HSDatepicker, { ICustomDatepickerOptions } from "preline/dist/datepicker";
import { AjaxSettings, ConfigColumnDefs } from "datatables.net-dt";
import prisma from "../../../../../generated/prisma";
declare let guildId: string;
declare let channels: Array<any>;
// #region DataTable
const emptyTableHtml: string = `
<div class="max-w-md w-full min-h-[400px] flex flex-col justify-center mx-auto px-6 py-4">
<div class="flex justify-center items-center size-[46px] bg-gray-100 rounded-lg dark:bg-neutral-800">
<svg class="shrink-0 size-6 text-gray-600 dark:text-neutral-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11a9 9 0 0 1 9 9"></path><path d="M4 4a16 16 0 0 1 16 16"></path><circle cx="5" cy="19" r="1"></circle></svg>
</div>
<h2 class="mt-5 font-semibold text-gray-800 dark:text-white">
No results found
</h2>
<p class="mt-2 text-sm text-gray-600 dark:text-neutral-400">
Refine your search or create a new feed.
Alternatively, use a template to deploy a ready-made feed.
</p>
<div class="mt-5 flex flex-col sm:flex-row gap-2">
<button type="button" class="open-edit-modal-js py-2 px-3 inline-flex justify-center items-center gap-x-2 text-sm font-medium rounded-lg border border-transparent bg-blue-600 text-white hover:bg-blue-700 focus:outline-hidden focus:bg-blue-700 disabled:opacity-50 disabled:pointer-events-none">
<svg class="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
Create a feed
</button>
<button type="button" onclick="alert('not implemented');" class="py-2 px-3 inline-flex justify-center items-center gap-x-2 text-sm font-medium rounded-lg border border-gray-200 bg-white text-gray-800 shadow-xs hover:bg-gray-50 focus:outline-hidden focus:bg-gray-50 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-800 dark:border-neutral-700 dark:text-white dark:hover:bg-neutral-700 dark:focus:bg-neutral-700">
Use a Template
</button>
</div>
</div>
`;
const columnDefs: ConfigColumnDefs[] = [
// Select checkbox column
{
target: 0,
className: "size-px whitespace-nowrap",
render: (_data: unknown, _type: unknown, row: prisma.Feed) => { return `
<div class="ps-6 py-4">
<label class="rowSelect${row.id}-js" class="flex">
<input type="checkbox" id="rowSelect${row.id}-js" class="cj-table-checkbox" data-hs-datatable-row-selecting-individual="">
<span class="sr-only">Select Row</span>
</label>
</div>
`}
},
{
target: 1,
data: "name",
orderable: true,
searchable: true,
className: "size-px whitespace-nowrap",
render: (data: string, _type: string, row: prisma.Feed) => { return `
<span class="cj-table-link open-edit-modal-js max-w-[250px] truncate" data-id="${row.id}">
${data}
</span>
`}
},
{
target: 2,
data: "url",
orderable: true,
searchable: true,
className: "size-px whitespace-nowrap",
render: (data: string) => { return `
<a href="${data}" class="cj-table-link max-w-[450px] truncate">
${data}
</a>
`}
},
{
target: 3,
data: "channels",
className: "size-px",
render: (data: prisma.Channel[], type: string, row: prisma.Feed) => {
if (type !== "display") { return data; }
if (!data.length) { return ""; }
const wrapper = $("<div>").addClass("flex flex-nowrap gap-1 px-6 py-4");
const tag = $("<span>").addClass("inline-flex items-center whitespace-nowrap gap-1 py-1 px-2.5 border text-xs rounded-md bg-white dark:bg-neutral-800 border-gray-200 dark:border-neutral-700 text-gray-800 dark:text-neutral-200");
if (!verifyChannels(data, channels)) {
wrapper.text("invalid channels").addClass("whitespace-nowrap");
return wrapper.get(0);
}
const firstChannelName = "# " + channels.find(c => c.id === data[0].channel_id).name;
wrapper.append(tag.clone().text(firstChannelName));
// No need to run the dropdown code if there's no more to show
if (data.length === 1) {
return wrapper.get(0);
}
data.shift();
if (data.length <= 1) {
const secondChannelName = "# " + channels.find(c => c.id === data[0].channel_id).name;
wrapper.append(tag.clone().text(secondChannelName));
return wrapper.get(0);
}
const dropdown = $("<div>").addClass("hs-dropdown inline-block");
const dropdownBtn = $("<button>").attr("id", `channelDrop-${row.id}`).attr("type", "button").addClass("cursor-pointer inline-flex items-center gap-1 py-1 px-2.5 border text-xs rounded-md bg-white dark:bg-neutral-800 border-gray-200 dark:border-neutral-700 text-gray-800 dark:text-neutral-200");
const dropdownMenu = $("<div>").addClass("hs-dropdown-menu hidden opacity-0 hs-dropdown-open:opacity-100 transition-[opacity,margin] overflow-hidden z-10 w-fit max-w-64 border p-2 rounded-md bg-gray-200 dark:bg-neutral-700 border-gray-300 dark:border-neutral-600");
dropdown.append(dropdownBtn.text(`+${data.length}`));
data.forEach(channel => {
const channelName = "# " + channels.find(c => c.id === channel.channel_id).name;
dropdownMenu.append(tag.clone().text(channelName));
});
dropdown.append(dropdownMenu);
wrapper.append(dropdown);
return wrapper.get(0);
}
},
{
target: 4,
data: "filters",
className: "size-px whitespace-nowrap",
render: (data: prisma.Filter[], type: string, row: prisma.Feed) => {
if (type !== "display") return data;
if (!data.length) return "";
const wrapper = $("<div>").addClass("flex flex-nowrap gap-1 px-6 py-4");
const tag = $("<span>").addClass("inline-flex items-center whitespace-nowrap gap-1 py-1 px-2.5 border text-xs rounded-md bg-white dark:bg-neutral-800 border-gray-200 dark:border-neutral-700 text-gray-800 dark:text-neutral-200");
wrapper.append(tag.clone().text(data[0].name));
if (data.length === 1) {
return wrapper.get(0);
}
data.shift();
if (data.length <= 1) {
wrapper.append(tag.clone().text(data[0].name));
return wrapper.get(0);
}
const dropdown = $("<div>").addClass("hs-dropdown inline-block");
const dropdownBtn = $("<button>").attr("id", `channelDrop-${row.id}`).attr("type", "button").addClass("cursor-pointer inline-flex items-center gap-1 py-1 px-2.5 border text-xs rounded-md bg-white dark:bg-neutral-800 border-gray-200 dark:border-neutral-700 text-gray-800 dark:text-neutral-200");
const dropdownMenu = $("<div>").addClass("hs-dropdown-menu hidden opacity-0 hs-dropdown-open:opacity-100 transition-[opacity,margin] overflow-hidden z-10 w-fit max-w-64 border p-2 rounded-md bg-gray-200 dark:bg-neutral-700 border-gray-300 dark:border-neutral-600");
dropdown.append(dropdownBtn.text(`+${data.length}`));
data.forEach(filter => {
dropdownMenu.append(tag.clone().text(filter.name));
});
dropdown.append(dropdownMenu);
wrapper.append(dropdown);
return wrapper.get(0);
}
},
{
target: 5,
data: null, // "message_style_id"
className: "size-px whitespace-nowrap",
render: (_data: unknown, type: string, row: any) => {
if (!row.message_style || type !== "display") return null;
const wrapper = $("<div>").addClass("flex px-6 py-4");
const badge = $("<span>").addClass("inline-flex items-center whitespace-nowrap border rounded-md bg-white dark:bg-neutral-800 border-gray-200 dark:border-neutral-700 overflow-hidden");
const colour = $("<span>").addClass("size-6 shrink-0").css("background-color", row.message_style.colour);
const label = $("<span>").addClass("py-1 px-2.5 text-xs text-gray-800 dark:text-neutral-200");
label.text(row.message_style.name);
badge.append(colour).append(label);
wrapper.append(badge);
return wrapper.get(0);
}
},
{
target: 6,
data: "created_at",
orderable: true,
className: "size-px whitespace-nowrap",
render: (data: string) => { return `
<div class="px-6 py-4">
<span class="cj-table-text">
${formatTimestamp(data)}
</span>
</div>
`}
},
{
target: 7,
data: "active",
orderable: true,
className: "size-px whitespace-nowrap",
render: (data: boolean) => {
const wrapper = $("<div>").addClass("px-6 py-4");
const badge = $("<span>").addClass("py-1 px-1.5 inline-flex items-center gap-x-1 text-xs font-medium rounded-full");
const label = $("<span>");
if (data) {
badge.addClass("bg-teal-100 text-teal-800 dark:bg-teal-500/10 dark:text-teal-500");
badge.append($('<svg class="size-2.5" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/></svg>'))
.append(label.text("Active"));
} else {
badge.addClass("bg-red-100 text-red-800 dark:bg-red-500/10 dark:text-red-500");
badge.append($('<svg class="size-2.5" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/></svg>'))
.append(label.text("Inactive"));
}
wrapper.append(badge);
return wrapper.get(0);
}
}
];
const ajaxSettings: AjaxSettings = {
url: `/guild/${guildId}/feeds/api/datatable`,
type: "POST",
contentType: "application/json",
dataSrc: "data",
data: (data: unknown) => {
if (data === undefined) return;
// TODO,
return JSON.stringify(data);
}
};
const tableOptions: IDataTableOptions = {
ajax: ajaxSettings,
serverSide: true,
processing: true,
select: {
style: "multi",
selector: "td:first-child input[type='checkbox']"
},
columnDefs: columnDefs,
pagingOptions: { pageBtnClasses: "hidden" },
rowSelectingOptions: { selectAllSelector: "#selectAllBox" },
language: {
zeroRecords: emptyTableHtml,
emptyTable: emptyTableHtml,
loadingRecords: "Placeholder loading message..."
},
drawCallback: () => HSDropdown.autoInit(),
rowCallback: (row: HTMLTableRowElement) => {
$(row).addClass("bg-white dark:bg-neutral-900");
}
};
let table: HSDataTable;
window.addEventListener("preline:ready", () => {
const tableEl = $("#table").get(0);
if (!HSDataTable.getInstance(tableEl, true)) {
table = new HSDataTable(tableEl, tableOptions);
}
});
// #endregion
// #region Table Paging Select
const pageSelectOptions: ISelectOptions = {
toggleTag: '<button type="button" aria-expanded="false"></button>',
optionTemplate: `
<div class="flex justify-between items-center w-full">
<span data-title></span>
<span class="hidden hs-selected:block">
<svg class="shrink-0 size-3.5 text-blue-600 dark:text-blue-500" xmlns="http:.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
</div>`,
toggleClasses: "cj-table-paging-select-toggle",
optionClasses: "cj-table-paging-select-option",
dropdownClasses: "cj-table-paging-select-dropdown",
dropdownSpace: 10,
dropdownScope: "parent",
dropdownPlacement: "top",
dropdownVerticalFixedPlacement: null
};
window.addEventListener("preline:ready", () => {
const selectEl = $("#selectPageSize-js").get(0);
if (!HSSelect.getInstance(selectEl, true)) {
new HSSelect(selectEl, pageSelectOptions);
}
});
// #endregion
// #region Edit Modal
const closeEditModal = () => { editModal.close() };
const openEditModal = async (id: number | undefined) => {
$("#editForm").removeClass("submitted");
editModal.open();
id === undefined
? clearEditModalData()
: loadEditModalData(id);
};
$(document).on("click", ".open-edit-modal-js", async event => {
await openEditModal($(event.target).data("id"));
});
const editModalOptions: IOverlayOptions = {};
let editModal: HSOverlay;
window.addEventListener("preline:ready", () => {
const modalEl = $("#editModal").get(0);
if (!HSOverlay.getInstance(modalEl, true)) {
editModal = new HSOverlay(modalEl, editModalOptions);
}
});
// #endregion
// #region Edit Form
interface ExpandedFeed extends prisma.Feed {
channels: prisma.Channel[],
filters: prisma.Feed[]
}
const clearEditModalData = () => {
$(editModal.el).removeData("id");
$("#formName").val("");
$("#formUrl").val("");
$("#formActive").prop("checked", true);
channelSelect.setValue([]);
filterSelect.setValue([]);
styleSelect.setValue("");
};
const loadEditModalData = async (id: number) => {
const feed: ExpandedFeed = await $.ajax({
url: `/guild/${guildId}/feeds/api?id=${id}`,
method: "get"
});
$(editModal.el).data("id", feed.id);
$("#formName").val(feed.name);
$("#formUrl").val(feed.url);
$("#formActive").prop("checked", feed.active);
channelSelect.setValue(feed.channels.map(channel => channel.channel_id));
filterSelect.setValue(feed.filters.map(filter => `${filter.id}`));
styleSelect.setValue(`${feed.message_style_id}`);
}
$("#editForm").on("submit", async event => {
event.preventDefault();
const form = $(event.target).get(0) as HTMLFormElement;
$(form).addClass("submitted");
const validity = form.checkValidity();
if (!validity) {
console.debug(`Submit form invalid: ${validity}`);
return;
};
let method = "post";
const data = $(event.target).serializeArray();
// If 'id' has a value, we are patching an existing entry
const id: number | undefined = $(editModal.el).data("id");
if (id !== undefined) {
data.push({ name: "id", value: `${id}` });
method = "patch";
}
await $.ajax({
url: `/guild/${guildId}/feeds/api`,
dataType: "json",
method: method,
data: data,
success: () => {
(table as any).dataTable.draw()
closeEditModal();
},
error: error => {
alert(JSON.stringify(error, null, 4));
}
});
});
const channelSelectOptions: ISelectOptions = {
placeholder: "Select option....",
mode: "tags",
tagsItemTemplate: `
<div class="flex flex-nowrap items-center relative z-10 bg-white border border-gray-200 rounded-lg p-1 m-1 dark:bg-neutral-900 dark:border-neutral-700 ">
<div class="size-6 flex justify-center items-center" data-icon></div>
<div class="whitespace-nowrap text-gray-800 dark:text-neutral-200" data-title></div>
<div class="inline-flex shrink-0 justify-center items-center size-5 ms-2 rounded-lg text-gray-800 bg-gray-200 hover:bg-gray-300 focus:outline-hidden focus:ring-2 focus:ring-gray-400 text-sm dark:bg-neutral-700/50 dark:hover:bg-neutral-700 dark:text-neutral-400 cursor-pointer" data-remove>
<svg class="shrink-0 size-3" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</div>
</div>
`,
optionTemplate: `
<div class="cj-tag-select-option">
<div data-icon></div>
<div>
<div data-title></div>
<div data-description></div>
</div>
<div class="ms-auto">
<span class="hidden hs-selected:block">
<svg class="shrink-0 size-4 text-blue-600" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M12.736 3.97a.733.733 0 0 1 1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.065.02L3.217 8.384a.757.757 0 0 1 0-1.06.733.733 0 0 1 1.047 0l3.052 3.093 5.4-6.425a.247.247 0 0 1 .02-.022Z"/></svg>
</span>
</div>
</div>
`,
tagsInputId: "formChannelsInput",
wrapperClasses: "cj-tag-select-wrapper",
dropdownClasses: "cj-tag-select-dropdown w-full",
tagsInputClasses: "cj-tag-select-input",
dropdownScope: "window",
dropdownSpace: 10,
dropdownPlacement: "bottom",
dropdownVerticalFixedPlacement: null,
hasSearch: false,
searchNoResultClasses: "cj-tag-select-search-no-results",
};
const filterSelectOptions: ISelectOptions = {
placeholder: "Select option....",
mode: "tags",
tagsItemTemplate: `
<div class="flex flex-nowrap items-center relative z-10 bg-white border border-gray-200 rounded-lg p-1 m-1 dark:bg-neutral-900 dark:border-neutral-700 ">
<div class="size-6 flex justify-center items-center">
<svg class="shrink-0 size-[16px]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z" /></svg>
</div>
<div class="whitespace-nowrap text-gray-800 dark:text-neutral-200" data-title></div>
<div class="inline-flex shrink-0 justify-center items-center size-5 ms-2 rounded-lg text-gray-800 bg-gray-200 hover:bg-gray-300 focus:outline-hidden focus:ring-2 focus:ring-gray-400 text-sm dark:bg-neutral-700/50 dark:hover:bg-neutral-700 dark:text-neutral-400 cursor-pointer" data-remove>
<svg class="shrink-0 size-3" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</div>
</div>
`,
optionTemplate: `
<div class="cj-tag-select-option">
<div data-icon>
<svg class="shrink-0 size-[18px]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z" /></svg>
</div>
<div>
<div data-title></div>
<div data-description></div>
</div>
<div class="ms-auto">
<span class="hidden hs-selected:block">
<svg class="shrink-0 size-4 text-blue-600" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M12.736 3.97a.733.733 0 0 1 1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.065.02L3.217 8.384a.757.757 0 0 1 0-1.06.733.733 0 0 1 1.047 0l3.052 3.093 5.4-6.425a.247.247 0 0 1 .02-.022Z"/></svg>
</span>
</div>
</div>
`,
tagsInputId: "formFiltersInput",
wrapperClasses: "cj-tag-select-wrapper",
dropdownClasses: "cj-tag-select-dropdown w-full",
tagsInputClasses: "cj-tag-select-input",
dropdownScope: "window",
dropdownSpace: 10,
dropdownPlacement: "bottom",
dropdownVerticalFixedPlacement: null,
// API
apiUrl: `/guild/${guildId}/filters/api/select`,
apiQuery: "limit=15",
apiFieldsMap: {
id: "id",
val: "id",
title: "name",
description: "value",
name: "title"
},
apiSearchQueryKey: "search",
hasSearch: false,
searchNoResultClasses: "cj-tag-select-search-no-results",
};
const styleSelectOptions: ISelectOptions = {
placeholder: "Select option...",
toggleTag: '<button type="button" aria-expanded="false"><span data-title></span></button>',
optionTemplate: `
<div class="flex justify-between items-center w-full">
<span data-title></span>
<span class="hidden hs-selected:block">
<svg class="shrink-0 size-3.5 text-blue-600 dark:text-blue-500" xmlns="http:.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
</div>`,
toggleClasses: "cj-select-toggle select-input",
optionClasses: "cj-select-option",
dropdownClasses: "cj-select-dropdown",
wrapperClasses: "peer",
dropdownSpace: 10,
dropdownScope: "parent",
dropdownPlacement: "top",
dropdownVerticalFixedPlacement: null,
apiUrl: `/guild/${guildId}/styles/api/select`,
// apiQuery: "limit=15",
apiFieldsMap: {
id: "id",
val: "id",
title: "name",
description: "value",
name: "title"
},
apiSearchQueryKey: "search",
hasSearch: false,
optionAllowEmptyOption: true
};
let channelSelect: HSSelect;
let filterSelect: HSSelect;
let styleSelect: HSSelect;
window.addEventListener("preline:ready", () => {
const exists = (element: HTMLElement) => HSSelect.getInstance(element, true);
const channelEl = $("#formChannels").get(0);
const filterEl = $("#formFilters").get(0);
const styleEl = $("#formMessageStyle").get(0);
if (!exists(channelEl) && !exists(filterEl) && !exists(styleEl)) {
channelSelect = new HSSelect(channelEl, channelSelectOptions);
filterSelect = new HSSelect(filterEl, filterSelectOptions);
styleSelect = new HSSelect(styleEl, styleSelectOptions);
}
});
const publishedThresholdOptions: ICustomDatepickerOptions = {
type: "default",
dateMax: "2050-00-00",
mode: "custom-select",
layouts: {
default: `
<div class="--single-month flex flex-col overflow-hidden dark:bg-neutral-900 dark:border-neutral-700">
<div class="grid grid-cols-5 items-center gap-x-3 mx-1.5 pb-3" data-vc="header">
<div class="col-span-1">
<#CustomArrowPrev />
</div>
<div class="col-span-3 flex justify-center items-center gap-x-1">
<#CustomMonth />
<span class="text-gray-800 dark:text-neutral-200">/</span>
<#CustomYear />
</div>
<div class="col-span-1 flex justify-end">
<#CustomArrowNext />
</div>
</div>
<div data-vc="wrapper">
<div data-vc="content">
<#Week /><#Dates />
</div>
</div>
</div>
<#CustomTime />`
},
inputModeOptions: { itemsSeparator: " / " },
templates: {
arrowPrev: '<button data-vc-arrow="prev"><svg class="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"></path></svg></button>',
arrowNext: '<button data-vc-arrow="next"><svg class="shrink-0 size-4" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"></path></svg></button>'
},
};
let publishedThresholdInput: HSDatepicker;
window.addEventListener("preline:ready", () => {
const publishedEl = $("#formPublishedThreshold").get(0);
if (!HSDatepicker.getInstance(publishedEl, true)) {
publishedThresholdInput = new HSDatepicker(publishedEl, publishedThresholdOptions);
}
});
// #endregion

View File

@ -176,17 +176,6 @@
<label for="formChannels" class="text-input-label">Channels</label>
<select name="channels" id="formChannels" class="--prevent-on-load-init" multiple>
<option value="">Choose</option>
<% guild.channels.cache
.filter(channel => channel.type == 0)
.sort((a, b) => a.rawPosition - b.rawPosition)
.forEach(channel => { %>
<option value="<%= channel.id %>" data-hs-select-option='{
"description": "<%= channel.id %>",
"icon": "<svg class=\"shrink-0 size-[16px]\" viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" stroke=\"currentColor\" stroke-width=\"2\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><line x1=\"4\" y1=\"9\" x2=\"20\" y2=\"9\"></line><line x1=\"4\" y1=\"15\" x2=\"20\" y2=\"15\"></line><line x1=\"10\" y1=\"3\" x2=\"8\" y2=\"21\"></line><line x1=\"16\" y1=\"3\" x2=\"14\" y2=\"21\"></line></svg>"
}'>
<%= channel.name %>
</option>
<% }); %>
</select>
<p class="text-input-help">
The recipients of content from this feed.
@ -207,14 +196,14 @@
<option value="">None</option>
</select>
<p class="text-input-help">
placeholder.
A custom appearance used to display content from this feed.
</p>
</div>
<div>
<label for="formPublishedThreshold" class="text-input-label">Published Threshold</label>
<input type="text" id="formPublishedThreshold" name="published_threshold" class="z-[80] py-3 px-4 block w-full border-gray-200 rounded-lg sm:text-sm focus:border-blue-600 focus:ring-blue-600 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 dark:placeholder:text-neutral-400 dark:focus:border-blue-500 dark:focus:ring-neutral-500" readonly required>
<input type="datetime-local" id="formPublishedThreshold" name="published_threshold" class="text-input form-input peer invalid:group-[.submitted]:border-red-500 invalid:group-[.submitted]:ring-red-500" required>
<p class="text-input-help block peer-invalid:group-[.submitted]:hidden">
placeholder helper text.
This feed won't process content older than this date &amp; time.
</p>
<p class="mt-2 text-sm text-red-500 hidden peer-invalid:group-[.submitted]:block">
Please enter a date.
@ -255,4 +244,4 @@
.map(channel => channel.toJSON())
) %>`);
</script>
<% block("scripts").append('<script src="/public/generated/js/guild/test.js"></script>'); %>
<% block("scripts").append('<script src="/public/generated/js/guild/feeds.js"></script>'); %>