Compare commits

...

54 Commits

Author SHA1 Message Date
6b77d062f0 chore(release): 0.1.5
Some checks failed
Build / build (push) Failing after 36s
2025-05-09 15:56:46 +01:00
31c4779bf2 refactor: entire feeds front-end js
Some checks failed
Build / build (push) Has been cancelled
2025-05-09 15:56:30 +01:00
816da70229 fix: parse publish threshold on post/patch
Some checks failed
Build / build (push) Has been cancelled
2025-05-09 15:55:55 +01:00
9423a0f1ce working on proper client packing
Some checks failed
Build / build (push) Failing after 57s
2025-05-08 22:14:55 +01:00
d6382347d0 trying to figure out preline implementation
Some checks failed
Build / build (push) Failing after 32s
2025-05-08 01:16:06 +01:00
8f1ee46d6d build db step replacement (needs env var)
Some checks failed
Build / build (push) Has been cancelled
2025-05-08 01:15:30 +01:00
6761a9163b feat: publish threshold field on Feed model 2025-05-08 01:14:35 +01:00
bab3759423 chore: remove outdated seeds
Some checks failed
Build / build (push) Failing after 48s
2025-05-08 01:14:00 +01:00
79b76c3b58 fix bad import path
Some checks failed
Build / build (push) Failing after 40s
2025-05-06 18:09:30 +01:00
faaaaf6ac7 style: add missing semicolons 2025-05-06 18:09:05 +01:00
676885a004 chore: remove unused div
Some checks failed
Build / build (push) Has been cancelled
2025-05-06 18:08:44 +01:00
9f71c9c29e colours plus select height fix
Some checks failed
Build / build (push) Failing after 54s
2025-05-06 17:03:49 +01:00
3ac33dc00a fix: error when searching filters (algorithm isn't searchable via API)
Some checks failed
Build / build (push) Failing after 48s
2025-05-06 16:35:02 +01:00
d5af04c317 feat: select message style on feed
Some checks failed
Build / build (push) Failing after 52s
2025-05-06 16:19:25 +01:00
0dd928b8f4 working on logger
Some checks failed
Build / build (push) Failing after 54s
2025-05-05 23:39:22 +01:00
badd232d3d chore: shorten style table headers and use a unique icon for table 'no results found'.
Some checks failed
Build / build (push) Failing after 48s
2025-05-05 23:20:09 +01:00
6356bb1d06 feat(db): connect message style to feed 2025-05-05 23:19:23 +01:00
e9807ee6f6 feat: colour picker on message style edit modal
Some checks failed
Build / build (push) Failing after 40s
2025-05-05 23:06:05 +01:00
99a59c61e7 fix(api): style mutators causing errors if blank
Some checks failed
Build / build (push) Failing after 37s
2025-05-05 23:03:51 +01:00
200716988c style colour length to 7 from 6 (2)
Some checks failed
Build / build (push) Has been cancelled
2025-05-05 23:03:05 +01:00
a42e353aa4 style colour length to 7 from 6 2025-05-05 23:02:47 +01:00
cfde210a39 fix: mistakenly broken class on the style table delete button 2025-05-05 18:57:42 +01:00
be03788cfc feat: added styles page table and edit modal
Some checks failed
Build / build (push) Failing after 38s
2025-05-05 18:51:28 +01:00
2d8a26f392 fix: incorrect 'for' value being used on the editModal's 'value' label.
Some checks failed
Build / build (push) Has been cancelled
2025-05-05 18:50:48 +01:00
a1bd362799 feat(api): added message style api endpoints 2025-05-05 18:50:10 +01:00
6d1f4e6f7f feat: migrate from sqlite to postgresql, and add message_style model
Some checks failed
Build / build (push) Failing after 52s
2025-05-05 17:05:59 +01:00
b528153113 feat: create and update feeds with filters 2025-05-05 15:10:31 +01:00
e935d801e6 feat: filter selection and table render for feed view
All checks were successful
Build / build (push) Successful in 48s
2025-05-05 14:41:53 +01:00
ff992fefa7 chore: remove large "unused" preline import on main.ts
All checks were successful
Build / build (push) Successful in 46s
2025-05-05 14:40:26 +01:00
989f93addf build: minify client-side js on prod
Some checks failed
Build / build (push) Has been cancelled
2025-05-05 14:39:33 +01:00
a0d2711a51 fix: ID not being reset when creating new feeds, causing unintentional edits over existing feeds
All checks were successful
Build / build (push) Successful in 39s
2025-05-02 13:43:59 +01:00
73aed35ce0 feat: completed filter table and edit modal
All checks were successful
Build / build (push) Successful in 42s
2025-05-02 13:43:02 +01:00
e58d7343b1 feat(api): Functional API endpoints for filters 2025-05-02 13:42:13 +01:00
2259e3229a chore: remove commented styles
All checks were successful
Build / build (push) Successful in 47s
2025-05-02 13:41:39 +01:00
78fed2b2a3 chore: remove shadow from algorithm select 2025-05-02 13:39:14 +01:00
fa42eb3551 feat: css shorthands for generic select toggle
All checks were successful
Build / build (push) Successful in 53s
2025-05-02 13:31:29 +01:00
e4ab506abe fix(api): patching feeds would duplicate channels #4
All checks were successful
Build / build (push) Successful in 52s
2025-05-02 11:13:28 +01:00
2589cabec6 feat: delete selected feed rows
All checks were successful
Build / build (push) Successful in 58s
2025-05-01 20:38:25 +01:00
00a4c749f0 fix(api): expected ids to be integers, not strings
All checks were successful
Build / build (push) Successful in 57s
2025-05-01 20:37:19 +01:00
9d79d8dbef chore: add migration
Some checks failed
Build / build (push) Has been cancelled
2025-05-01 20:36:41 +01:00
79e331bdb9 fix(database): cascade channels on feed delete
Some checks failed
Build / build (push) Has been cancelled
2025-05-01 20:36:24 +01:00
dda5461a0d working on delete button
All checks were successful
Build / build (push) Successful in 59s
2025-05-01 19:33:46 +01:00
300041ec49 cleanup build action file
All checks were successful
Build / build (push) Successful in 53s
2025-05-01 14:34:18 +01:00
a768059e74 build test
All checks were successful
Build / build (push) Successful in 39s
2025-05-01 14:29:52 +01:00
e6c641575e set env var before db process
Some checks failed
Build / build (push) Failing after 47s
2025-05-01 14:26:45 +01:00
40a0211609 build(api): include database in test build process
Some checks failed
Build / build (push) Failing after 43s
2025-05-01 14:20:50 +01:00
671b1856c6 build(actions): add checkout to build action
All checks were successful
Build / build (push) Successful in 56s
2025-05-01 13:03:15 +01:00
3da72482ba build(actions): added action to test build process
Some checks failed
Build / build (push) Failing after 51s
2025-05-01 13:01:16 +01:00
22e252ce53 docs: add 'author', 'license' and 'description' values to package.json 2025-05-01 12:49:58 +01:00
179d6e3b3b chore: mute css lint warnings for tailwindcss rules 2025-05-01 12:46:16 +01:00
6b6af17731 fix: datatable count including entries from other guilds 2025-05-01 12:45:42 +01:00
7ef8b88aab chore: fix broken syntax 2025-05-01 12:35:03 +01:00
2a88e1c184 fix: channels select dropdown height breaking on smaller screens 2025-05-01 12:33:33 +01:00
84772852e3 feat: patch existing feeds 2025-05-01 12:32:32 +01:00
34 changed files with 2501 additions and 520 deletions

View File

@ -0,0 +1,29 @@
name: Build
run-name: ${{ gitea.actor }} is building
on:
push:
branches:
- master
pull_request:
branches:
- master
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Install Dependencies
run: npm i
- name: Reset and Push Database Migrations
run: npx prisma migrate reset && npm run db:push
env:
- DATABASE_URL: ""
- name: Build Dist
run: npm run build

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"css.lint.unknownAtRules": "ignore" // For tailwindcss rules '@config' & '@apply'
}

View File

@ -2,6 +2,41 @@
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
### [0.1.5](https://gitea.cor.bz/corbz/relay/compare/v0.1.4...v0.1.5) (2025-05-09)
### Features
* added styles page table and edit modal ([be03788](https://gitea.cor.bz/corbz/relay/commit/be03788cfcd6148ae03537425581aefecbc67734))
* **api:** added message style api endpoints ([a1bd362](https://gitea.cor.bz/corbz/relay/commit/a1bd362799b640647d6d536c61e5037fcb938fab))
* **api:** Functional API endpoints for filters ([e58d734](https://gitea.cor.bz/corbz/relay/commit/e58d7343b163e56eaeca553a560ff7c112e962df))
* colour picker on message style edit modal ([e9807ee](https://gitea.cor.bz/corbz/relay/commit/e9807ee6f6f0066e0351b58a1e53653af9993874))
* completed filter table and edit modal ([73aed35](https://gitea.cor.bz/corbz/relay/commit/73aed35ce04180e7adc9043bdf9ab2193a9e4d74))
* create and update feeds with filters ([b528153](https://gitea.cor.bz/corbz/relay/commit/b528153113be2773dbe678f3414ffcb20b5db440))
* css shorthands for generic select toggle ([fa42eb3](https://gitea.cor.bz/corbz/relay/commit/fa42eb355198bd86ee45c578669f6152df7d191e))
* **db:** connect message style to feed ([6356bb1](https://gitea.cor.bz/corbz/relay/commit/6356bb1d063b2a4e1d38bf2f90bddf607416ee0e))
* delete selected feed rows ([2589cab](https://gitea.cor.bz/corbz/relay/commit/2589cabec6356179e18f2c6ebb032de474e4ebe9))
* filter selection and table render for feed view ([e935d80](https://gitea.cor.bz/corbz/relay/commit/e935d801e6e2e5910909362098da5ae38f42c6b7))
* migrate from sqlite to postgresql, and add message_style model ([6d1f4e6](https://gitea.cor.bz/corbz/relay/commit/6d1f4e6f7f6529d7bcdbb6db85923a7537320d1e))
* patch existing feeds ([8477285](https://gitea.cor.bz/corbz/relay/commit/84772852e3550bc88b33fb8fa05d173ba40cfb0d))
* publish threshold field on Feed model ([6761a91](https://gitea.cor.bz/corbz/relay/commit/6761a9163bf5e3aef9cdb99b0dbaa70eef43d72d))
* select message style on feed ([d5af04c](https://gitea.cor.bz/corbz/relay/commit/d5af04c317ef58943ce3eefa26d79ca58e3a04d0))
### Bug Fixes
* **api:** expected ids to be integers, not strings ([00a4c74](https://gitea.cor.bz/corbz/relay/commit/00a4c749f0f644905e7c878ddd61404616371327))
* **api:** patching feeds would duplicate channels [#4](https://gitea.cor.bz/corbz/relay/issues/4) ([e4ab506](https://gitea.cor.bz/corbz/relay/commit/e4ab506abe68a4eecb1e91d940f4e367d164a666))
* **api:** style mutators causing errors if blank ([99a59c6](https://gitea.cor.bz/corbz/relay/commit/99a59c61e7b8e73b7b2207eb45c7543f6a3f9f44))
* channels select dropdown height breaking on smaller screens ([2a88e1c](https://gitea.cor.bz/corbz/relay/commit/2a88e1c18459dcbb3b49828769821dba9ffe73e4))
* **database:** cascade channels on feed delete ([79e331b](https://gitea.cor.bz/corbz/relay/commit/79e331bdb9629f3d851bae2c57c16be1e2913cb3))
* datatable count including entries from other guilds ([6b6af17](https://gitea.cor.bz/corbz/relay/commit/6b6af177318fc1a6c67face8c647eb6d66980d03))
* error when searching filters (algorithm isn't searchable via API) ([3ac33dc](https://gitea.cor.bz/corbz/relay/commit/3ac33dc00a364f2c62a8855b902f1680b7f05781))
* ID not being reset when creating new feeds, causing unintentional edits over existing feeds ([a0d2711](https://gitea.cor.bz/corbz/relay/commit/a0d2711a510ee781460f6f7854106f8bb0701950))
* incorrect 'for' value being used on the editModal's 'value' label. ([2d8a26f](https://gitea.cor.bz/corbz/relay/commit/2d8a26f39269dfc517a72b1012a4f9f86061745f))
* mistakenly broken class on the style table delete button ([cfde210](https://gitea.cor.bz/corbz/relay/commit/cfde210a394c28eedabeb3a4f515c4b09b1bd42e))
* parse publish threshold on post/patch ([816da70](https://gitea.cor.bz/corbz/relay/commit/816da70229218684cf699ca7c10bf435471dc6e7))
### [0.1.4](https://gitea.cor.bz/corbz/relay/compare/v0.1.3...v0.1.4) (2025-04-30)

View File

@ -3,7 +3,11 @@
import { build } from "esbuild";
import glob from "fast-glob";
import dotenv from "dotenv";
dotenv.config();
const isProdEnv = process.env.PROD === "true";
const entryPoints = await glob("./src/client/src/ts/**/*");
build({
@ -12,7 +16,8 @@ build({
bundle: true,
target: ["es6"],
format: "iife",
sourcemap: false,
loader: {".ts": "ts"},
minify: false
sourcemap: false, // !isProdEnv,
minify: isProdEnv,
keepNames: !isProdEnv
}).catch(() => process.exit(1));

View File

@ -1,6 +1,6 @@
{
"name": "relay",
"version": "0.1.4",
"version": "0.1.5",
"main": "index.js",
"scripts": {
"start": "node ./dist/app.js",
@ -21,15 +21,17 @@
"release:minor": "npx standard-version --release-as minor",
"release:major": "npx standard-version --release-as major"
},
"author": "",
"license": "ISC",
"description": "",
"author": "Corbz",
"license": "GPL-3.0-only",
"description": "An RSS aggregator with Discord integration and a web management interface.",
"devDependencies": {
"@tailwindcss/cli": "^4.1.4",
"@tailwindcss/postcss": "^4.1.4",
"@types/dropzone": "^5.7.9",
"@types/ejs": "^3.1.5",
"@types/express": "^5.0.1",
"@types/jquery": "^3.5.32",
"@types/lodash": "^4.17.16",
"@types/node": "^22.14.1",
"@zerollup/ts-transform-paths": "^1.7.18",
"autoprefixer": "^10.4.21",
@ -49,22 +51,29 @@
"dependencies": {
"@floating-ui/dom": "^1.6.13",
"@preline/datatable": "^3.0.0",
"@preline/datepicker": "^3.0.1",
"@preline/dropdown": "^3.0.1",
"@preline/overlay": "^3.0.0",
"@preline/select": "^3.0.0",
"@prisma/client": "^6.6.0",
"@tailwindcss/forms": "^0.5.10",
"datatables.net-dt": "^2.2.2",
"datatables.net-select": "^3.0.0",
"datatables.net-select-dt": "^3.0.0",
"discord.js": "^14.18.0",
"dotenv": "^16.5.0",
"dropzone": "^6.0.0-beta.2",
"ejs": "^3.1.10",
"ejs-mate": "^4.0.0",
"express": "^5.1.0",
"jquery": "^3.7.1",
"lodash": "^4.17.21",
"nouislider": "^15.8.1",
"preline": "^3.0.1",
"sqlite3": "^5.1.7",
"tsconfig-paths": "^4.2.0",
"vanilla-calendar-pro": "^3.0.4"
"vanilla-calendar-pro": "^3.0.4",
"winston": "^3.17.0"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"

View File

@ -1,45 +0,0 @@
-- CreateTable
CREATE TABLE "Feed" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL,
"url" TEXT NOT NULL,
"guild_id" TEXT NOT NULL,
"active" BOOLEAN NOT NULL,
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Channel" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"channel_id" TEXT NOT NULL,
"feedId" INTEGER NOT NULL,
CONSTRAINT "Channel_feedId_fkey" FOREIGN KEY ("feedId") REFERENCES "Feed" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Filter" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"guild_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"value" TEXT NOT NULL,
"matching_algorithm" TEXT NOT NULL,
"is_insensitive" BOOLEAN NOT NULL,
"is_whitelist" BOOLEAN NOT NULL,
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "_FeedToFilter" (
"A" INTEGER NOT NULL,
"B" INTEGER NOT NULL,
CONSTRAINT "_FeedToFilter_A_fkey" FOREIGN KEY ("A") REFERENCES "Feed" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "_FeedToFilter_B_fkey" FOREIGN KEY ("B") REFERENCES "Filter" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "_FeedToFilter_AB_unique" ON "_FeedToFilter"("A", "B");
-- CreateIndex
CREATE INDEX "_FeedToFilter_B_index" ON "_FeedToFilter"("B");

View File

@ -1,5 +0,0 @@
-- CreateIndex
CREATE INDEX "Feed_guild_id_created_at_idx" ON "Feed"("guild_id", "created_at" DESC);
-- CreateIndex
CREATE INDEX "Filter_guild_id_created_at_idx" ON "Filter"("guild_id", "created_at" DESC);

View File

@ -0,0 +1,87 @@
-- CreateEnum
CREATE TYPE "MatchingAlgorithms" AS ENUM ('ANY', 'ALL', 'EXACT', 'REGEX', 'FUZZY');
-- CreateEnum
CREATE TYPE "TextMutator" AS ENUM ('UWUIFY', 'UWUIFY_SFW', 'GOTHIC_SCRIPT', 'EMOJI_SUBSTITUTE', 'ZALGO', 'MORSE_CODE', 'BINARY', 'HEXADECIMAL', 'REMOVE_VOWELS', 'DOUBLE_CHARACTERS', 'SMALL_CASE', 'LEET_SPEAK', 'PIG_LATIN', 'UPSIDE_DOWN', 'ALL_REVERSED', 'REVERSED_WORDS', 'SHUFFLE_WORDS', 'RANDOM_CASE', 'GIBBERISH', 'SHAKESPEAREAN');
-- CreateTable
CREATE TABLE "Feed" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"url" TEXT NOT NULL,
"guild_id" TEXT NOT NULL,
"active" BOOLEAN NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Feed_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Channel" (
"id" SERIAL NOT NULL,
"channel_id" TEXT NOT NULL,
"feedId" INTEGER NOT NULL,
CONSTRAINT "Channel_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Filter" (
"id" SERIAL NOT NULL,
"guild_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"value" TEXT NOT NULL,
"matching_algorithm" "MatchingAlgorithms" NOT NULL,
"is_insensitive" BOOLEAN NOT NULL,
"is_whitelist" BOOLEAN NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Filter_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MessageStyle" (
"id" SERIAL NOT NULL,
"guild_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"show_author" BOOLEAN NOT NULL,
"show_image" BOOLEAN NOT NULL,
"show_thumbnail" BOOLEAN NOT NULL,
"show_footer" BOOLEAN NOT NULL,
"show_timestamp" BOOLEAN NOT NULL,
"colour" VARCHAR(6) NOT NULL,
"title_mutator" "TextMutator",
"description_mutator" "TextMutator",
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MessageStyle_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_FeedToFilter" (
"A" INTEGER NOT NULL,
"B" INTEGER NOT NULL,
CONSTRAINT "_FeedToFilter_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE INDEX "Feed_guild_id_created_at_idx" ON "Feed"("guild_id", "created_at" DESC);
-- CreateIndex
CREATE INDEX "Filter_guild_id_created_at_idx" ON "Filter"("guild_id", "created_at" DESC);
-- CreateIndex
CREATE INDEX "_FeedToFilter_B_index" ON "_FeedToFilter"("B");
-- AddForeignKey
ALTER TABLE "Channel" ADD CONSTRAINT "Channel_feedId_fkey" FOREIGN KEY ("feedId") REFERENCES "Feed"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_FeedToFilter" ADD CONSTRAINT "_FeedToFilter_A_fkey" FOREIGN KEY ("A") REFERENCES "Feed"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_FeedToFilter" ADD CONSTRAINT "_FeedToFilter_B_fkey" FOREIGN KEY ("B") REFERENCES "Filter"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,19 @@
/*
Warnings:
- A unique constraint covering the columns `[guild_id,name]` on the table `Feed` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[guild_id,name]` on the table `Filter` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[guild_id,name]` on the table `MessageStyle` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "Feed_guild_id_name_key" ON "Feed"("guild_id", "name");
-- CreateIndex
CREATE UNIQUE INDEX "Filter_guild_id_name_key" ON "Filter"("guild_id", "name");
-- CreateIndex
CREATE INDEX "MessageStyle_guild_id_created_at_idx" ON "MessageStyle"("guild_id", "created_at" DESC);
-- CreateIndex
CREATE UNIQUE INDEX "MessageStyle_guild_id_name_key" ON "MessageStyle"("guild_id", "name");

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "MessageStyle" ALTER COLUMN "colour" SET DATA TYPE VARCHAR(7);

View File

@ -0,0 +1,12 @@
/*
Warnings:
- Added the required column `published_threshold` to the `Feed` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Feed" ADD COLUMN "message_style_id" INTEGER,
ADD COLUMN "published_threshold" TIMESTAMP(3) NOT NULL;
-- AddForeignKey
ALTER TABLE "Feed" ADD CONSTRAINT "Feed_message_style_id_fkey" FOREIGN KEY ("message_style_id") REFERENCES "MessageStyle"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"
provider = "postgresql"

View File

@ -7,28 +7,32 @@ generator client {
}
datasource db {
provider = "sqlite"
provider = "postgres"
url = env("DATABASE_URL")
}
model Feed {
id Int @id @default(autoincrement())
name String
url String
guild_id String
active Boolean
created_at DateTime @default(now())
updated_at DateTime @updatedAt
channels Channel[]
filters Filter[]
id Int @id @default(autoincrement())
name String
url String
guild_id String
active Boolean
created_at DateTime @default(now())
updated_at DateTime @updatedAt
channels Channel[]
filters Filter[]
message_style MessageStyle? @relation(fields: [message_style_id], references: [id], onDelete: SetNull)
message_style_id Int?
published_threshold DateTime
@@unique([guild_id, name])
@@index([guild_id, created_at(sort: Desc)])
}
model Channel {
id Int @id @default(autoincrement())
channel_id String
Feed Feed @relation(fields: [feedId], references: [id])
Feed Feed @relation(fields: [feedId], references: [id], onDelete: Cascade)
feedId Int
}
@ -44,6 +48,7 @@ model Filter {
updated_at DateTime @updatedAt
feeds Feed[]
@@unique([guild_id, name])
@@index([guild_id, created_at(sort: Desc)])
}
@ -54,3 +59,47 @@ enum MatchingAlgorithms {
REGEX
FUZZY
}
model MessageStyle {
id Int @id @default(autoincrement())
guild_id String
name String
colour String @db.VarChar(7) // hex colour: #5865F2
show_author Boolean
show_image Boolean
show_thumbnail Boolean
show_footer Boolean
show_timestamp Boolean
title_mutator TextMutator?
description_mutator TextMutator?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
Feed Feed[]
@@unique([guild_id, name])
@@index([guild_id, created_at(sort: Desc)])
}
// Entertainment mutators for message styles
enum TextMutator {
UWUIFY
UWUIFY_SFW
GOTHIC_SCRIPT
EMOJI_SUBSTITUTE
ZALGO
MORSE_CODE
BINARY
HEXADECIMAL
REMOVE_VOWELS
DOUBLE_CHARACTERS
SMALL_CASE
LEET_SPEAK
PIG_LATIN
UPSIDE_DOWN
ALL_REVERSED
REVERSED_WORDS
SHUFFLE_WORDS
RANDOM_CASE
GIBBERISH
SHAKESPEAREAN
}

View File

@ -2,64 +2,8 @@ import Prisma, { PrismaClient } from "../generated/prisma";
const client = new PrismaClient();
function getRandomDate(from: Date, to: Date) {
const fromDate = from.getTime();
return new Date(fromDate + Math.random() * (to.getTime() - fromDate));
}
function generateRandomChannelId() {
const min = 1000000000;
const max = 9999999999;
return Math.floor(Math.random() * (max - min + 1)) + min + '';
}
async function createManyChannels(feedId: number) {
const channelData: Prisma.Prisma.ChannelCreateManyInput[] = [ ];
if (Math.random() < .5) return;
for (let i = 0; i < Math.floor(Math.random() * 10) + 1; i++) {
channelData.push({
channel_id: generateRandomChannelId(),
feedId: feedId
});
}
await client.channel.createMany({ data: channelData });
}
async function deleteAllFeeds() {
await client.feed.deleteMany()
}
async function createManyFeeds() {
const feedData: Prisma.Prisma.FeedCreateManyInput[] = [ ];
for (let i = 0; i < 35; i++) {
feedData.push({
name: `News Network ${i}`,
url: `https://news-network-${i}.com/rss`,
guild_id: "1204426362794811453",
active: Math.random() < .5,
created_at: getRandomDate(
new Date("2020-01-01T00:00:00.000Z"),
new Date("2025-04-27T00:00:00.000Z")
)
});
}
const feedIds = await client.feed.createManyAndReturn({
data: feedData,
select: { id: true }
});
feedIds.forEach(async feed => await createManyChannels(feed.id));
}
async function main() {
// await deleteAllFeeds();
// await createManyFeeds();
}
async function main() { }
main()
.then(async () => { await client.$disconnect() })

View File

@ -1,5 +1,6 @@
@import "tailwindcss";
@import "./preline";
@import "../../../../node_modules/preline/src/plugins/datepicker/styles.css";
@config "../../../../tailwind.config.js";
@ -126,16 +127,37 @@
/* Tag Select */
.cj-tag-select-wrapper {
@apply relative form-select has-invalid:group-[.submitted]:border-red-500
has-invalid:group-[.submitted]:ring-red-500 py-0 ps-0.5 pe-9 min-h-[46px] flex items-center flex-wrap
text-nowrap w-full border border-gray-200 rounded-lg text-start text-sm focus:border-blue-500
focus:ring-blue-500 dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400;
@apply
relative
form-select
py-0
ps-0.5
pe-9
min-h-[46px]
flex
items-center
flex-wrap
text-nowrap
w-full
border
rounded-lg
text-start
text-sm
border-gray-200
focus:border-blue-500
focus:ring-blue-500
dark:bg-neutral-900
dark:border-neutral-700
dark:text-neutral-400
has-invalid:group-[.submitted]:border-red-500
has-invalid:group-[.submitted]:ring-red-500;
}
.cj-tag-select-dropdown {
@apply
z-80
min-w-fit
min-h-fit
max-h-72
p-1.5
space-y-0.5
@ -155,7 +177,14 @@
}
.cj-tag-select-input {
@apply px-2 rounded-xs order-1 text-sm outline-hidden dark:bg-neutral-900 dark:placeholder-neutral-500
@apply
px-2
rounded-xs
order-1
text-sm
outline-hidden
dark:bg-neutral-900
dark:placeholder-neutral-500
dark:text-neutral-400;
}
@ -183,6 +212,84 @@
@apply text-xs text-gray-500 dark:text-neutral-500;
}
.cj-tag-select-search {
@apply
block
w-full
rounded-lg
py-1.5
sm:py-2
px-3
sm:text-sm
bg-white
text-gray-800
border-gray-200
dark:text-neutral-400
dark:bg-neutral-900
dark:border-neutral-700;
}
.cj-tag-select-search-wrapper {
@apply
p-2
-mx-1
-mt-1
sticky
top-0
bg-none
}
.cj-tag-select-search-no-results {
@apply block p-4;
}
/* Normal Select */
.cj-select-toggle {
@apply form-select hs-select-disabled:pointer-events-none
hs-select-disabled:opacity-50 relative py-2 px-3 pe-9 flex text-nowrap w-full
cursor-pointer border rounded-lg text-start text-sm
focus:outline-hidden before:absolute before:inset-0 before:z-1
placeholder-gray-800
bg-white
border-gray-200
dark:text-neutral-200
dark:bg-neutral-900
dark:border-neutral-700
dark:placeholder-neutral-500
peer-invalid:group-[.submitted]:border-red-500
peer-invalid:group-[.submitted]:ring-red-500
}
.cj-select-option {
@apply py-2 px-3 w-full text-sm text-gray-800 cursor-pointer hover:bg-gray-100
rounded-lg focus:outline-hidden focus:bg-gray-100 dark:bg-neutral-900
dark:hover:bg-neutral-800 dark:text-neutral-200 dark:focus:bg-neutral-800;
}
.cj-select-dropdown {
@apply
z-80
w-full
min-h-0
max-h-72
p-1.5
space-y-0.5
bg-white
border
border-gray-200 rounded-lg overflow-hidden
overflow-y-auto
[&::-webkit-scrollbar]:w-2
[&::-webkit-scrollbar-thumb]:rounded-full
[&::-webkit-scrollbar-track]:rounded-full
[&::-webkit-scrollbar-track]:bg-gray-100
[&::-webkit-scrollbar-thumb]:bg-gray-300
dark:[&::-webkit-scrollbar-track]:bg-neutral-700
dark:[&::-webkit-scrollbar-thumb]:bg-neutral-500
dark:bg-neutral-900
dark:border-neutral-700;
}
/* Layout Sidebar */
@ -223,6 +330,7 @@
text-sm
disabled:opacity-50
disabled:pointer-events-none
text-neutral-600
border-gray-200
focus:border-blue-500
focus:ring-blue-500
@ -230,9 +338,41 @@
dark:border-neutral-700
dark:text-neutral-400
dark:placeholder-neutral-500
dark:focus:ring-neutral-600
dark:focus:ring-neutral-600;
}
.text-input-help {
@apply mt-2 text-sm text-gray-500 dark:text-neutral-500
}
}
.select-input {
@apply
relative
py-3
ps-4
pe-9
flex
gap-x-2
text-nowrap
w-full
cursor-pointer
bg-white
border
border-gray-200
rounded-lg
text-start
text-sm
focus:outline-hidden
focus:ring-2
focus:ring-blue-500
dark:bg-neutral-900
dark:border-neutral-700
dark:text-neutral-400
dark:focus:outline-hidden
dark:focus:ring-1
dark:focus:ring-neutral-600;
}
/* Vanilla Calendar z-index */
.vc { z-index: 80; }

View File

@ -1,23 +1,18 @@
import $ from "jquery";
import DataTable from "datatables.net";
import HSDropdown from "@preline/dropdown";
import HSOverlay, { IOverlayOptions } from "@preline/overlay";
import HSSelect, { ISelectOptions } from "@preline/select";
import HSDataTable, { IDataTableOptions } from "@preline/datatable";
import { 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 { ISingleOption } from "preline";
import { TextChannel } from "discord.js";
declare let guildId: string;
declare let channels: Array<any>;
// #region DataTable
//
// Fix dependency bugs with preline
(window as any).DataTable = DataTable;
(window as any).$hsDataTableCollection = [];
const emptyTableHtml: string = `
<div class="max-w-md w-full min-h-[400px] flex flex-col justify-center mx-auto px-6 py-4">
@ -33,7 +28,7 @@ const emptyTableHtml: string = `
</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" data-hs-overlay="#TODO">
<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>
@ -48,13 +43,11 @@ 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">
<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=""/>
<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>
@ -66,8 +59,8 @@ const columnDefs: ConfigColumnDefs[] = [
orderable: true,
searchable: true,
className: "size-px whitespace-nowrap",
render: (data: string) => { return `
<span class="cj-table-link max-w-[250px] truncate">
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>
`}
@ -87,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; }
@ -109,16 +100,15 @@ const columnDefs: ConfigColumnDefs[] = [
if (data.length === 1) {
return wrapper.get(0);
}
else if (data.length <= 2) {
const secondChannelName = "# " + channels.find(c => c.id === data[1].channel_id).name;
data.shift();
if (data.length <= 1) {
const secondChannelName = "# " + channels.find(c => c.id === data[0].channel_id).name;
wrapper.append(tag.clone().text(secondChannelName));
data.shift();
return wrapper.get(0);
}
// drop the first element to exclude it from the dropdown
data.shift();
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");
@ -138,36 +128,64 @@ const columnDefs: ConfigColumnDefs[] = [
{
target: 4,
data: "filters",
orderable: false,
searchable: false,
className: "size-px whitespace-nowrap",
render: (data: prisma.Filter[]) => { return `
<div class="px-6 py-4">
<span class="cj-table-text">
${data}
</span>
</div>
`}
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: "message_style",
orderable: false, // both should be true, but message_style doesnt exist yet
searchable: false,
data: null, // "message_style_id"
className: "size-px whitespace-nowrap",
render: (data: string) => { return `
<div class="px-6 py-4">
<span class="cj-table-text">
${data}
</span>
</div>
`}
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,
searchable: false,
className: "size-px whitespace-nowrap",
render: (data: string) => { return `
<div class="px-6 py-4">
@ -181,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");
@ -220,6 +237,10 @@ 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" },
@ -234,29 +255,18 @@ const tableOptions: IDataTableOptions = {
}
};
const table: HSDataTable = new HSDataTable(
$("#table").get(0) as HTMLElement,
tableOptions
);
let table: HSDataTable;
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).$hsSelectCollection = [];
(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>',
@ -269,44 +279,129 @@ const pageSelectOptions: ISelectOptions = {
</div>`,
toggleClasses: "cj-table-paging-select-toggle",
optionClasses: "cj-table-paging-select-option",
dropdownClasses: `cj-table-paging-select-dropdown`,
dropdownClasses: "cj-table-paging-select-dropdown",
dropdownSpace: 10,
dropdownScope: "parent",
dropdownPlacement: "top",
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 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 = {};
const editModal: HSOverlay = new HSOverlay(
$("#editModal").get(0) as HTMLElement,
editModalOptions
);
let editModal: HSOverlay;
$(document).on("click", ".open-edit-modal-js", async () => {
await openEditModal(-1);
window.addEventListener("preline:ready", () => {
const modalEl = $("#editModal").get(0);
if (!HSOverlay.getInstance(modalEl, true)) {
editModal = new HSOverlay(modalEl, editModalOptions);
}
});
const openEditModal = async (id: number) => {
editModal.open();
// #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("");
$("#formPublishedThreshold").val(new Date().toISOString().slice(0, 16));
$("#formActive").prop("checked", true);
channelSelect.setValue([]);
filterSelect.setValue([]);
styleSelect.setValue("");
};
const closeEditModal = () => {
editModal.close();
};
const loadEditModalData = async (id: number) => {
const feed: ExpandedFeed = await $.ajax({
url: `/guild/${guildId}/feeds/api?id=${id}`,
method: "get"
});
const channelsSelectOptions: ISelectOptions = {
$(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));
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",
@ -324,13 +419,14 @@ const channelsSelectOptions: ISelectOptions = {
<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 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",
@ -340,35 +436,137 @@ const channelsSelectOptions: ISelectOptions = {
dropdownScope: "window",
dropdownSpace: 10,
dropdownPlacement: "bottom",
dropdownVerticalFixedPlacement: null
dropdownVerticalFixedPlacement: null,
hasSearch: false,
searchNoResultClasses: "cj-tag-select-search-no-results",
};
const channelSelect: HSSelect = new HSSelect(
$("#formChannels").get(0) as HTMLElement,
channelsSelectOptions
);
const filterSelectOptions: ISelectOptions = {
placeholder: "Select option....",
mode: "tags",
$("#editForm").on("submit", async event => {
event.preventDefault();
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",
const form = $(event.target).get(0) as HTMLFormElement;
$(form).addClass("submitted");
dropdownScope: "window",
dropdownSpace: 10,
dropdownPlacement: "bottom",
dropdownVerticalFixedPlacement: null,
if (!form.checkValidity()) return;
// 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",
await $.ajax({
url: `/guild/${guildId}/feeds/api`,
method: "post",
dataType: "json",
data: $(event.target).serializeArray(),
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));
}
});
};
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)) return;
channelSelect = new HSSelect(channelEl, channelSelectOptions);
filterSelect = new HSSelect(filterEl, filterSelectOptions);
styleSelect = new HSSelect(styleEl, styleSelectOptions);
// 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,15 +1,19 @@
import $ from "jquery";
import HSSelect, { ISelectOptions } from "@preline/select";
import "datatables.net-select-dt";
import HSDropdown from "@preline/dropdown";
import HSOverlay, { IOverlayOptions } from "@preline/overlay";
import HSSelect, { ISelectOptions, ISingleOption } from "@preline/select";
import HSDataTable, { IDataTableOptions } from "@preline/datatable";
import DataTable from "datatables.net";
import { ConfigColumnDefs, AjaxSettings } from "datatables.net-dt";
import DataTable, { Api, ConfigColumnDefs, AjaxSettings } from "datatables.net-dt";
import { autoUpdate, computePosition, offset } from "@floating-ui/dom";
import { formatTimestamp } from "../../../src/ts/main";
import { formatTimestamp } from "../main";
import prisma from "../../../../../generated/prisma";
declare let guildId: string;
declare const matchingAlgorithms: { [key: string]: string };
// #region DataTable
//
// Fix dependency bugs with preline
(window as any).DataTable = DataTable;
(window as any).$hsDataTableCollection = [];
@ -39,155 +43,128 @@ const emptyTableHtml: string = `
`;
const columnDefs: ConfigColumnDefs[] = [
// Select checkbox column
{
target: 0,
orderable: false,
searchable: false,
render: (_data: unknown, _type: unknown, row: any) => { return `
<td class="size-px whitespace-nowrap">
// Select checkbox column
{
target: 0,
orderable: false,
searchable: false,
className: "size-px whitespace-nowrap",
render: (_data: unknown, _type: unknown, row: prisma.Filter) => { 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">Checkbox</span>
<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>
</td>
`}
},
{
target: 1,
data: "name",
orderable: true,
searchable: true,
render: (data: string) => { return `
<td class="size-px whitespace-nowrap align-top">
<span class="cj-table-link max-w-[250px] truncate">
`}
},
{
target: 1,
data: "name",
orderable: true,
searchable: true,
className: "size-px whitespace-nowrap",
render: (data: string, _type: string, row: prisma.Filter) => { return `
<span class="cj-table-link open-edit-modal-js max-w-[250px] truncate" data-id="${row.id}">
${data}
</span>
</td>
`}
},
{
target: 2,
data: "value",
orderable: true,
searchable: true,
render: (data: string) => { return `
<td class="size-px whitespace-nowrap align-top">
<div class="px-6 py-4 max-w-[600px] truncate">
`}
},
{
target: 2,
data: "value",
orderable: true,
searchable: true,
className: "size-px whitespace-nowrap",
render: (data: string) => { return `
<div class="px-6 py-4">
<span class="cj-table-text">
${data}
</span>
</div>
</td>
`}
},
{
target: 3,
data: "matching_algorithm",
orderable: true,
searchable: true,
render: (data: string) => {
const wrapper = $("<div>").addClass("px-6 py-4");
const label = $("<span>").addClass("cj-table-text");
let description: string;
`}
},
{
target: 3,
data: "matching_algorithm",
orderable: true,
searchable: false,
className: "size-px whitespace-nowrap",
render: (data: string, type: string) => {
if (type !== "display") return data;
switch (data) {
case "ANY":
description = "Any Word";
break;
case "ALL":
description = "All Words";
break;
case "EXACT":
description = "Exact Match";
break;
case "REGEX":
description = "Regular Expression";
break;
case "FUZZY":
description = "Fuzzy Match";
break;
const wrapper = $("<div>").addClass("px-6 py-4");
const badge = $("<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");
badge.text(matchingAlgorithms[data]);
wrapper.append(badge);
return wrapper.get(0);
}
},
{
target: 4,
data: "is_insensitive",
orderable: true,
searchable: false,
className: "size-px whitespace-nowrap",
render: (data: boolean) => {
const wrapper = $("<div>").addClass("px-6 py-4");
const badge = $("<span>").addClass("inline-flex items-center whitespace-nowrap gap-1 py-1 px-2.5 text-xs rounded-md");
label.text(description);
wrapper.append(label);
return wrapper.get(0);
}
// render: (data: string) => { return `
// <td class="size-px whitespace-nowrap align-top">
// <div class="px-6 py-4">
// <span class="cj-table-text">
// ${data}
// </span>
// </div>
// </td>
// `}
},
{
target: 4,
data: "is_insensitive",
orderable: true,
searchable: true,
render: data => {
const wrapper = $("<div>").addClass("px-6 py-4");
const badge = $("<span>").addClass("py-1 px-1.5 inline-flex items-center text-xs font-medium rounded-full");
const label = $("<span>");
if (data) {
badge.addClass("bg-red-100 text-red-800 dark:bg-red-500/10 dark:text-red-500");
badge.append(label.text("No"));
} else {
badge.addClass("bg-teal-100 text-teal-800 dark:bg-teal-500/10 dark:text-teal-500");
badge.append(label.text("Yes"));
if (data) {
badge.text("Case-Insensitive");
badge.addClass("bg-teal-100 text-teal-800 dark:bg-teal-500/10 dark:text-teal-500");
} else {
badge.text("Case-Sensitive");
badge.addClass("bg-red-100 text-red-800 dark:bg-red-500/10 dark:text-red-500");
}
wrapper.append(badge);
return wrapper.get(0);
}
},
{
target: 5,
data: "is_whitelist",
orderable: true,
searchable: false,
className: "size-px whitespace-nowrap",
render: (data: boolean) => {
const wrapper = $("<div>").addClass("px-6 py-4");
const badge = $("<span>").addClass("inline-flex items-center whitespace-nowrap gap-1 py-1 px-2.5 text-xs rounded-md");
wrapper.append(badge);
return wrapper.get(0);
}
},
{
target: 5,
data: "is_whitelist",
orderable: true,
searchable: true,
render: data => {
const wrapper = $("<div>").addClass("px-6 py-4");
const badge = $("<span>").addClass("py-1 px-2 inline-flex items-center 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(label.text("Whitelist"));
} else {
badge.addClass("bg-red-100 text-red-800 dark:bg-red-500/10 dark:text-red-500");
badge.append(label.text("Blacklist"));
if (data) {
badge.text("Whitelist");
badge.addClass("bg-teal-100 text-teal-800 dark:bg-teal-500/10 dark:text-teal-500");
} else {
badge.text("Blacklist");
badge.addClass("bg-red-100 text-red-800 dark:bg-red-500/10 dark:text-red-500");
}
wrapper.append(badge);
return wrapper.get(0);
}
wrapper.append(badge);
return wrapper.get(0);
}
},
{
target: 6,
data: "created_at",
orderable: true,
searchable: false,
render: (data: string) => { return `
<td class="size-px whitespace-nowrap align-top">
},
{
target: 6,
data: "created_at",
orderable: true,
searchable: false,
className: "size-px whitespace-nowrap",
render: (data: string) => { return `
<div class="px-6 py-4">
<span class="cj-table-text">
${formatTimestamp(data)}
</span>
</div>
</td>
`}
}
`}
},
];
const ajaxSettings: AjaxSettings = {
url: `/guild/${1204426362794811453}/filters/api/datatable`,
url: `/guild/${guildId}/filters/api/datatable`,
type: "POST",
contentType: "application/json",
dataSrc: "data",
@ -202,6 +179,10 @@ 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" },
@ -210,20 +191,60 @@ const tableOptions: IDataTableOptions = {
emptyTable: emptyTableHtml,
loadingRecords: "Placeholder loading message..."
},
drawCallback: () => HSDropdown.autoInit(),
rowCallback: (row: HTMLTableRowElement) => {
$(row).addClass("bg-white dark:bg-neutral-900");
}
};
const table: HSDataTable = new HSDataTable(
$("#table").get(0),
const table = new HSDataTable(
$("#table").get(0) as HTMLElement,
tableOptions
);
)
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.Filter) => row.id);
await $.ajax({
url: `/guild/${guildId}/filters/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);
}
});
});
// #endregion
// #region Page Size Select
// https://preline.co/plugins/html/advanced-select.html
(window as any).$hsSelectCollection = [];
(window as any)["FloatingUIDOM"] = {
@ -232,13 +253,19 @@ const table: HSDataTable = new HSDataTable(
offset: offset
};
// Close on click.
window.addEventListener('click', (evt) => {
const evtTarget = evt.target;
HSSelect.closeCurrentlyOpened(evtTarget as HTMLElement);
});
const pageSelectOptions: ISelectOptions = {
toggleTag: "<button type=\"button\" aria-expanded=\"false\"></button>",
toggleTag: '<button type="button" aria-expanded="false"></button>',
optionTemplate: `
<div class=\"flex justify-between items-center w-full\">
<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 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",
@ -251,8 +278,134 @@ const pageSelectOptions: ISelectOptions = {
};
const pageSizeSelect: HSSelect = new HSSelect(
$("#selectPageSize-js").get(0),
$("#selectPageSize-js").get(0) as HTMLElement,
pageSelectOptions
);
// #endregion
// #region Edit Modal
(window as any).$hsOverlayCollection = [];
const editModalOptions: IOverlayOptions = {};
const editModal: HSOverlay = new HSOverlay(
$("#editModal").get(0) as HTMLElement,
editModalOptions
);
$(document).on("click", ".open-edit-modal-js", async event => {
await openEditModal($(event.target).data("id"));
});
const clearEditModalData = () => {
$(editModal.el).removeData("id");
$("#formName").val("");
$("#formValue").val("");
$("#formInsensitive").prop("checked", false);
$("#formWhitelist").prop("checked", false);
algorithmSelect.setValue("");
};
const loadEditModalData = async (id: number) => {
const filter: prisma.Filter = await $.ajax({
url: `/guild/${guildId}/filters/api?id=${id}`,
method: "get"
});
$(editModal.el).data("id", filter.id);
$("#formName").val(filter.name);
$("#formValue").val(filter.value);
$("#formInsensitive").prop("checked", filter.is_insensitive);
$("#formWhitelist").prop("checked", filter.is_whitelist);
// BUG:
// Breaks the appearance & functionality of the select
algorithmSelect.setValue(filter.matching_algorithm);
}
const openEditModal = async (id: number | undefined) => {
$("#editForm").removeClass("submitted");
editModal.open();
id === undefined
? clearEditModalData()
: loadEditModalData(id);
};
const closeEditModal = () => {
editModal.close();
};
const algorithmSelectOptions: ISelectOptions = {
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
};
const algorithmSelect = new HSSelect(
$("#formAlgorithm").get(0),
algorithmSelectOptions
);
// Add options to algorithm select
Object.entries(matchingAlgorithms).forEach(([key, description]) => {
algorithmSelect.addOption({
title: description,
val: key
} as ISingleOption)
})
$("#editForm").on("submit", async event => {
event.preventDefault();
const form = $(event.target).get(0) as HTMLFormElement;
$(form).addClass("submitted");
if (!form.checkValidity()) return;
let method = "post";
const data = $(event.target).serializeArray();
const id: number | undefined = $(editModal.el).data("id");
if (id !== undefined) {
data.push({
name: "id",
value: `${id}`
})
method = "patch";
}
await $.ajax({
url: `/guild/${guildId}/filters/api`,
dataType: "json",
method: method,
data: data,
success: () => {
(table as any).dataTable.draw();
closeEditModal();
},
error: error => {
alert(JSON.stringify(error, null, 4));
}
});
});
// #endregion

View File

@ -0,0 +1,542 @@
import $ from "jquery";
import "datatables.net-select-dt";
import HSDropdown from "@preline/dropdown";
import HSOverlay, { IOverlayOptions } from "@preline/overlay";
import HSSelect, { ISelectOptions, ISingleOption } 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, genHexString } from "../../../src/ts/main";
import prisma from "../../../../../generated/prisma";
declare let guildId: string;
declare const textMutators: { [key: string]: string };
// #region DataTable
//
// Fix dependency bugs with preline
(window as any).DataTable = DataTable;
(window as any).$hsDataTableCollection = [];
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" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon"><path stroke-linecap="round" stroke-linejoin="round" d="M9.53 16.122a3 3 0 0 0-5.78 1.128 2.25 2.25 0 0 1-2.4 2.245 4.5 4.5 0 0 0 8.4-2.245c0-.399-.078-.78-.22-1.128Zm0 0a15.998 15.998 0 0 0 3.388-1.62m-5.043-.025a15.994 15.994 0 0 1 1.622-3.395m3.42 3.42a15.995 15.995 0 0 0 4.764-4.648l3.876-5.814a1.151 1.151 0 0 0-1.597-1.597L14.146 6.32a15.996 15.996 0 0 0-4.649 4.763m3.42 3.42a6.776 6.776 0 0 0-3.42-3.42"></path></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 style.
Alternatively, use a template to deploy a ready-made style.
</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 style
</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,
orderable: false,
searchable: false,
className: "size-px whitespace-nowrap",
render: (_data: unknown, _type: unknown, row: prisma.MessageStyle) => { 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: "colour",
orderable: true,
searchable: true,
className: "size-px whitespace-nowrap",
render: (data: string) => { return `
<div class="px-6 py-4">
<span class="cj-table-text">
${data}
</span>
</div>
`}
},
{
target: 3,
data: "title_mutator",
orderable: true,
searchable: true,
className: "size-px whitespace-nowrap",
render: (data: string, type: string) => {
if (type !== "display") return data;
if (!data) return "";
const wrapper = $("<div>").addClass("px-6 py-4");
const badge = $("<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");
badge.text(textMutators[data]);
wrapper.append(badge);
return wrapper.get(0);
}
},
{
target: 4,
data: "description_mutator",
orderable: true,
searchable: true,
className: "size-px whitespace-nowrap",
render: (data: string, type: string) => {
if (type !== "display") return data;
if (!data) return "";
const wrapper = $("<div>").addClass("px-6 py-4");
const badge = $("<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");
badge.text(textMutators[data]);
wrapper.append(badge);
return wrapper.get(0);
}
},
{
target: 5,
data: "show_author",
orderable: true,
searchable: false,
className: "size-px whitespace-nowrap",
render: (data: boolean) => {
const wrapper = $("<div>").addClass("px-6 py-4");
const badge = $("<span>").addClass("inline-flex items-center whitespace-nowrap gap-1 py-1 px-2.5 text-xs rounded-md");
if (data) {
badge.text("Show");
badge.addClass("bg-teal-100 text-teal-800 dark:bg-teal-500/10 dark:text-teal-500");
} else {
badge.text("Hide");
badge.addClass("bg-red-100 text-red-800 dark:bg-red-500/10 dark:text-red-500");
}
wrapper.append(badge);
return wrapper.get(0);
}
},
{
target: 6,
data: "show_image",
orderable: true,
searchable: false,
className: "size-px whitespace-nowrap",
render: (data: boolean) => {
const wrapper = $("<div>").addClass("px-6 py-4");
const badge = $("<span>").addClass("inline-flex items-center whitespace-nowrap gap-1 py-1 px-2.5 text-xs rounded-md");
if (data) {
badge.text("Show");
badge.addClass("bg-teal-100 text-teal-800 dark:bg-teal-500/10 dark:text-teal-500");
} else {
badge.text("Hide");
badge.addClass("bg-red-100 text-red-800 dark:bg-red-500/10 dark:text-red-500");
}
wrapper.append(badge);
return wrapper.get(0);
}
},
{
target: 7,
data: "show_thumbnail",
orderable: true,
searchable: false,
className: "size-px whitespace-nowrap",
render: (data: boolean) => {
const wrapper = $("<div>").addClass("px-6 py-4");
const badge = $("<span>").addClass("inline-flex items-center whitespace-nowrap gap-1 py-1 px-2.5 text-xs rounded-md");
if (data) {
badge.text("Show");
badge.addClass("bg-teal-100 text-teal-800 dark:bg-teal-500/10 dark:text-teal-500");
} else {
badge.text("Hide");
badge.addClass("bg-red-100 text-red-800 dark:bg-red-500/10 dark:text-red-500");
}
wrapper.append(badge);
return wrapper.get(0);
}
},
{
target: 8,
data: "show_footer",
orderable: true,
searchable: false,
className: "size-px whitespace-nowrap",
render: (data: boolean) => {
const wrapper = $("<div>").addClass("px-6 py-4");
const badge = $("<span>").addClass("inline-flex items-center whitespace-nowrap gap-1 py-1 px-2.5 text-xs rounded-md");
if (data) {
badge.text("Show");
badge.addClass("bg-teal-100 text-teal-800 dark:bg-teal-500/10 dark:text-teal-500");
} else {
badge.text("Hide");
badge.addClass("bg-red-100 text-red-800 dark:bg-red-500/10 dark:text-red-500");
}
wrapper.append(badge);
return wrapper.get(0);
}
},
{
target: 9,
data: "show_timestamp",
orderable: true,
searchable: false,
className: "size-px whitespace-nowrap",
render: (data: boolean) => {
const wrapper = $("<div>").addClass("px-6 py-4");
const badge = $("<span>").addClass("inline-flex items-center whitespace-nowrap gap-1 py-1 px-2.5 text-xs rounded-md");
if (data) {
badge.text("Show");
badge.addClass("bg-teal-100 text-teal-800 dark:bg-teal-500/10 dark:text-teal-500");
} else {
badge.text("Hide");
badge.addClass("bg-red-100 text-red-800 dark:bg-red-500/10 dark:text-red-500");
}
wrapper.append(badge);
return wrapper.get(0);
}
},
{
target: 10,
data: "created_at",
orderable: true,
searchable: false,
className: "size-px whitespace-nowrap",
render: (data: string) => { return `
<div class="px-6 py-4">
<span class="cj-table-text">
${formatTimestamp(data)}
</span>
</div>
`}
},
];
const ajaxSettings: AjaxSettings = {
url: `/guild/${guildId}/styles/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");
}
};
const table: HSDataTable = new HSDataTable(
$("#table").get(0) as HTMLElement,
tableOptions
);
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.MessageStyle) => row.id);
await $.ajax({
url: `/guild/${guildId}/styles/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);
}
});
});
// #endregion
// #region Page Size Select
// https://preline.co/plugins/html/advanced-select.html
(window as any).$hsSelectCollection = [];
(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);
});
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
};
const pageSizeSelect: HSSelect = new HSSelect(
$("#selectPageSize-js").get(0) as HTMLElement,
pageSelectOptions
);
// #endregion
// #region Edit Modal
(window as any).$hsOverlayCollection = [];
const editModalOptions: IOverlayOptions = {};
const editModal: HSOverlay = new HSOverlay(
$("#editModal").get(0) as HTMLElement,
editModalOptions
);
$(document).on("click", ".open-edit-modal-js", async event => {
await openEditModal($(event.target).data("id"));
});
const clearEditModalData = () => {
$(editModal.el).removeData("id");
$("#formName").val("");
updateColourInput("#5865F2");
titleMutatorSelect.setValue("");
descriptionMutatorSelect.setValue("");
$("#formShowAuthor").prop("checked", true);
$("#formShowImage").prop("checked", true);
$("#formShowThumbnail").prop("checked", true);
$("#formShowFooter").prop("checked", true);
$("#formShowTimestamp").prop("checked", true);
};
const loadEditModalData = async (id: number) => {
const style: prisma.MessageStyle = await $.ajax({
url: `/guild/${guildId}/styles/api?id=${id}`,
method: "get"
});
$(editModal.el).data("id", style.id);
$("#formName").val(style.name);
updateColourInput("#5865F2");
titleMutatorSelect.setValue(style.title_mutator || "");
descriptionMutatorSelect.setValue(style.description_mutator || "");
$("#formShowAuthor").prop("checked", style.show_author);
$("#formShowImage").prop("checked", style.show_image);
$("#formShowThumbnail").prop("checked", style.show_thumbnail);
$("#formShowFooter").prop("checked", style.show_footer);
$("#formShowTimestamp").prop("checked", style.show_timestamp);
}
const openEditModal = async (id: number | undefined) => {
$("#editForm").removeClass("submitted");
editModal.open();
id === undefined
? clearEditModalData()
: loadEditModalData(id);
};
const closeEditModal = () => {
editModal.close();
};
const mutatorSelectOptions: ISelectOptions = {
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
};
const titleMutatorSelect = new HSSelect(
$("#formTitleMutator").get(0),
mutatorSelectOptions
);
// Add options to title mutator select
titleMutatorSelect.addOption({ title: "None", val: "" });
Object.entries(textMutators).forEach(([key, description]) => {
titleMutatorSelect.addOption({
title: description,
val: key
} as ISingleOption)
});
const descriptionMutatorSelect = new HSSelect(
$("#formDescriptionMutator").get(0),
mutatorSelectOptions
);
// Add options to description mutator select
descriptionMutatorSelect.addOption({ title: "None", val: "" });
Object.entries(textMutators).forEach(([key, description]) => {
descriptionMutatorSelect.addOption({
title: description,
val: key
} as ISingleOption)
});
const colourPicker = $("#formColour") as JQuery<HTMLInputElement>;
const colourTextInput = $("#formColourInput") as JQuery<HTMLInputElement>;
const colourRandomBtn = $("#formColourRandomBtn") as JQuery<HTMLButtonElement>;
const updateColourInput = (value: string) => {
value = "#" + value.replace(/[^A-F0-9]/gi, '')
.toUpperCase()
.slice(0, 6)
.padEnd(6, "0");
colourPicker.val(value);
colourTextInput.val(value);
};
colourPicker.on("change", _ => updateColourInput(colourPicker.val()));
colourTextInput.on("change", _ => updateColourInput(colourTextInput.val()));
colourRandomBtn.on("click", _ => updateColourInput(genHexString(6)));
$("#editForm").on("submit", async event => {
event.preventDefault();
const form = $(event.target).get(0) as HTMLFormElement;
$(form).addClass("submitted");
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}/styles/api`,
dataType: "json",
method: method,
data: data,
success: () => {
(table as any).dataTable.draw()
closeEditModal();
},
error: error => {
alert(JSON.stringify(error, null, 4));
}
});
});
// #endregion

View File

@ -1,7 +1,32 @@
import "preline";
import $ from "jquery";
import _ from "lodash";
import noUiSlider from "nouislider";
import "datatables.net";
import "dropzone/dist/dropzone-min.js";
import * as VanillaCalendarPro from "vanilla-calendar-pro";
import * as FloatingUIDOM from "@floating-ui/dom";
import { Channel } from "discord.js";
import prisma from "../../../../generated/prisma";
// Preline: requirements
window._ = _;
window.$ = $;
window.jQuery = $;
window.DataTable = $.fn.dataTable;
window.noUiSlider = noUiSlider;
window.VanillaCalendarPro = VanillaCalendarPro;
window.FloatingUIDOM = FloatingUIDOM
document.addEventListener("DOMContentLoaded", () => {
window.HSStaticMethods.autoInit("all");
setTimeout(() => {
window.dispatchEvent(new Event("preline:ready"));
}, 100);
});
// Preline: necessary for header events.
window.addEventListener("load", () => {
const inputs = document.querySelectorAll('.dt-container thead input');
@ -43,4 +68,12 @@ export const verifyChannels = (data: prisma.Channel[], channels: Channel[]) => {
return data.some(item => {
return channels.map(channel => channel.id).includes(item.channel_id);
});
};
};
export function genHexString(len=6) {
let output = '';
for (let i = 0; i < len; ++i) {
output += (Math.floor(Math.random() * 16)).toString(16);
}
return output;
}

23
src/client/src/types/client.d.ts vendored Normal file
View File

@ -0,0 +1,23 @@
import type noUiSlider from "nouislider";
import type { IStaticMethods } from "preline/dist";
declare global {
interface Window {
// Optional third-party libraries
_: typeof import("lodash");
$: typeof import("jquery");
jQuery: typeof import("jquery");
DataTable: typeof $.fn.dataTable;
Dropzone: typeof import("dropzone");
noUiSlider: typeof noUiSlider;
VanillaCalendarPro: typeof import("vanilla-calendar-pro");
// Preline UI
HSStaticMethods: IStaticMethods;
// Floating UI
FloatingUIDOM: typeof import("@floating-ui/dom");
}
}
export {};

View File

@ -3,11 +3,16 @@
"target": "ES5",
"outDir": "./public/generated/js",
"rootDir": "./src/ts",
"baseUrl": ".",
"sourceMap": false,
"esModuleInterop": true,
"noImplicitAny": true
"noImplicitAny": true,
"typeRoots": [
"./src/types"
]
},
"include": [
"./src/ts/**/*"
"./src/ts/**/*",
"./src/types"
]
}

View File

@ -22,6 +22,13 @@
</div>
<div class="sm:col-span-2">
<button type="button" id="deleteRowsBtn" disabled class="py-2 px-3 inline-flex items-center gap-x-2 text-sm font-medium rounded-lg border border-gray-200 bg-white text-red-500 shadow-xs hover:bg-gray-50 focus:outline-hidden focus:bg-gray-50 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-900 dark:border-neutral-700 dark:hover:bg-neutral-800 dark:focus:bg-neutral-800" href="#">
<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="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
<span>
<span class="hidden sm:inline">Delete</span>
<span class="rows-selected-count-js zero-empty-js before:content-['('] after:content-[')'] empty:hidden"></span>
</span>
</button>
<button type="button" class="open-edit-modal-js py-2 px-3 inline-flex 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>
<span>
@ -44,7 +51,7 @@
<span class="sr-only">Checkbox</span>
</label>
</th>
<th scope="col" data-dt-column="name" class="cj-table-header">
<th scope="col" class="cj-table-header">
<div class="cj-table-header-content cursor-pointer">
<span>Name</span>
<svg 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">
@ -52,7 +59,7 @@
</svg>
</div>
</th>
<th scope="col" data-dt-column="url" class="cj-table-header">
<th scope="col" class="cj-table-header">
<div class="cj-table-header-content cursor-pointer">
<span>URL</span>
<svg 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">
@ -60,25 +67,22 @@
</svg>
</div>
</th>
<th scope="col" data-dt-column="channels" class="cj-table-header --exclude-from-ordering">
<th scope="col" class="cj-table-header --exclude-from-ordering">
<div class="cj-table-header-content">
<span>Channels</span>
</div>
</th>
<th scope="col" data-dt-column="filters" class="cj-table-header --exclude-from-ordering">
<th scope="col" class="cj-table-header --exclude-from-ordering">
<div class="cj-table-header-content">
<span>Filters</span>
</div>
</th>
<th scope="col" data-dt-column="style" class="cj-table-header">
<th scope="col" class="cj-table-header --exclude-from-ordering">
<div class="cj-table-header-content cursor-pointer">
<span>Style</span>
<svg 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="m7 15 5 5 5-5"></path><path d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
<th scope="col" data-dt-column="created_at" class="cj-table-header">
<th scope="col" class="cj-table-header">
<div class="cj-table-header-content cursor-pointer">
<span>Created at</span>
<svg 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">
@ -86,7 +90,7 @@
</svg>
</div>
</th>
<th scope="col" data-dt-column="active" class="cj-table-header">
<th scope="col" class="cj-table-header">
<div class="cj-table-header-content cursor-pointer">
<span>Status</span>
<svg 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">
@ -137,7 +141,7 @@
</div>
</div>
<div id="editModal" class="hs-overlay hidden size-full fixed top-0 start-0 z-80 overflow-x-hidden overflow-y-auto pointer-events-none" role="dialog" tabindex="-1">
<div id="editModal" class="hidden size-full fixed top-0 start-0 z-80 overflow-x-hidden overflow-y-auto pointer-events-none" role="dialog" tabindex="-1">
<div class="hs-overlay-animation-target hs-overlay-open:scale-100 hs-overlay-open:opacity-100 scale-95 opacity-0 ease-in-out transition-all duration-200 lg:max-w-4xl lg:w-full m-3 lg:mx-auto min-h-[calc(100%-3.5rem)] flex items-center">
<div class="w-full p-4 sm:p-7 flex flex-col bg-white border shadow-xs rounded-lg pointer-events-auto dark:bg-neutral-900 dark:border-neutral-800 dark:shadow-neutral-700/70">
<div class="mb-8">
@ -167,35 +171,44 @@
<p class="mt-2 text-sm text-red-500 hidden peer-invalid:group-[.submitted]:block">
Please enter a valid URL.
</p>
</div>
<div>
</div>
<div>
</div>
<div class="relative">
<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.
</p>
</div>
<div class="relative">
<label for="formFilters" class="text-input-label">Filters</label>
<select name="filters" id="formFilters" class="--prevent-on-load-init" multiple>
<option value="">Choose</option>
</select>
<p class="text-input-help">
Filter out unwanted content from this feed.
</p>
</div>
<div class="relative">
<label for="formMessageStyle" class="text-input-label">Message Style</label>
<select name="message_style" id="formMessageStyle" class="--prevent-on-load-init">
<option value="">None</option>
</select>
<p class="text-input-help">
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="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">
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.
</p>
</div>
<div></div>
<div></div>
<div>
<label for="formActive" class="flex gap-4">
<input type="checkbox" id="formActive" name="active" class="form-radio relative w-[3.25rem] h-7 p-px bg-gray-100 border-transparent text-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:ring-blue-600 disabled:opacity-50 disabled:pointer-events-none checked:bg-none checked:text-blue-600 checked:border-blue-600 focus:checked:border-blue-600 dark:bg-neutral-800 dark:border-neutral-700 dark:checked:bg-blue-500 dark:checked:border-blue-500 dark:focus:ring-offset-gray-600 before:inline-block before:size-6 before:bg-white checked:before:bg-blue-200 before:translate-x-0 checked:before:translate-x-full before:rounded-full before:shadow-sm before:transform before:ring-0 before:transition before:ease-in-out before:duration-200 dark:before:bg-neutral-400 dark:checked:before:bg-blue-200">

View File

@ -22,6 +22,13 @@
</div>
<div class="sm:col-span-2">
<button type="button" id="deleteRowsBtn" disabled class="py-2 px-3 inline-flex items-center gap-x-2 text-sm font-medium rounded-lg border border-gray-200 bg-white text-red-500 shadow-xs hover:bg-gray-50 focus:outline-hidden focus:bg-gray-50 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-900 dark:border-neutral-700 dark:hover:bg-neutral-800 dark:focus:bg-neutral-800" href="#">
<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="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
<span>
<span class="hidden sm:inline">Delete</span>
<span class="rows-selected-count-js zero-empty-js before:content-['('] after:content-[')'] empty:hidden"></span>
</span>
</button>
<button type="button" class="open-edit-modal-js py-2 px-3 inline-flex 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>
<span>
@ -38,83 +45,62 @@
<table class="cj-table">
<thead class="cj-thead">
<tr>
<th scope="col" class="ps-6 py-3 text-start --exclude-from-ordering">
<label for="selectAllBox" class="">
<th scope="col" class="cj-table-header --exclude-from-ordering">
<label for="selectAllBox" class="flex">
<input type="checkbox" id="selectAllBox" class="cj-table-checkbox">
<span class="sr-only">Checkbox</span>
</label>
</th>
<th scope="col" data-dt-column="name" class="px-6 py-3 text-start">
<div class="flex justify-between item-center gap-x-2 cursor-pointer">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-800 dark:text-neutral-200">
Name
</span>
<svg class="size-3.5 ms-1 -me-0.5 text-gray-400 dark:text-neutral-500" 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 class="hs-datatable-ordering-desc:text-blue-600 dark:hs-datatable-ordering-desc:text-blue-500" d="m7 15 5 5 5-5"></path>
<path class="hs-datatable-ordering-asc:text-blue-600 dark:hs-datatable-ordering-asc:text-blue-500" d="m7 9 5-5 5 5"></path>
<th scope="col" data-dt-column="name" class="cj-table-header">
<div class="cj-table-header-content cursor-pointer">
<span>Name</span>
<svg 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="m7 15 5 5 5-5"></path><path d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
<th scope="col" data-dt-column="url" class="px-6 py-3 text-start">
<div class="flex justify-between items-center gap-x-2 cursor-pointer">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-800 dark:text-neutral-200">
Value
</span>
<svg class="size-3.5 ms-1 -me-0.5 text-gray-400 dark:text-neutral-500" 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 class="hs-datatable-ordering-desc:text-blue-600 dark:hs-datatable-ordering-desc:text-blue-500" d="m7 15 5 5 5-5"></path>
<path class="hs-datatable-ordering-asc:text-blue-600 dark:hs-datatable-ordering-asc:text-blue-500" d="m7 9 5-5 5 5"></path>
<th scope="col" data-dt-column="value" class="cj-table-header">
<div class="cj-table-header-content cursor-pointer">
<span>Value</span>
<svg 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="m7 15 5 5 5-5"></path><path d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
<th scope="col" data-dt-column="url" class="px-6 py-3 text-start">
<div class="flex justify-between items-center gap-x-2 cursor-pointer">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-800 dark:text-neutral-200">
Algorithm
</span>
<svg class="size-3.5 ms-1 -me-0.5 text-gray-400 dark:text-neutral-500" 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 class="hs-datatable-ordering-desc:text-blue-600 dark:hs-datatable-ordering-desc:text-blue-500" d="m7 15 5 5 5-5"></path>
<path class="hs-datatable-ordering-asc:text-blue-600 dark:hs-datatable-ordering-asc:text-blue-500" d="m7 9 5-5 5 5"></path>
<th scope="col" data-dt-column="matching_algorithm" class="cj-table-header">
<div class="cj-table-header-content cursor-pointer">
<span>Algorithm</span>
<svg 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="m7 15 5 5 5-5"></path><path d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
<th scope="col" data-dt-column="url" class="px-6 py-3 text-start">
<div class="flex justify-between items-center gap-x-2 cursor-pointer">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-800 dark:text-neutral-200">
Case-Sensitive
</span>
<svg class="size-3.5 ms-1 -me-0.5 text-gray-400 dark:text-neutral-500" 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 class="hs-datatable-ordering-desc:text-blue-600 dark:hs-datatable-ordering-desc:text-blue-500" d="m7 15 5 5 5-5"></path>
<path class="hs-datatable-ordering-asc:text-blue-600 dark:hs-datatable-ordering-asc:text-blue-500" d="m7 9 5-5 5 5"></path>
<th scope="col" data-dt-column="is_insensitive" class="cj-table-header">
<div class="cj-table-header-content cursor-pointer">
<span>Case Sensitivity</span>
<svg 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="m7 15 5 5 5-5"></path><path d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
<th scope="col" data-dt-column="url" class="px-6 py-3 text-start">
<div class="flex justify-between items-center gap-x-2 cursor-pointer">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-800 dark:text-neutral-200">
Type
</span>
<svg class="size-3.5 ms-1 -me-0.5 text-gray-400 dark:text-neutral-500" 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 class="hs-datatable-ordering-desc:text-blue-600 dark:hs-datatable-ordering-desc:text-blue-500" d="m7 15 5 5 5-5"></path>
<path class="hs-datatable-ordering-asc:text-blue-600 dark:hs-datatable-ordering-asc:text-blue-500" d="m7 9 5-5 5 5"></path>
<th scope="col" data-dt-column="is_whitelist" class="cj-table-header">
<div class="cj-table-header-content cursor-pointer">
<span>Filter Type</span>
<svg 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="m7 15 5 5 5-5"></path><path d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
<th scope="col" data-dt-column="created_at" class="px-6 py-3 text-start">
<div class="flex justify-between items-center gap-x-2 cursor-pointer">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-800 dark:text-neutral-200 text-nowrap">
Created at
</span>
<svg class="size-3.5 ms-1 -me-0.5 text-gray-400 dark:text-neutral-500" 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 class="hs-datatable-ordering-desc:text-blue-600 dark:hs-datatable-ordering-desc:text-blue-500" d="m7 15 5 5 5-5"></path>
<path class="hs-datatable-ordering-asc:text-blue-600 dark:hs-datatable-ordering-asc:text-blue-500" d="m7 9 5-5 5 5"></path>
<th scope="col" data-dt-column="created_at" class="cj-table-header">
<div class="cj-table-header-content cursor-pointer">
<span>Created at</span>
<svg 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="m7 15 5 5 5-5"></path><path d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
@ -156,17 +142,72 @@
</div>
</div>
<div id="editModal" class="hs-overlay hidden size-full fixed top-0 start-0 z-80 overflow-x-hidden overflow-y-auto pointer-events-none" role="dialog" tabindex="-1" aria-labelledby="hs-scale-animation-modal-label">
<div class="hs-overlay-animation-target hs-overlay-open:scale-100 hs-overlay-open:opacity-100 scale-95 opacity-0 ease-in-out transition-all duration-200 lg:max-w-4xl lg:w-full m-3 lg:mx-auto min-h-[calc(100%-3.5rem)] flex items-center">
<div id="editModal" class="hs-overlay hidden size-full fixed top-0 start-0 z-80 overflow-x-hidden overflow-y-auto pointer-events-none" role="dialog" tabindex="-1">
<div class="hs-overlay-animation-target hs-overlay-open:scale-100 hs-overlay-open:opacity-100 scale-95 opacity-0 ease-in-out transition-all duration-200 lg:max-w-lg lg:w-full m-3 lg:mx-auto min-h-[calc(100%-3.5rem)] flex items-center">
<div class="w-full p-4 sm:p-7 flex flex-col bg-white border shadow-xs rounded-lg pointer-events-auto dark:bg-neutral-900 dark:border-neutral-800 dark:shadow-neutral-700/70">
<div class="mb-8">
<h2 class="text-xl font-bold text-gray-800 dark:text-neutral-200">Filters</h2>
<h2 class="text-xl font-bold text-gray-800 dark:text-neutral-200">Filter</h2>
<p class="text-sm text-gray-600 dark:text-neutral-400">
Manage your filters.
Manage your filters to organise the content in your feeds.
</p>
</div>
<form id="editForm" novalidate class="group grid sm:grid-cols-2 gap-y-4 sm:gap-y-6 md:gap-y-8 gap-x-6 sm:gap-x-8 md:gap-x-10">
placeholder form content
<form id="editForm" novalidate class="group grid grid-cols-1 gap-y-4 sm:gap-y-6 md:gap-y-8 gap-x-6 sm:gap-x-8 md:gap-x-10">
<div>
<label for="formName" class="text-input-label">Name</label>
<input type="text" id="formName" name="name" class="form-input text-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">
Human-readable name for this entry.
</p>
<p class="mt-2 text-sm text-red-500 hidden peer-invalid:group-[.submitted]:block">
Please enter a name.
</p>
</div>
<div>
<label for="formValue" class="text-input-label">Value</label>
<input type="text" id="formValue" name="value" class="form-input text-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">
The value to match against feed content.
</p>
<p class="mt-2 text-sm text-red-500 hidden peer-invalid:group-[.submitted]:block">
Please enter a value.
</p>
</div>
<div class="relative">
<label for="formAlgorithm" class="text-input-label">Matching Algorithm</label>
<select name="matching_algorithm" id="formAlgorithm" class="peer --prevent-on-load-init" required>
<option value="">Choose</option>
</select>
<p class="text-input-help block peer-has-invalid:group-[.submitted]:hidden">
How the filter value will be matched against feed content.
</p>
<p class="mt-2 text-sm text-red-500 hidden peer-has-invalid:group-[.submitted]:block">
Please select an algorithm.
</p>
</div>
<div>
<label for="formInsensitive" class="flex gap-4">
<input type="checkbox" id="formInsensitive" name="is_insensitive" class="form-radio relative w-[3.25rem] h-7 p-px bg-gray-100 border-transparent text-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:ring-blue-600 disabled:opacity-50 disabled:pointer-events-none checked:bg-none checked:text-blue-600 checked:border-blue-600 focus:checked:border-blue-600 dark:bg-neutral-800 dark:border-neutral-700 dark:checked:bg-blue-500 dark:checked:border-blue-500 dark:focus:ring-offset-gray-600 before:inline-block before:size-6 before:bg-white checked:before:bg-blue-200 before:translate-x-0 checked:before:translate-x-full before:rounded-full before:shadow-sm before:transform before:ring-0 before:transition before:ease-in-out before:duration-200 dark:before:bg-neutral-400 dark:checked:before:bg-blue-200">
<span class="flex flex-col">
<span class="block text-sm dark:text-neutral-400">Case-Insensitive</span>
<span class="block text-sm text-gray-500 dark:text-neutral-500">
By default the filter value will be case-sensitive.
</span>
</span>
</label>
</div>
<div>
<label for="formWhitelist" class="flex gap-4">
<input type="checkbox" id="formWhitelist" name="is_whitelist" class="form-radio relative w-[3.25rem] h-7 p-px bg-gray-100 border-transparent text-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:ring-blue-600 disabled:opacity-50 disabled:pointer-events-none checked:bg-none checked:text-blue-600 checked:border-blue-600 focus:checked:border-blue-600 dark:bg-neutral-800 dark:border-neutral-700 dark:checked:bg-blue-500 dark:checked:border-blue-500 dark:focus:ring-offset-gray-600 before:inline-block before:size-6 before:bg-white checked:before:bg-blue-200 before:translate-x-0 checked:before:translate-x-full before:rounded-full before:shadow-sm before:transform before:ring-0 before:transition before:ease-in-out before:duration-200 dark:before:bg-neutral-400 dark:checked:before:bg-blue-200">
<span class="flex flex-col">
<span class="block text-sm dark:text-neutral-400">Is Whitelist?</span>
<span class="block text-sm text-gray-500 dark:text-neutral-500">
By default filters will blacklist non-matching content.
</span>
</span>
</label>
</div>
</form>
<div class="flex items-center gap-x-2 mt-8">
<button type="button" class="me-auto py-2 px-3 inline-flex 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">
@ -183,4 +224,8 @@
</div>
</div>
<script>
var guildId = "<%- guild.id %>";
var matchingAlgorithms = JSON.parse(`<%- JSON.stringify( matchingAlgorithms ) %> `);
</script>
<% block("scripts").append('<script src="/public/generated/js/guild/filters.js"></script>'); %>

View File

@ -2,4 +2,312 @@
<%- include("header") -%>
Styles page placeholder
<div id="table" class="--prevent-on-load-init max-w-full px-4 sm:px-6">
<div class="flex flex-col">
<div class="-m-1.5">
<div class="max-w-full min-w-full p-1.5 inline-block align-middle">
<div class="bg-white border border-gray-200 rounded-lg shadow-xs dark:bg-neutral-900 dark:border-neutral-700">
<!-- Header -->
<div class="px-6 py-4 gap-3 flex flex-nowrap justify-between items-center">
<div class="hidden sm:block sm:col-span-1">
<label for="search" class="sr-only">Search</label>
<div class="relative">
<input type="text" id="search" class="form-input px-3 ps-11 block w-full border-gray-200 rounded-lg text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 dark:placeholder-neutral-500 dark:focus:ring-neutral-600" placeholder="Search" data-hs-datatable-search="">
<div class="absolute inset-y-0 start-0 flex items-center pointer-events-none ps-4">
<svg class="shrink-0 size-4 text-gray-400 dark:text-neutral-500" 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"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
</div>
</div>
</div>
<div class="sm:col-span-2">
<button type="button" id="deleteRowsBtn" disabled class="py-2 px-3 inline-flex items-center gap-x-2 text-sm font-medium rounded-lg border border-gray-200 bg-white text-red-500 shadow-xs hover:bg-gray-50 focus:outline-hidden focus:bg-gray-50 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-900 dark:border-neutral-700 dark:hover:bg-neutral-800 dark:focus:bg-neutral-800" href="#">
<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="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
<span>
<span class="hidden sm:inline">Delete</span>
<span class="rows-selected-count-js zero-empty-js before:content-['('] after:content-[')'] empty:hidden"></span>
</span>
</button>
<button type="button" class="open-edit-modal-js py-2 px-3 inline-flex 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>
<span>
Create
<span class="hidden sm:inline">a style</span>
</span>
</button>
</div>
</div>
<!-- Table -->
<div class="min-w-full overflow-x-auto">
<table class="cj-table">
<thead class="cj-thead">
<tr>
<th scope="col" class="cj-table-header --exclude-from-ordering">
<label for="selectAllBox" class="flex">
<input type="checkbox" id="selectAllBox" class="cj-table-checkbox">
<span class="sr-only">Checkbox</span>
</label>
</th>
<th scope="col" data-dt-column="name" class="cj-table-header">
<div class="cj-table-header-content cursor-pointer">
<span>Name</span>
<svg 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="m7 15 5 5 5-5"></path><path d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
<th scope="col" data-dt-column="colour" class="cj-table-header">
<div class="cj-table-header-content cursor-pointer">
<span>Colour</span>
<svg 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="m7 15 5 5 5-5"></path><path d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
<th scope="col" data-dt-column="title_mutator" class="cj-table-header">
<div class="cj-table-header-content cursor-pointer">
<span>Title Mutator</span>
<svg 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="m7 15 5 5 5-5"></path><path d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
<th scope="col" data-dt-column="description_mutator" class="cj-table-header">
<div class="cj-table-header-content cursor-pointer">
<span>Description Mutator</span>
<svg 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="m7 15 5 5 5-5"></path><path d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
<th scope="col" data-dt-column="show_author" class="cj-table-header">
<div class="cj-table-header-content cursor-pointer">
<span>Author</span>
<svg 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="m7 15 5 5 5-5"></path><path d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
<th scope="col" data-dt-column="show_image" class="cj-table-header">
<div class="cj-table-header-content cursor-pointer">
<span>Image</span>
<svg 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="m7 15 5 5 5-5"></path><path d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
<th scope="col" data-dt-column="show_thumbnail" class="cj-table-header">
<div class="cj-table-header-content cursor-pointer">
<span>Thumbnail</span>
<svg 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="m7 15 5 5 5-5"></path><path d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
<th scope="col" data-dt-column="show_footer" class="cj-table-header">
<div class="cj-table-header-content cursor-pointer">
<span>Footer</span>
<svg 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="m7 15 5 5 5-5"></path><path d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
<th scope="col" data-dt-column="show_timestamp" class="cj-table-header">
<div class="cj-table-header-content cursor-pointer">
<span>Timestamp</span>
<svg 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="m7 15 5 5 5-5"></path><path d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
<th scope="col" data-dt-column="created_at" class="cj-table-header">
<div class="cj-table-header-content cursor-pointer">
<span>Created at</span>
<svg 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="m7 15 5 5 5-5"></path><path d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
</tr>
</thead>
</table>
</div>
<div class="cj-table-footer">
<div class="max-w-sm space-y-3">
<select id="selectPageSize-js" data-hs-datatable-page-entities="">
<option value="5">5</option>
<option value="10" selected>10</option>
<option value="15">15</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
<div class="sm:inline-flex items-center gap-x-2" data-hs-datatable-info="">
<p class="text-sm text-gray dark:text-neutral-400">
<span data-hs-datatable-info-from=""></span>
to
<span data-hs-datatable-info-to=""></span>
of
<span data-hs-datatable-info-length=""></span>
</p>
</div>
<div class="inline-flex gap-x-2" data-hs-datatable-paging="">
<button type="button" class="cj-table-paging-btn" data-hs-datatable-paging-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"/></svg>
Prev
</button>
<div class="flex items-center space-x-1" data-hs-datatable-paging-pages=""></div>
<button type="button" class="cj-table-paging-btn" data-hs-datatable-paging-next="">
Next
<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="m9 18 6-6-6-6"/></svg>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="editModal" class="hs-overlay hidden size-full fixed top-0 start-0 z-80 overflow-x-hidden overflow-y-auto pointer-events-none" role="dialog" tabindex="-1">
<div class="hs-overlay-animation-target hs-overlay-open:scale-100 hs-overlay-open:opacity-100 scale-95 opacity-0 ease-in-out transition-all duration-200 lg:max-w-4xl lg:w-full m-3 lg:mx-auto min-h-[calc(100%-3.5rem)] flex items-center">
<div class="w-full p-4 sm:p-7 flex flex-col bg-white border shadow-xs rounded-lg pointer-events-auto dark:bg-neutral-900 dark:border-neutral-800 dark:shadow-neutral-700/70">
<div class="mb-8">
<h2 class="text-xl font-bold text-gray-800 dark:text-neutral-200">Message Style</h2>
<p class="text-sm text-gray-600 dark:text-neutral-400">
Customise the appearance of Discord Embeds containing feed content.
</p>
</div>
<form id="editForm" novalidate class="group grid sm:grid-cols-2 gap-y-4 sm:gap-y-6 md:gap-y-8 gap-x-6 sm:gap-x-8 md:gap-x-10">
<div>
<label for="formName" class="text-input-label">Name</label>
<input type="text" id="formName" name="name" class="form-input text-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">
Human-readable name for this entry.
</p>
<p class="mt-2 text-sm text-red-500 hidden peer-invalid:group-[.submitted]:block">
Please enter a name.
</p>
</div>
<div>
<label for="formColour" class="text-input-label">Embed Colour</label>
<div class="flex rounded-lg peer border-gray-200 dark:border-neutral-700">
<input type="color" id="formColour" name="colour" class="size-11.5 shrink-0 inline-flex justify-center items-center px-1 py-0.5 rounded-s-lg border border-inherit border-e-0 focus:outline-hidden disabled:opacity-50 disabled:pointer-events-none" required>
<input type="text" id="formColourInput" class="form-input text-input !border-s-0 !rounded-none" required>
<button type="button" id="formColourRandomBtn" class="size-11.5 shrink-0 inline-flex justify-center items-center gap-x-2 text-sm font-semibold rounded-e-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="size-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" data-slot="icon">
<path d="M2.578 8.174a.327.327 0 0 0-.328.326v8c0 .267.143.514.373.648l8.04 4.69a.391.391 0 0 0 .587-.338v-7.75a.991.991 0 0 0-.492-.855L2.742 8.217a.327.327 0 0 0-.164-.043Zm2.176 2.972a.964.964 0 0 1 .389.067c.168.067.27.149.367.234.192.171.343.372.48.61.138.238.236.466.287.718.026.127.046.259.02.438a.89.89 0 0 1-.422.642.89.89 0 0 1-.768.045 1.172 1.172 0 0 1-.367-.236 2.368 2.368 0 0 1-.48-.607 2.377 2.377 0 0 1-.287-.721 1.183 1.183 0 0 1-.02-.438.89.89 0 0 1 .422-.642.818.818 0 0 1 .379-.11Zm3.25 1.702a.956.956 0 0 1 .389.064c.168.067.27.151.367.236.192.171.343.37.48.608.138.238.236.468.287.72.026.127.046.259.02.438a.89.89 0 0 1-.422.643c-.293.169-.6.11-.768.043a1.17 1.17 0 0 1-.367-.235 2.378 2.378 0 0 1-.48-.61 2.366 2.366 0 0 1-.287-.718 1.183 1.183 0 0 1-.02-.437.89.89 0 0 1 .422-.643.823.823 0 0 1 .379-.11Zm-3.25 1.5a.956.956 0 0 1 .389.064c.168.067.27.151.367.236.192.171.343.37.48.608.138.238.236.468.287.72.026.127.046.259.02.438a.89.89 0 0 1-.422.643c-.293.169-.6.11-.768.043a1.17 1.17 0 0 1-.367-.235 2.378 2.378 0 0 1-.48-.61 2.366 2.366 0 0 1-.287-.718 1.183 1.183 0 0 1-.02-.437.89.89 0 0 1 .422-.643.823.823 0 0 1 .379-.11Zm3.25 1.75a.956.956 0 0 1 .389.064c.168.067.27.151.367.236.192.171.343.37.48.608.138.238.236.468.287.72.026.127.046.259.02.438a.89.89 0 0 1-.422.643c-.293.169-.6.11-.768.043a1.17 1.17 0 0 1-.367-.235 2.378 2.378 0 0 1-.48-.61 2.366 2.366 0 0 1-.287-.718 1.183 1.183 0 0 1-.02-.437.89.89 0 0 1 .422-.643.823.823 0 0 1 .379-.11Zm13.443-7.924a.327.327 0 0 0-.19.043l-8.015 4.678a.991.991 0 0 0-.492.855v7.799a.363.363 0 0 0 .547.312l8.08-4.713a.752.752 0 0 0 .373-.648v-8a.327.327 0 0 0-.303-.326Zm-5.502 4.707a.83.83 0 0 1 .43.111.89.89 0 0 1 .422.643c.026.179.006.311-.02.437-.051.253-.15.481-.287.719a2.378 2.378 0 0 1-.48.61 1.17 1.17 0 0 1-.367.234.889.889 0 0 1-.768-.043.89.89 0 0 1-.422-.643 1.183 1.183 0 0 1 .02-.437c.051-.253.15-.483.287-.721.137-.238.288-.437.48-.607.097-.086.2-.17.367-.237a.96.96 0 0 1 .338-.066zm3.25 1.5a.83.83 0 0 1 .43.111.89.89 0 0 1 .422.643c.026.179.006.311-.02.437-.051.253-.15.481-.287.719a2.378 2.378 0 0 1-.48.61 1.17 1.17 0 0 1-.367.234.889.889 0 0 1-.768-.043.89.89 0 0 1-.422-.643 1.183 1.183 0 0 1 .02-.437c.051-.253.15-.483.287-.721.137-.238.288-.437.48-.607.097-.086.2-.17.367-.237a.96.96 0 0 1 .338-.066zM12 1.5c-.13 0-.26.033-.377.102L3.533 6.32a.36.36 0 0 0 0 .623l7.74 4.516a1.44 1.44 0 0 0 1.454 0l7.765-4.531a.343.343 0 0 0 0-.592l-8.115-4.734A.745.745 0 0 0 12 1.5Zm-.094 4.078h.102c.274 0 .523.03.767.111.123.041.247.091.39.204a.886.886 0 0 1 .343.685.886.886 0 0 1-.344.686 1.19 1.19 0 0 1-.389.203 2.376 2.376 0 0 1-.767.111c-.275 0-.523-.03-.768-.111a1.19 1.19 0 0 1-.388-.203.886.886 0 0 1-.344-.686c0-.338.201-.573.344-.685a1.19 1.19 0 0 1 .388-.204 2.28 2.28 0 0 1 .666-.11z"></path>
</svg>
</button>
</div>
<p class="text-input-help block peer-has-invalid:group-[.submitted]:hidden">
The colour of the Discord Embed.
</p>
<p class="mt-2 text-sm text-red-500 hidden peer-has-invalid:group-[.submitted]:block">
Please enter a colour.
</p>
</div>
<div class="relative">
<label for="formTitleMutator" class="text-input-label">Title Mutator</label>
<select name="title_mutator" id="formTitleMutator" class="peer --prevent-on-load-init">
<option value="">Choose</option>
</select>
<p class="text-input-help block peer-has-invalid:group-[.submitted]:hidden">
An optional humorous text mutator for the title.
</p>
</div>
<div class="relative">
<label for="formDescriptionMutator" class="text-input-label">Description Mutator</label>
<select name="description_mutator" id="formDescriptionMutator" class="peer --prevent-on-load-init">
<option value="">Choose</option>
</select>
<p class="text-input-help block peer-has-invalid:group-[.submitted]:hidden">
An optional humorous text mutator for the description.
</p>
</div>
<div>
<label for="formShowAuthor" class="flex gap-4">
<input type="checkbox" id="formShowAuthor" name="show_author" class="form-radio relative w-[3.25rem] h-7 p-px bg-gray-100 border-transparent text-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:ring-blue-600 disabled:opacity-50 disabled:pointer-events-none checked:bg-none checked:text-blue-600 checked:border-blue-600 focus:checked:border-blue-600 dark:bg-neutral-800 dark:border-neutral-700 dark:checked:bg-blue-500 dark:checked:border-blue-500 dark:focus:ring-offset-gray-600 before:inline-block before:size-6 before:bg-white checked:before:bg-blue-200 before:translate-x-0 checked:before:translate-x-full before:rounded-full before:shadow-sm before:transform before:ring-0 before:transition before:ease-in-out before:duration-200 dark:before:bg-neutral-400 dark:checked:before:bg-blue-200">
<span class="flex flex-col">
<span class="block text-sm dark:text-neutral-400">Show author</span>
<span class="block text-sm text-gray-500 dark:text-neutral-500">
Show the content author in the message.
</span>
</span>
</label>
</div>
<div>
<label for="formShowImage" class="flex gap-4">
<input type="checkbox" id="formShowImage" name="show_image" class="form-radio relative w-[3.25rem] h-7 p-px bg-gray-100 border-transparent text-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:ring-blue-600 disabled:opacity-50 disabled:pointer-events-none checked:bg-none checked:text-blue-600 checked:border-blue-600 focus:checked:border-blue-600 dark:bg-neutral-800 dark:border-neutral-700 dark:checked:bg-blue-500 dark:checked:border-blue-500 dark:focus:ring-offset-gray-600 before:inline-block before:size-6 before:bg-white checked:before:bg-blue-200 before:translate-x-0 checked:before:translate-x-full before:rounded-full before:shadow-sm before:transform before:ring-0 before:transition before:ease-in-out before:duration-200 dark:before:bg-neutral-400 dark:checked:before:bg-blue-200">
<span class="flex flex-col">
<span class="block text-sm dark:text-neutral-400">Show image</span>
<span class="block text-sm text-gray-500 dark:text-neutral-500">
Show the content image in the message.
</span>
</span>
</label>
</div>
<div>
<label for="formShowThumbnail" class="flex gap-4">
<input type="checkbox" id="formShowThumbnail" name="show_thumbnail" class="form-radio relative w-[3.25rem] h-7 p-px bg-gray-100 border-transparent text-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:ring-blue-600 disabled:opacity-50 disabled:pointer-events-none checked:bg-none checked:text-blue-600 checked:border-blue-600 focus:checked:border-blue-600 dark:bg-neutral-800 dark:border-neutral-700 dark:checked:bg-blue-500 dark:checked:border-blue-500 dark:focus:ring-offset-gray-600 before:inline-block before:size-6 before:bg-white checked:before:bg-blue-200 before:translate-x-0 checked:before:translate-x-full before:rounded-full before:shadow-sm before:transform before:ring-0 before:transition before:ease-in-out before:duration-200 dark:before:bg-neutral-400 dark:checked:before:bg-blue-200">
<span class="flex flex-col">
<span class="block text-sm dark:text-neutral-400">Show thumbnail</span>
<span class="block text-sm text-gray-500 dark:text-neutral-500">
Show the content thumbnail in the message.
</span>
</span>
</label>
</div>
<div>
<label for="formShowFooter" class="flex gap-4">
<input type="checkbox" id="formShowFooter" name="show_footer" class="form-radio relative w-[3.25rem] h-7 p-px bg-gray-100 border-transparent text-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:ring-blue-600 disabled:opacity-50 disabled:pointer-events-none checked:bg-none checked:text-blue-600 checked:border-blue-600 focus:checked:border-blue-600 dark:bg-neutral-800 dark:border-neutral-700 dark:checked:bg-blue-500 dark:checked:border-blue-500 dark:focus:ring-offset-gray-600 before:inline-block before:size-6 before:bg-white checked:before:bg-blue-200 before:translate-x-0 checked:before:translate-x-full before:rounded-full before:shadow-sm before:transform before:ring-0 before:transition before:ease-in-out before:duration-200 dark:before:bg-neutral-400 dark:checked:before:bg-blue-200">
<span class="flex flex-col">
<span class="block text-sm dark:text-neutral-400">Show footer</span>
<span class="block text-sm text-gray-500 dark:text-neutral-500">
Show a footer in the Discord Embed.
</span>
</span>
</label>
</div>
<div>
<label for="formShowTimestamp" class="flex gap-4">
<input type="checkbox" id="formShowTimestamp" name="show_timestamp" class="form-radio relative w-[3.25rem] h-7 p-px bg-gray-100 border-transparent text-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:ring-blue-600 disabled:opacity-50 disabled:pointer-events-none checked:bg-none checked:text-blue-600 checked:border-blue-600 focus:checked:border-blue-600 dark:bg-neutral-800 dark:border-neutral-700 dark:checked:bg-blue-500 dark:checked:border-blue-500 dark:focus:ring-offset-gray-600 before:inline-block before:size-6 before:bg-white checked:before:bg-blue-200 before:translate-x-0 checked:before:translate-x-full before:rounded-full before:shadow-sm before:transform before:ring-0 before:transition before:ease-in-out before:duration-200 dark:before:bg-neutral-400 dark:checked:before:bg-blue-200">
<span class="flex flex-col">
<span class="block text-sm dark:text-neutral-400">Show timestamp</span>
<span class="block text-sm text-gray-500 dark:text-neutral-500">
Show a timestamp in the message.
</span>
</span>
</label>
</div>
</form>
<div class="flex items-center gap-x-2 mt-8">
<button type="button" class="me-auto py-2 px-3 inline-flex 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">
Templates
</button>
<button type="button" class="py-2 px-3 inline-flex 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" data-hs-overlay="#editModal">
Close
</button>
<button type="submit" form="editForm" class="group-invalid:pointer-events-none group-invalid:opacity-30 py-2 px-3 inline-flex 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">
Save changes
</button>
</div>
</div>
</div>
</div>
<script>
var guildId = "<%- guild.id %>";
var textMutators = JSON.parse(`<%- JSON.stringify( textMutators ) %> `);
</script>
<% block("scripts").append('<script src="/public/generated/js/guild/styles.js"></script>'); %>

18
src/log.ts Normal file
View File

@ -0,0 +1,18 @@
import winston from "winston";
const { combine, timestamp, json, printf } = winston.format;
const timestampFormat = "YYYY-MM-DD HH:mm:ss";
export const logger = winston.createLogger({
format: combine(
timestamp({ format: timestampFormat }),
json(),
printf(({ timestamp, level, message, ...data }) => {
const response = { level, message, data };
return JSON.stringify(response);
})
),
transports: [
new winston.transports.Console()
]
});

View File

@ -25,7 +25,7 @@ export const datatableRequest = async <TOrderBy, TWhere>(
model: ModelDelegate,
defaultOrderBy: TOrderBy,
include?: object,
where?: object
baseWhere?: object
) => {
const query = request.body as unknown as DatatableQuery;
@ -33,7 +33,7 @@ export const datatableRequest = async <TOrderBy, TWhere>(
? { [query.columns[query.order[0].column].data]: query.order[0].dir } as unknown as TOrderBy
: defaultOrderBy;
const searchWhere = query.search?.value
let filterWhere = query.search?.value
? {
OR: Object.values(query.columns)
.filter(col => col.searchable)
@ -43,18 +43,18 @@ export const datatableRequest = async <TOrderBy, TWhere>(
}
: {};
where = { ...where, ...searchWhere };
filterWhere = { ...filterWhere, ...baseWhere };
const data = await model.findMany({
skip: query.start,
take: query.length,
orderBy,
where,
include,
orderBy: orderBy,
where: filterWhere,
include: include,
});
const recordsFiltered = await model.count({ where });
const recordsTotal = await model.count();
const recordsFiltered = await model.count({ where: filterWhere });
const recordsTotal = await model.count({ where: baseWhere });
response.json({
data,

View File

@ -1,6 +1,7 @@
import { Request, Response } from "express";
import prisma, { Prisma } from "@server/prisma";
import { datatableRequest } from "@server/controllers/guild/api/dt.module";
import { logger } from "@server/../log";
export const get = async (request: Request, response: Response) => {
if (!request.query.id) {
@ -10,7 +11,7 @@ export const get = async (request: Request, response: Response) => {
const feed = await prisma.feed.findUnique({
where: { id: Number(request.query.id) },
include: { channels: true }
include: { channels: true, filters: true }
});
if (!feed) {
@ -23,23 +24,46 @@ export const get = async (request: Request, response: Response) => {
export const post = async (request: Request, response: Response) => {
const guildId = request.params.guildId;
const { name, url, active, channels } = request.body;
const {
name,
url,
active,
channels,
filters,
message_style,
published_threshold
} = request.body;
logger.debug("Post Feed", request.body);
// channels comes through as either String[] or String
const formattedChannels = Array.isArray(channels)
? channels.map((channelId) => ({ channel_id: channelId }))
: [{ channel_id: channels }]
let formattedChannels = undefined;
if (channels !== undefined) {
formattedChannels = Array.isArray(channels)
? channels.map((channelId) => ({ channel_id: channelId }))
: [{ channel_id: channels }]
}
let formattedFilters = undefined;
if (filters !== undefined) {
formattedFilters = Array.isArray(filters)
? filters.map((filterId) => ({ id: Number(filterId) }))
: [{ id: Number(filters) }]
}
let feed;
try {
feed = await prisma.feed.create({
data: {
data: {
name: name,
url: url,
guild_id: guildId,
active: active === "on",
channels: channels !== undefined ? { create: formattedChannels } : channels
channels: { create: formattedChannels },
filters: { connect: formattedFilters },
message_style_id: message_style === "" ? null : Number(message_style),
published_threshold: new Date(published_threshold)
}
});
}
@ -54,10 +78,75 @@ export const post = async (request: Request, response: Response) => {
response.status(201).json(feed);
};
export const patch = async () => {} // TODO ...
export const patch = async (request: Request, response: Response) => {
const guildId = request.params.guildId;
const {
id,
name,
url,
active,
channels,
filters,
message_style,
published_threshold
} = request.body;
logger.info("Patch Feed", request.body);
// channels comes through as either String[] or String
let formattedChannels = undefined;
if (channels !== undefined) {
formattedChannels = Array.isArray(channels)
? channels.map((channelId) => ({ channel_id: channelId }))
: [{ channel_id: channels }]
}
let formattedFilters = undefined;
if (filters !== undefined) {
formattedFilters = Array.isArray(filters)
? filters.map((filterId) => ({ id: Number(filterId) }))
: [{ id: Number(filters) }]
}
let feed;
try {
feed = await prisma.feed.update({
where: { id: Number(id) },
data: {
name: name,
url: url,
guild_id: guildId,
active: active === "on",
channels: {
deleteMany: {},
create: formattedChannels
},
filters: {
set: [],
connect: formattedFilters
},
message_style_id:
message_style === ""
? null
: Number(message_style),
published_threshold: new Date(published_threshold)
}
});
}
catch (error) {
console.error(error);
if (error instanceof Prisma.PrismaClientKnownRequestError) {
response.status(500).json({ error: error.message });
return;
}
}
response.status(201).json(feed);
}
export const del = async (request: Request, response: Response) => {
const { ids } = request.body;
let { ids } = request.body;
const guildId = request.params.guildId;
if (!ids || !Array.isArray(ids)) {
@ -65,6 +154,8 @@ export const del = async (request: Request, response: Response) => {
return;
}
ids = ids.map(id => Number(id));
try {
await prisma.feed.deleteMany({ where: {
id: { in: ids },
@ -87,8 +178,8 @@ export const datatable = async (request: Request, response: Response) => {
request,
response,
prisma.feed,
{ id: "asc" },
{ channels: true, filters: true },
[{ updated_at: "desc" }, { id: "asc" }],
{ channels: true, filters: true, message_style: true },
{ guild_id: request.params.guildId } // TODO: verify authenticated user can access this guild
);
};

View File

@ -2,6 +2,7 @@ import { Request, Response } from "express";
import prisma, { Prisma } from "@server/prisma";
import { datatableRequest } from "@server/controllers/guild/api/dt.module";
// TODO: this doesn't account for guild ID or permissions
export const get = async (request: Request, response: Response) => {
if (!request.query.id) {
response.status(400).json({ error: "missing 'id' query" });
@ -33,8 +34,8 @@ export const post = async (request: Request, response: Response) => {
guild_id: guildId,
value: value,
matching_algorithm: matching_algorithm,
is_insensitive: is_insensitive,
is_whitelist: is_whitelist
is_insensitive: is_insensitive === "on",
is_whitelist: is_whitelist === "on"
}
});
}
@ -49,10 +50,38 @@ export const post = async (request: Request, response: Response) => {
response.status(201).json(filter);
};
export const patch = async () => {} // TODO ...
export const patch = async (request: Request, response: Response) => {
const guildId = request.params.guildId;
const { id, name, value, matching_algorithm, is_insensitive, is_whitelist } = request.body;
let filter;
try {
filter = await prisma.filter.update({
where: { id: Number(id) },
data: {
name: name,
guild_id: guildId,
value: value,
matching_algorithm: matching_algorithm,
is_insensitive: is_insensitive === "on",
is_whitelist: is_whitelist === "on"
}
});
}
catch (error) {
console.error(error);
if (error instanceof Prisma.PrismaClientKnownRequestError) {
response.status(500).json({ error: error.message });
return;
}
}
response.status(201).json(filter);
};
export const del = async (request: Request, response: Response) => {
const { ids } = request.body;
let { ids } = request.body;
const guildId = request.params.guildId;
if (!ids || !Array.isArray(ids)) {
@ -60,6 +89,8 @@ export const del = async (request: Request, response: Response) => {
return;
}
ids = ids.map(id => Number(id));
try {
await prisma.filter.deleteMany({ where: {
id: { in: ids },
@ -82,8 +113,33 @@ export const datatable = async (request: Request, response: Response) => {
request,
response,
prisma.filter,
{ id: "asc" }
[{ updated_at: "desc" }, { id: "asc" }],
{},
{ guild_id: request.params.guildId }
);
};
export default { get, post, patch, del, datatable };
export const select = async (request: Request, response: Response) => {
const guildId = request.params.guildId;
const { search } = request.query;
const data = await prisma.filter.findMany({
where: {
guild_id: guildId,
name: { contains: `${search}` }
}
});
// Preline Bug: https://github.com/htmlstreamofficial/preline/issues/567
// The returned data must have a "title" key, otherwise the advanced
// select component with 'tags' mode will have no title, regardless of
// mapping.
const modifiedResults = data.map(filter => ({
...filter,
title: filter.name
}));
response.json(modifiedResults);
};
export default { get, post, patch, del, datatable, select };

View File

@ -0,0 +1,176 @@
import { Request, Response } from "express";
import prisma, { Prisma } from "@server/prisma";
import { datatableRequest } from "@server/controllers/guild/api/dt.module";
import { logger } from "@server/../log";
export const get = async (request: Request, response: Response) => {
if (!request.query.id) {
response.status(400).json({ error: "missing 'id' query" });
return;
}
const style = await prisma.messageStyle.findUnique({
where: { id: Number(request.query.id) },
});
if (!style) {
response.status(404).json({ message: "no result found" });
return;
}
response.json(style);
};
export const post = async (request: Request, response: Response) => {
const guildId = request.params.guildId;
const {
name,
show_author,
show_image,
show_thumbnail,
show_footer,
show_timestamp,
colour,
title_mutator,
description_mutator
} = request.body;
logger.debug("Style Post", request.body);
let style;
try {
style = await prisma.messageStyle.create({
data: {
name: name,
guild_id: guildId,
show_author: show_author === "on",
show_image: show_image === "on",
show_thumbnail: show_thumbnail === "on",
show_footer: show_footer === "on",
show_timestamp: show_timestamp === "on",
colour: colour,
title_mutator: title_mutator || null,
description_mutator: description_mutator || null
}
});
}
catch (error) {
console.error(error);
if (error instanceof Prisma.PrismaClientKnownRequestError) {
response.status(500).json({ error: error.message });
return;
}
}
response.status(201).json(style);
};
export const patch = async (request: Request, response: Response) => {
const guildId = request.params.guildId;
const {
id,
name,
show_author,
show_image,
show_thumbnail,
show_footer,
show_timestamp,
colour,
title_mutator,
description_mutator
} = request.body;
let style;
try {
style = await prisma.messageStyle.update({
where: { id: Number(id) },
data: {
name: name,
guild_id: guildId,
show_author: show_author === "on",
show_image: show_image === "on",
show_thumbnail: show_thumbnail === "on",
show_footer: show_footer === "on",
show_timestamp: show_timestamp === "on",
colour: colour,
title_mutator: title_mutator || null,
description_mutator: description_mutator || null
}
});
}
catch (error) {
console.error(error);
if (error instanceof Prisma.PrismaClientKnownRequestError) {
response.status(500).json({ error: error.message });
return;
}
}
response.status(201).json(style);
};
export const del = async (request: Request, response: Response) => {
let { ids } = request.body;
const guildId = request.params.guildId;
if (!ids || !Array.isArray(ids)) {
response.status(400).json({ error: "invalid request body" });
return;
}
ids = ids.map(id => Number(id));
try {
await prisma.messageStyle.deleteMany({ where: {
id: { in: ids },
guild_id: guildId
}});
}
catch (error) {
console.error(error);
if (error instanceof Prisma.PrismaClientKnownRequestError) {
response.status(500).json({ error: error.message });
return;
}
}
response.status(204).json(null);
};
export const datatable = async (request: Request, response: Response) => {
return await datatableRequest(
request,
response,
prisma.messageStyle,
[{ updated_at: "desc" }, { id: "asc" }],
{ },
{ guild_id: request.params.guildId } // TODO: verify authenticated user can access this guild
);
};
export const select = async (request: Request, response: Response) => {
const guildId = request.params.guildId;
const { search } = request.query;
const data = await prisma.messageStyle.findMany({
where: {
guild_id: guildId,
name: { contains: `${search}` }
}
});
// Preline Bug: https://github.com/htmlstreamofficial/preline/issues/567
// The returned data must have a "title" key, otherwise the advanced
// select component with 'tags' mode will have no title, regardless of
// mapping.
const modifiedResults = data.map(filter => ({
...filter,
title: filter.name
}));
response.json(modifiedResults);
};
export default { get, post, patch, del, datatable, select };

View File

@ -1,5 +1,6 @@
import { Request, Response } from "express";
import { client as bot } from "@bot/bot";
import prisma from "generated/prisma";
export const get = async (request: Request, response: Response) => {
const guildId = request.params.guildId;
@ -10,9 +11,18 @@ export const get = async (request: Request, response: Response) => {
return;
}
const matchingAlgorithms: Record<keyof typeof prisma.MatchingAlgorithms, string> = {
ANY: "Any Word",
ALL: "All Words",
EXACT: "Exact Match",
REGEX: "Regular Expression",
FUZZY: "Fuzzy Match"
};
response.render("guild/filters", {
title: `${guild.name} - Relay`,
guild: guild
guild: guild,
matchingAlgorithms: matchingAlgorithms
});
};

View File

@ -1,5 +1,6 @@
import { Request, Response } from "express";
import { client as bot } from "@bot/bot";
import prisma from "generated/prisma";
export const get = async (request: Request, response: Response) => {
const guildId = request.params.guildId;
@ -10,9 +11,33 @@ export const get = async (request: Request, response: Response) => {
return;
}
const textMutators: Record<keyof typeof prisma.TextMutator, string> = {
UWUIFY: "UWUify",
UWUIFY_SFW: "UWUify (Safe)",
GOTHIC_SCRIPT: "Gothic Script",
EMOJI_SUBSTITUTE: "Emoji Substitute",
ZALGO: "Zalgo",
MORSE_CODE: "Morse Code",
BINARY: "Binary",
HEXADECIMAL: "Hexadecimal",
REMOVE_VOWELS: "Remove Vowels",
DOUBLE_CHARACTERS: "Double Characters",
SMALL_CASE: "Small Case",
LEET_SPEAK: "L33t Sp34k",
PIG_LATIN: "Pig Latin",
UPSIDE_DOWN: "Upside Down",
ALL_REVERSED: "All Reversed",
REVERSED_WORDS: "Reversed Words",
SHUFFLE_WORDS: "Shuffle Words",
RANDOM_CASE: "Random Case",
GIBBERISH: "Gibberish",
SHAKESPEAREAN: "Shakespearean"
};
response.render("guild/styles", {
title: `${guild.name} - Relay`,
guild: guild
guild: guild,
textMutators: textMutators
});
};

View File

@ -1,7 +1,9 @@
import { Request, Response } from "express";
import { logger } from "@server/../log";
const get = async (_request: Request, response: Response) => {
response.render("home", { title: "home page" });
logger.info("Success");
};
export default { get }

View File

@ -7,6 +7,7 @@ import contentController from "@server/controllers/guild/content.controller";
import feedApiController from "@server/controllers/guild/api/feed.controller";
import filterApiController from "@server/controllers/guild/api/filter.controller";
import styleApiController from "@server/controllers/guild/api/style.controller";
const router = Router();
@ -32,9 +33,17 @@ router.patch("/:guildId/feeds/api", feedApiController.patch);
router.delete("/:guildId/feeds/api", feedApiController.del);
router.post("/:guildId/filters/api/datatable", filterApiController.datatable);
router.get("/:guildId/filters/api/select", filterApiController.select);
router.get("/:guildId/filters/api", filterApiController.get);
router.post("/:guildId/filters/api", filterApiController.post);
router.patch("/:guildId/filters/api", filterApiController.patch);
router.delete("/:guildId/filters/api", filterApiController.del);
router.post("/:guildId/styles/api/datatable", styleApiController.datatable);
router.get("/:guildId/styles/api/select", styleApiController.select);
router.get("/:guildId/styles/api", styleApiController.get);
router.post("/:guildId/styles/api", styleApiController.post);
router.patch("/:guildId/styles/api", styleApiController.patch);
router.delete("/:guildId/styles/api", styleApiController.del);
export default router;

10
src/types/client.d.ts vendored
View File

@ -1,10 +0,0 @@
import { Channel } from "discord.js";
declare global {
interface Window {
guildId: string;
channels: Channel[];
}
}
export {};