Compare commits
139 Commits
Author | SHA1 | Date | |
---|---|---|---|
22c35ae1da | |||
ec2f62ab3b | |||
dc3deffb32 | |||
99e1b0ef96 | |||
93d5d29aab | |||
9b07cc358b | |||
50d3f1d0a8 | |||
8dd702515d | |||
5303d81b19 | |||
d2a050e654 | |||
5ad695059e | |||
05359dedcb | |||
99ebb9a578 | |||
c4247bf2a6 | |||
755bf32774 | |||
d6a014ec91 | |||
8f6ce8867c | |||
86e4f9d0d6 | |||
31ad266389 | |||
3f58496fa0 | |||
540de53cd0 | |||
3feb464097 | |||
cda89f824e | |||
f294a751dc | |||
72fe545211 | |||
c99b6d6a46 | |||
389ea6cf34 | |||
81dcf325c3 | |||
f871a1d847 | |||
cf8713c1bb | |||
55c6a7125a | |||
13b736ed8b | |||
82195c7030 | |||
a33ba96115 | |||
61771af94f | |||
07aac70d4b | |||
e320a1d958 | |||
79b79382e2 | |||
0ce68139e1 | |||
851467ab90 | |||
524bb4fc02 | |||
ba286e769b | |||
0297fb12b6 | |||
fb011e80c2 | |||
fb76266250 | |||
cc845d3adc | |||
c0ddec1c71 | |||
e5f04a2c7d | |||
dc0a4a9be0 | |||
1d1f7005ed | |||
d1957faf95 | |||
186e1dbf93 | |||
fc24f05903 | |||
9b6eb86cd8 | |||
6b77d062f0 | |||
31c4779bf2 | |||
816da70229 | |||
9423a0f1ce | |||
d6382347d0 | |||
8f1ee46d6d | |||
6761a9163b | |||
bab3759423 | |||
79b76c3b58 | |||
faaaaf6ac7 | |||
676885a004 | |||
9f71c9c29e | |||
3ac33dc00a | |||
d5af04c317 | |||
0dd928b8f4 | |||
badd232d3d | |||
6356bb1d06 | |||
e9807ee6f6 | |||
99a59c61e7 | |||
200716988c | |||
a42e353aa4 | |||
cfde210a39 | |||
be03788cfc | |||
2d8a26f392 | |||
a1bd362799 | |||
6d1f4e6f7f | |||
b528153113 | |||
e935d801e6 | |||
ff992fefa7 | |||
989f93addf | |||
a0d2711a51 | |||
73aed35ce0 | |||
e58d7343b1 | |||
2259e3229a | |||
78fed2b2a3 | |||
fa42eb3551 | |||
e4ab506abe | |||
2589cabec6 | |||
00a4c749f0 | |||
9d79d8dbef | |||
79e331bdb9 | |||
dda5461a0d | |||
300041ec49 | |||
a768059e74 | |||
e6c641575e | |||
40a0211609 | |||
671b1856c6 | |||
3da72482ba | |||
22e252ce53 | |||
179d6e3b3b | |||
6b6af17731 | |||
7ef8b88aab | |||
2a88e1c184 | |||
84772852e3 | |||
23ca09b4a2 | |||
b4aac14fb3 | |||
77c8d39142 | |||
ce51623637 | |||
a856925ab4 | |||
d8141e485c | |||
e0cb99974f | |||
9896f8e094 | |||
acde6e1bbb | |||
054cb6c017 | |||
95c55f3ba7 | |||
8aedc84280 | |||
5e8fc3c8aa | |||
ad268096f3 | |||
1e5c8a821e | |||
39e67c1088 | |||
db5178fef0 | |||
5323009fd8 | |||
637415b8ca | |||
31b9063365 | |||
3acd08d922 | |||
6ff4bacddf | |||
0a5d32e6e8 | |||
16134e1719 | |||
60cb0083f5 | |||
3774c0b6db | |||
9b5913cd77 | |||
1edc1d4016 | |||
336484c13a | |||
12dff02c6f | |||
f8724162ad |
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@ -0,0 +1,12 @@
|
||||
dist/
|
||||
node_modules/
|
||||
generated/
|
||||
logs/
|
||||
.env
|
||||
|
||||
# Prisma local database
|
||||
*.db
|
||||
*.db-journal
|
||||
|
||||
# Exclude generated public files, which can be large with bundling
|
||||
src/client/public/generated/
|
57
.gitea/workflows/docker.yaml
Normal file
57
.gitea/workflows/docker.yaml
Normal file
@ -0,0 +1,57 @@
|
||||
name: Build & Push Docker Image
|
||||
run-name: ${{ gitea.actor }} is building and pushing a docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- staging
|
||||
- dev
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- staging
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Get Current Version Number
|
||||
id: version
|
||||
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV
|
||||
|
||||
- name: Create Docker Tag From Branch
|
||||
id: tag
|
||||
run: |
|
||||
# master branch uses version as tag, otherwise use branch name
|
||||
|
||||
if [[ "${{ gitea.ref_name }}" == "master" ]]; then
|
||||
TAG="${{ env.VERSION }}"
|
||||
else
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
fi
|
||||
|
||||
echo "TAG=$TAG" >> $GITHUB_ENV
|
||||
|
||||
- name: Build Docker Image
|
||||
run: docker build -t relay:${{ env.TAG }} .
|
||||
|
||||
- name: Login to Docker Registry
|
||||
run: echo ${{ secrets.DOCKER_TOKEN }} | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin
|
||||
|
||||
- name: Tag & Push Docker Image
|
||||
run: |
|
||||
docker tag relay:${{ env.TAG }} xordk/relay:${{ env.TAG }}
|
||||
docker push xordk/relay:${{ env.TAG }}
|
||||
|
||||
# If on master, push an additional "latest" tag
|
||||
|
||||
if [[ "${{ gitea.ref_name }}" == "master" ]]; then
|
||||
docker tag relay:${{ env.TAG }} xordk/relay:latest
|
||||
docker push xordk/relay:latest
|
||||
fi
|
39
.gitea/workflows/tests.yaml
Normal file
39
.gitea/workflows/tests.yaml
Normal file
@ -0,0 +1,39 @@
|
||||
name: Test & Build
|
||||
run-name: ${{ gitea.actor }} is testing & building
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- staging
|
||||
- dev
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- staging
|
||||
- dev
|
||||
|
||||
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: |
|
||||
echo "DATABASE_URL is ${DATABASE_URL::9}****"
|
||||
npx prisma migrate reset --force
|
||||
npx prisma db push
|
||||
env:
|
||||
DATABASE_URL: ${{ secrets.POSTGRESQL_CONN_STRING }}
|
||||
|
||||
- name: Build Dist
|
||||
run: npm run build
|
||||
|
||||
- name: Run Tests
|
||||
run: npm run test
|
15
.gitignore
vendored
15
.gitignore
vendored
@ -1,17 +1,12 @@
|
||||
dist/
|
||||
node_modules/
|
||||
generated/prisma
|
||||
package-lock.json
|
||||
generated/
|
||||
logs/
|
||||
.env
|
||||
|
||||
#prisma local database
|
||||
# Prisma local database
|
||||
*.db
|
||||
*.db-journal
|
||||
|
||||
# exclude this very large css file, it can be
|
||||
# built when needed with `npm run build:tailwind`
|
||||
src/client/public/css/tailwind.css
|
||||
|
||||
# Contains bundled js files built from typescript
|
||||
# they can be very large, build with `npm run build:client`
|
||||
src/client/public/bundles
|
||||
# Exclude generated public files, which can be large with bundling
|
||||
src/client/public/generated/
|
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"css.lint.unknownAtRules": "ignore" // For tailwindcss rules '@config' & '@apply'
|
||||
}
|
57
CHANGELOG.md
57
CHANGELOG.md
@ -2,6 +2,63 @@
|
||||
|
||||
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)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add '15' as a page size option for tables ([9b5913c](https://gitea.cor.bz/corbz/relay/commit/9b5913cd77e8ebe90bae3f3a6957a4927dc0fc15))
|
||||
* add filters guild page with table (missing full features) ([6ff4bac](https://gitea.cor.bz/corbz/relay/commit/6ff4bacddf9200aa7d25284efee1c193ed3e76ae))
|
||||
* allow a custom 'where' clause parameter for datatable queries. ([637415b](https://gitea.cor.bz/corbz/relay/commit/637415b8ca6843fa46dbb4e80d849c3a4324f0b6))
|
||||
* basic search functionality for feed table ([f872416](https://gitea.cor.bz/corbz/relay/commit/f8724162ad167e53bf38cb5d1957d8905c59ab17))
|
||||
* functional 'create' modal for feed records ([d8141e4](https://gitea.cor.bz/corbz/relay/commit/d8141e485cf60cede956b5a3846c1cd00962ff38))
|
||||
* tag design for status and channels in feed table ([1edc1d4](https://gitea.cor.bz/corbz/relay/commit/1edc1d4016b0640c6f7da320e498b4cc0875a0f8))
|
||||
* util for verifying feed channels ([5323009](https://gitea.cor.bz/corbz/relay/commit/5323009fd8562ab8f3997aeb829863fd5b3655ec))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** correctly handle arguments for channels and active ([9896f8e](https://gitea.cor.bz/corbz/relay/commit/9896f8e094c06a0980b835902df2d9cb384bbd3d))
|
||||
* **api:** feed push fails if channels is undefined (left blank in UI) ([ce51623](https://gitea.cor.bz/corbz/relay/commit/ce51623637b21829ca3727a564b278b2282902fd))
|
||||
* include filters on feed table ([db5178f](https://gitea.cor.bz/corbz/relay/commit/db5178fef086bba9a53ce6e8f1abcd66f211e322))
|
||||
* redraw feed datatable when new entries are created ([77c8d39](https://gitea.cor.bz/corbz/relay/commit/77c8d391424671af6834517aab07e47be87b5d63))
|
||||
* replace unusable <td> renders with className parameters for col configs. ([31b9063](https://gitea.cor.bz/corbz/relay/commit/31b90633652c493b7c87ef359736650574b0fb53))
|
||||
|
||||
### [0.1.3](https://gitea.cor.bz/corbz/relay/compare/v0.1.2...v0.1.3) (2025-04-26)
|
||||
|
||||
|
||||
|
15
Dockerfile
Normal file
15
Dockerfile
Normal file
@ -0,0 +1,15 @@
|
||||
FROM node:23.10-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
|
||||
RUN rm -rf src/
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "start"]
|
20
README.md
Normal file
20
README.md
Normal file
@ -0,0 +1,20 @@
|
||||
# Relay
|
||||
|
||||
## Installation
|
||||
|
||||
### Environment Variables
|
||||
|
||||
|Variable|Description|Required|Default Value|
|
||||
|-|-|:-:|-|
|
||||
|`HOST`|The application host.||`localhost`|
|
||||
|`PORT`|The application port.||`3000`|
|
||||
|`PUBLIC_URL`|The public endpoint of the web ui.|✔||
|
||||
|`BOT_TOKEN`|Authentication for the Discord Bot.|✔||
|
||||
|`DATABASE_URL`|Postgresql connection string.|✔||
|
||||
|`OAUTH_URL`|Discord authentication URL.|✔||
|
||||
|`CALLBACK_URL`|Discord authentication callback URL.|✔||
|
||||
|`CLIENT_ID`|Discord application client ID.|✔||
|
||||
|`CLIENT_SECRET`|Discord application client secret.|✔||
|
||||
|`DISCORD_USER_IDS`|CSV of Discord User IDs allowed to access the web ui.|✔||
|
||||
|`LOG_LEVEL`|Manually set the log level (not recommended).||`info`|
|
||||
|`LOG_DIR`|Override the default output directory for log files.|||
|
@ -1,5 +0,0 @@
|
||||
moving from knex to prisma
|
||||
|
||||
read more:
|
||||
https://github.com/prisma/prisma-examples/blob/latest/orm/express/src/index.ts
|
||||
https://www.prisma.io/docs/getting-started
|
15
esbuild.mjs
15
esbuild.mjs
@ -1,18 +1,23 @@
|
||||
// This file is for building client-side typescript found
|
||||
// in './src/client/typescript' to './src/client/public/bundles'
|
||||
// in './src/client/typescript' to './src/client/public/generated/js'
|
||||
|
||||
import { build } from "esbuild";
|
||||
import glob from "fast-glob";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
const entryPoints = await glob("./src/client/typescript/**/*");
|
||||
dotenv.config();
|
||||
|
||||
const isProdEnv = process.env.PROD === "true";
|
||||
const entryPoints = await glob("./src/client/src/ts/**/*");
|
||||
|
||||
build({
|
||||
entryPoints,
|
||||
outdir: "./src/client/public/bundles",
|
||||
outdir: "./src/client/public/generated/js",
|
||||
bundle: true,
|
||||
target: ["es6"],
|
||||
format: "iife",
|
||||
sourcemap: false,
|
||||
loader: {".ts": "ts"},
|
||||
minify: false
|
||||
sourcemap: false, // !isProdEnv,
|
||||
minify: isProdEnv,
|
||||
keepNames: !isProdEnv
|
||||
}).catch(() => process.exit(1));
|
34
eslint.config.mjs
Normal file
34
eslint.config.mjs
Normal file
@ -0,0 +1,34 @@
|
||||
//ts-check
|
||||
|
||||
import eslint from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
"./dist/**/*",
|
||||
"./src/**/generated/**/*",
|
||||
"./generated/**/*",
|
||||
"esbuild.mjs",
|
||||
"jest.config.js",
|
||||
"postcss.config.js",
|
||||
"tailwind.config.js",
|
||||
]
|
||||
},
|
||||
...tseslint.config(
|
||||
eslint.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
{
|
||||
files: ["./src/**/*.{ts,js}"],
|
||||
rules: {
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error", {
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-explicit-any": "off"
|
||||
}
|
||||
}
|
||||
)
|
||||
];
|
11
jest.config.js
Normal file
11
jest.config.js
Normal file
@ -0,0 +1,11 @@
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} **/
|
||||
module.exports = {
|
||||
testEnvironment: "node",
|
||||
transform: {
|
||||
"^.+\.tsx?$": ["ts-jest",{}],
|
||||
},
|
||||
testPathIgnorePatterns: [
|
||||
"node_modules/",
|
||||
"generated/"
|
||||
]
|
||||
};
|
12407
package-lock.json
generated
Normal file
12407
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
package.json
51
package.json
@ -1,60 +1,85 @@
|
||||
{
|
||||
"name": "relay",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.5",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"start": "node ./dist/app.js",
|
||||
"dryrun": "npx ts-node -r tsconfig-paths/register ./src/app.ts",
|
||||
"dev": "npm run build:tailwind && npm run build:client && npm run dryrun",
|
||||
"dev": "npm run build:client && npm run dryrun",
|
||||
"lint": "npx eslint .",
|
||||
"build": "sh ./scripts/build.sh",
|
||||
"build:client": "node esbuild.mjs",
|
||||
"build:client": "npm run build:css && node esbuild.mjs",
|
||||
"build:server": "npx tsc -p ./tsconfig.json && npx tsc-alias -p ./tsconfig.json",
|
||||
"build:tailwind": "npx @tailwindcss/cli -i ./src/client/public/css/main.css -o ./src/client/public/css/tailwind.css",
|
||||
"build:css": "npx postcss \"./src/client/src/css/**/*.css\" --dir ./src/client/public/generated/css",
|
||||
"db:migrate": "npx prisma migrate dev --name",
|
||||
"db:push": "npx prisma db push",
|
||||
"db:seed": "npx prisma db seed",
|
||||
"db:gen": "npx prisma generate",
|
||||
"db:format": "npx prisma format",
|
||||
"db:studio": "npx prisma studio",
|
||||
"db:reset": "npx prisma migrate reset",
|
||||
"release:patch": "npx standard-version --release-as patch",
|
||||
"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": {
|
||||
"@eslint/js": "^9.26.0",
|
||||
"@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/jest": "^29.5.14",
|
||||
"@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",
|
||||
"esbuild": "^0.25.2",
|
||||
"eslint": "^9.26.0",
|
||||
"fast-glob": "^3.3.3",
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.1.9",
|
||||
"postcss": "^8.5.3",
|
||||
"postcss-cli": "^11.0.1",
|
||||
"postcss-import": "^16.1.0",
|
||||
"prisma": "^6.6.0",
|
||||
"standard-version": "^9.5.0",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"ts-jest": "^29.3.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsc-alias": "^1.8.15",
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.32.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@preline/datatable": "^3.0.0",
|
||||
"@preline/dropdown": "^3.0.1",
|
||||
"@preline/select": "^3.0.0",
|
||||
"@prisma/client": "^6.6.0",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"chalk": "^5.4.1",
|
||||
"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",
|
||||
"fuzzball": "^2.2.2",
|
||||
"jquery": "^3.7.1",
|
||||
"lodash": "^4.17.21",
|
||||
"node-html-parser": "^7.0.1",
|
||||
"nouislider": "^15.8.1",
|
||||
"preline": "^3.0.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"rss-parser": "^3.13.0",
|
||||
"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"
|
||||
|
8
postcss.config.js
Normal file
8
postcss.config.js
Normal file
@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
module.exports = {
|
||||
plugins: [
|
||||
require("postcss-import"),
|
||||
require("@tailwindcss/postcss"),
|
||||
require("autoprefixer")
|
||||
]
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "TestModel" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"name" TEXT
|
||||
);
|
@ -1,10 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `TestModel` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropTable
|
||||
PRAGMA foreign_keys=off;
|
||||
DROP TABLE "TestModel";
|
||||
PRAGMA foreign_keys=on;
|
@ -1,18 +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,
|
||||
"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
|
||||
);
|
@ -1,17 +0,0 @@
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_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
|
||||
);
|
||||
INSERT INTO "new_Feed" ("active", "created_at", "guild_id", "id", "name", "updated_at", "url") SELECT "active", "created_at", "guild_id", "id", "name", "updated_at", "url" FROM "Feed";
|
||||
DROP TABLE "Feed";
|
||||
ALTER TABLE "new_Feed" RENAME TO "Feed";
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
87
prisma/migrations/20250505155254_init/migration.sql
Normal file
87
prisma/migrations/20250505155254_init/migration.sql
Normal 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;
|
19
prisma/migrations/20250505155447_unique_names/migration.sql
Normal file
19
prisma/migrations/20250505155447_unique_names/migration.sql
Normal 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");
|
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "MessageStyle" ALTER COLUMN "colour" SET DATA TYPE VARCHAR(7);
|
@ -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;
|
@ -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"
|
||||
|
@ -7,24 +7,99 @@ 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[]
|
||||
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
|
||||
}
|
||||
|
||||
model Filter {
|
||||
id Int @id @default(autoincrement())
|
||||
guild_id String
|
||||
name String
|
||||
value String
|
||||
matching_algorithm MatchingAlgorithms
|
||||
is_insensitive Boolean
|
||||
is_whitelist Boolean
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
feeds Feed[]
|
||||
|
||||
@@unique([guild_id, name])
|
||||
@@index([guild_id, created_at(sort: Desc)])
|
||||
}
|
||||
|
||||
enum MatchingAlgorithms {
|
||||
ANY
|
||||
ALL
|
||||
EXACT
|
||||
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
|
||||
}
|
||||
|
@ -1,25 +1,9 @@
|
||||
import Prisma, { PrismaClient } from "../generated/prisma";
|
||||
import { PrismaClient } from "../generated/prisma";
|
||||
|
||||
const client = new PrismaClient();
|
||||
|
||||
async function createManyFeeds() {
|
||||
const records: any = { data: [] };
|
||||
|
||||
for (let i = 0; i < 35; i++) {
|
||||
records.data.push(<Prisma.Prisma.FeedCreateInput>{
|
||||
name: `News Network ${i}`,
|
||||
url: `https://news-network-${i}.com/rss`,
|
||||
guild_id: "1204426362794811453",
|
||||
active: true
|
||||
});
|
||||
}
|
||||
|
||||
await client.feed.createMany(records);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await createManyFeeds();
|
||||
}
|
||||
async function main() { }
|
||||
|
||||
main()
|
||||
.then(async () => { await client.$disconnect() })
|
||||
|
@ -7,7 +7,6 @@ echo "Compiling backend..."
|
||||
npm run build:server
|
||||
|
||||
echo "Compiling frontend..."
|
||||
npm run build:tailwind
|
||||
npm run build:client
|
||||
|
||||
echo "Copying client files..."
|
||||
|
20
src/app.ts
20
src/app.ts
@ -6,10 +6,14 @@ import express from "express";
|
||||
import engine from "ejs-mate";
|
||||
|
||||
import "@bot/bot";
|
||||
import prisma from "@server/prisma";
|
||||
import homeRouter from "@server/routers/home.router";
|
||||
import guildRouter from "@server/routers/guild.router";
|
||||
import { attachGuilds } from "@server/middleware/attachGuilds";
|
||||
import { guildTabHelper } from "@server/middleware/guildTabHelper";
|
||||
import { getLogger } from "./log";
|
||||
|
||||
const logger = getLogger(__filename);
|
||||
|
||||
const app = express();
|
||||
|
||||
@ -17,8 +21,9 @@ app.engine("ejs", engine);
|
||||
app.set("view engine", "ejs");
|
||||
app.set("views", path.resolve(__dirname, "client/views"));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json());
|
||||
|
||||
app.use("/static", express.static(path.resolve(__dirname, "client/public")));
|
||||
app.use("/public", express.static(path.resolve(__dirname, "client/public")));
|
||||
|
||||
app.use("/guild", attachGuilds, guildTabHelper, guildRouter);
|
||||
app.use("/", attachGuilds, homeRouter);
|
||||
@ -26,6 +31,15 @@ app.use("/", attachGuilds, homeRouter);
|
||||
const HOST = process.env.HOST || "localhost";
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server is listening on port http://${HOST}:${PORT}`);
|
||||
const server = app.listen(PORT, () => {
|
||||
logger.info(`Server is listening on port http://${HOST}:${PORT}`);
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
logger.info("Shutdown signal received...");
|
||||
|
||||
prisma.$disconnect();
|
||||
server.close(error => {
|
||||
process.exit(error ? 1 : 0);
|
||||
});
|
||||
});
|
215
src/bot/__tests__/filters.test.ts
Normal file
215
src/bot/__tests__/filters.test.ts
Normal file
@ -0,0 +1,215 @@
|
||||
import * as filters from "../filter";
|
||||
import prisma, { MatchingAlgorithms } from "../../../generated/prisma";
|
||||
|
||||
interface FilterTestCase {
|
||||
input: string,
|
||||
expected: boolean;
|
||||
}
|
||||
|
||||
const templateFilter: prisma.Filter = {
|
||||
id: 0,
|
||||
guild_id: "",
|
||||
name: "",
|
||||
value: "",
|
||||
matching_algorithm: MatchingAlgorithms.ALL,
|
||||
is_insensitive: false,
|
||||
is_whitelist: false,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
|
||||
};
|
||||
|
||||
const runFilterTest = (filter: prisma.Filter, testCases: FilterTestCase[]) => {
|
||||
for (const { input, expected } of testCases) {
|
||||
test(`Input: ${input}`, () => {
|
||||
const result = filters.mapAlgorithmToFunction(filter, input);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
describe("Match: ALL (Case Sensitive)", () => {
|
||||
const filter: prisma.Filter = {
|
||||
...templateFilter,
|
||||
name: "Block All Words - CASE SENSITIVE",
|
||||
value: String.raw`one TWO threE`,
|
||||
matching_algorithm: MatchingAlgorithms.ALL,
|
||||
is_insensitive: false
|
||||
};
|
||||
|
||||
const testCases: FilterTestCase[] = [
|
||||
{
|
||||
input: "one two three",
|
||||
expected: false
|
||||
},
|
||||
{
|
||||
input: "one threE four five",
|
||||
expected: false
|
||||
},
|
||||
{
|
||||
input: "two four one six three",
|
||||
expected: false
|
||||
},
|
||||
{
|
||||
input: "one TWO threE four five",
|
||||
expected: true
|
||||
},
|
||||
{
|
||||
input: "nine ten seven eight one",
|
||||
expected: false
|
||||
},
|
||||
{
|
||||
input: "five four threE TWO one",
|
||||
expected: true
|
||||
},
|
||||
{
|
||||
input: "six nine one three eight",
|
||||
expected: false
|
||||
}
|
||||
];
|
||||
|
||||
runFilterTest(filter, testCases);
|
||||
});
|
||||
|
||||
describe("Match: ANY", () => {
|
||||
const filter: prisma.Filter = {
|
||||
...templateFilter,
|
||||
name: "Block American Politics",
|
||||
value: String.raw`trump biden democrat republican gop dnc kamala harris`,
|
||||
matching_algorithm: MatchingAlgorithms.ANY,
|
||||
is_insensitive: true
|
||||
};
|
||||
|
||||
const testCases: FilterTestCase[] = [
|
||||
{
|
||||
input: "Republicans float new tax breaks for tips, local taxes in Trump budget package",
|
||||
expected: true // Contains 'republican' and 'trump'.
|
||||
},
|
||||
{
|
||||
input: "Biden’s Approval Rating Dips Amid Economic Concerns",
|
||||
expected: true // Contains 'biden'.
|
||||
},
|
||||
{
|
||||
input: "GOP Governors Call for Tougher Border Security Measures",
|
||||
expected: true // Contains 'gop'.
|
||||
},
|
||||
{
|
||||
input: "Biden Announces New Initiative to Tackle Climate Change",
|
||||
expected: true // Contains 'biden'.
|
||||
},
|
||||
{
|
||||
input: "Joe Biden gives thoughts on Donald Trump and Kamala Harris in first interview since leaving office",
|
||||
expected: true // Contains 'biden', 'trump', 'kamala' and 'harris'.
|
||||
},
|
||||
{
|
||||
input: "UK Prime Minister Keir Starmer hails limited US-UK trade deal, but 10% duties remain",
|
||||
expected: false
|
||||
},
|
||||
{
|
||||
input: "Scientists discover new species of fish in Pacific Ocean",
|
||||
expected: false
|
||||
},
|
||||
{
|
||||
input: "Federal Reserve signals cautious approach to interest rate cuts in 2025",
|
||||
expected: false
|
||||
}
|
||||
];
|
||||
|
||||
runFilterTest(filter, testCases);
|
||||
});
|
||||
|
||||
describe("Match: LITERAL", () => { });
|
||||
|
||||
describe("Match: REGEX", () => {
|
||||
const filter: prisma.Filter = {
|
||||
...templateFilter,
|
||||
name: "Block American Politics",
|
||||
value: String.raw`\b(trump|biden|democrat|republican|gop|dnc|kamala|harris)\b`,
|
||||
matching_algorithm: MatchingAlgorithms.REGEX,
|
||||
is_insensitive: true
|
||||
};
|
||||
|
||||
const testCases: FilterTestCase[] = [
|
||||
{
|
||||
input: "Republicans float new tax breaks for tips, local taxes in Trump budget package",
|
||||
expected: true // Contains 'republican' and 'trump'.
|
||||
},
|
||||
{
|
||||
input: "Biden’s Approval Rating Dips Amid Economic Concerns",
|
||||
expected: true // Contains 'biden'.
|
||||
},
|
||||
{
|
||||
input: "GOP Governors Call for Tougher Border Security Measures",
|
||||
expected: true // Contains 'gop'.
|
||||
},
|
||||
{
|
||||
input: "Biden Announces New Initiative to Tackle Climate Change",
|
||||
expected: true // Contains 'biden'.
|
||||
},
|
||||
{
|
||||
input: "Joe Biden gives thoughts on Donald Trump and Kamala Harris in first interview since leaving office",
|
||||
expected: true // Contains 'biden', 'trump', 'kamala' and 'harris'.
|
||||
},
|
||||
{
|
||||
input: "UK Prime Minister Keir Starmer hails limited US-UK trade deal, but 10% duties remain",
|
||||
expected: false
|
||||
},
|
||||
{
|
||||
input: "Scientists discover new species of fish in Pacific Ocean",
|
||||
expected: false
|
||||
},
|
||||
{
|
||||
input: "Federal Reserve signals cautious approach to interest rate cuts in 2025",
|
||||
expected: false
|
||||
}
|
||||
];
|
||||
|
||||
runFilterTest(filter, testCases);
|
||||
});
|
||||
|
||||
describe("Match: FUZZY", () => {
|
||||
const filter: prisma.Filter = {
|
||||
...templateFilter,
|
||||
name: "Hello World Filter",
|
||||
value: String.raw`hello world`,
|
||||
matching_algorithm: MatchingAlgorithms.FUZZY,
|
||||
is_insensitive: true
|
||||
};
|
||||
|
||||
const testCases: FilterTestCase[] = [
|
||||
{
|
||||
input: "",
|
||||
expected: false
|
||||
},
|
||||
{
|
||||
input: "hello world",
|
||||
expected: true // exact match
|
||||
},
|
||||
{
|
||||
input: "Hello world",
|
||||
expected: true
|
||||
},
|
||||
{
|
||||
input: "hello World",
|
||||
expected: true
|
||||
},
|
||||
{
|
||||
input: "hello",
|
||||
expected: true
|
||||
},
|
||||
{
|
||||
input: "world",
|
||||
expected: true
|
||||
},
|
||||
{
|
||||
input: "hello there world",
|
||||
expected: false
|
||||
},
|
||||
{
|
||||
input: "hello worl",
|
||||
expected: true
|
||||
},
|
||||
];
|
||||
|
||||
runFilterTest(filter, testCases);
|
||||
});
|
@ -1,20 +1,21 @@
|
||||
import { Client, GatewayIntentBits, ActivityType } from "discord.js";
|
||||
import { Client, GatewayIntentBits } from "discord.js";
|
||||
import EventHandler from "@bot/handlers/events";
|
||||
import InteractionHandler from "@bot/handlers/interactions";
|
||||
|
||||
export const client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMembers,
|
||||
GatewayIntentBits.GuildWebhooks
|
||||
]
|
||||
})
|
||||
export default class DiscordBot extends Client {
|
||||
constructor() {
|
||||
super({ intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMembers,
|
||||
GatewayIntentBits.MessageContent,
|
||||
GatewayIntentBits.GuildWebhooks, // May not need?
|
||||
] });
|
||||
|
||||
client.on("ready", () => {
|
||||
if (!client.user) {
|
||||
throw Error("Client is null");
|
||||
this.login(process.env.BOT_TOKEN);
|
||||
}
|
||||
|
||||
client.user.setActivity("new sources", {type: ActivityType.Watching});
|
||||
console.log(`Discord Bot ${client.user.displayName} is online!`)
|
||||
});
|
||||
public events = new EventHandler(this);
|
||||
public interactions = new InteractionHandler(this);
|
||||
}
|
||||
|
||||
client.login(process.env.BOT_TOKEN);
|
||||
export const client = new DiscordBot();
|
||||
|
15
src/bot/components/event.ts
Normal file
15
src/bot/components/event.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import DiscordBot from "@bot/bot";
|
||||
|
||||
export default class Event {
|
||||
readonly client: DiscordBot;
|
||||
name!: string;
|
||||
once!: boolean;
|
||||
|
||||
constructor(client: DiscordBot) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
execute(..._args: unknown[]) {
|
||||
throw new Error("No execute override");
|
||||
}
|
||||
}
|
32
src/bot/components/interaction.ts
Normal file
32
src/bot/components/interaction.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { RESTPostAPIApplicationCommandsJSONBody, SlashCommandBuilder, ToAPIApplicationCommandOptions } from "discord.js";
|
||||
import DiscordBot from "@bot/bot";
|
||||
|
||||
export default class Interaction {
|
||||
readonly client: DiscordBot;
|
||||
name!: string;
|
||||
description: string = "No description";
|
||||
options: ToAPIApplicationCommandOptions[] = [];
|
||||
dmPermission!: boolean;
|
||||
|
||||
constructor(client: DiscordBot) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
execute(..._args: unknown[]) {
|
||||
throw new Error("No execute override");
|
||||
}
|
||||
|
||||
toJSON(): RESTPostAPIApplicationCommandsJSONBody {
|
||||
const command = new SlashCommandBuilder();
|
||||
|
||||
command.setName(this.name);
|
||||
command.setDescription(this.description);
|
||||
command.setDMPermission(this.dmPermission); // TODO: deprecated - replace
|
||||
|
||||
for (const option of this.options) {
|
||||
command.options.push(option);
|
||||
}
|
||||
|
||||
return command.toJSON();
|
||||
}
|
||||
}
|
14
src/bot/events/interactionCreate.ts
Normal file
14
src/bot/events/interactionCreate.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { Interaction } from "discord.js";
|
||||
import Event from "@bot/components/event";
|
||||
|
||||
export default class interactionCreate extends Event {
|
||||
name = "interactionCreate";
|
||||
|
||||
async execute(interaction: Interaction) {
|
||||
if (!interaction.isChatInputCommand()) return;
|
||||
|
||||
await interaction.deferReply();
|
||||
const command = this.client.interactions.get(interaction.commandName);
|
||||
return command!.execute(interaction);
|
||||
}
|
||||
}
|
22
src/bot/events/ready.ts
Normal file
22
src/bot/events/ready.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { ActivityType } from "discord.js";
|
||||
import Event from "@bot/components/event";
|
||||
import DiscordBot from "@bot/bot";
|
||||
import { getLogger } from "@server/../log";
|
||||
|
||||
const logger = getLogger(__filename);
|
||||
|
||||
export default class Ready extends Event {
|
||||
name = "ready";
|
||||
once = true;
|
||||
|
||||
async execute(client: DiscordBot): Promise<void> {
|
||||
if (!client.user) {
|
||||
throw new Error("Discord client is not truthy");
|
||||
}
|
||||
|
||||
await client.interactions.deploy();
|
||||
|
||||
client.user.setActivity("new sources", {type: ActivityType.Watching});
|
||||
logger.info(`Discord Bot ${client.user.displayName} is online!`)
|
||||
}
|
||||
}
|
86
src/bot/filter.ts
Normal file
86
src/bot/filter.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import fuzz from "fuzzball";
|
||||
import { Filter, MatchingAlgorithms } from "../../generated/prisma";
|
||||
|
||||
function splitWords(filterValue: string): string[] {
|
||||
const findTerms = [...filterValue.matchAll(/"([^"]+)"|(\S+)/g)];
|
||||
return findTerms.map(value => {
|
||||
const term = value[1] || value[2];
|
||||
return term.trim()
|
||||
.replace(/\s+/g, "\\s+") // Replace whitespace with equivelant regex characters
|
||||
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Escape common regex characters
|
||||
});
|
||||
}
|
||||
|
||||
// Includes all input words (space separated)
|
||||
export const all = (filter: Filter, input: string) => {
|
||||
try {
|
||||
const flags = filter.is_insensitive ? "i" : "";
|
||||
return splitWords(filter.value)
|
||||
.every(word => new RegExp(String.raw`\b(${word})\b`, flags).test(input));
|
||||
} catch (error) {
|
||||
console.error(`ALL: Invalid regex pattern: ${filter.value}`, error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Includes any input word (space separated)
|
||||
export const any = (filter: Filter, input: string) => {
|
||||
try {
|
||||
const flags = filter.is_insensitive ? "i" : "";
|
||||
const filterWords = splitWords(filter.value).toString().replace(/,/g, "|");
|
||||
const filterValue = String.raw`\b(${filterWords})\b`
|
||||
return new RegExp(filterValue, flags).test(input);
|
||||
} catch (error) {
|
||||
console.error(`ANY: Invalid regex pattern: ${filter.value}`, error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Includes exact input string
|
||||
export const literal = (filter: Filter, input: string) => {
|
||||
try {
|
||||
const filterValue = filter.is_insensitive ? filter.value.toLowerCase() : input;
|
||||
const inputValue = filter.is_insensitive ? input.toLowerCase() : input;
|
||||
return inputValue.includes(filterValue);
|
||||
} catch (error) {
|
||||
console.error(`LITERAL: Invalid regex pattern: ${filter.value}`, error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Matches given regex pattern
|
||||
export const regex = (filter: Filter, input: string) => {
|
||||
try {
|
||||
const flags = filter.is_insensitive ? "i" : "";
|
||||
return new RegExp(filter.value, flags).test(input);
|
||||
} catch (error) {
|
||||
console.error(`REGEX: Invalid regex pattern: ${filter.value}`, error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const fuzzy = (filter: Filter, input: string) => {
|
||||
let filterValue = filter.value.replace(/[^\w\s]/g, "");
|
||||
let inputValue = input.replace(/[^\w\s]/g, "");
|
||||
|
||||
// NOTE: it seems that case sensitivity is ignored for fuzzy matches... ?
|
||||
if (filter.is_insensitive) {
|
||||
filterValue = filterValue.toLowerCase();
|
||||
inputValue = inputValue.toLowerCase();
|
||||
}
|
||||
|
||||
const ratio = fuzz.partial_ratio(filterValue, inputValue);
|
||||
return ratio > 90;
|
||||
};
|
||||
|
||||
export const mapAlgorithmToFunction = (filter: Filter, input: string) => {
|
||||
switch (filter.matching_algorithm) {
|
||||
case MatchingAlgorithms.ALL: return all(filter, input);
|
||||
case MatchingAlgorithms.ANY: return any(filter, input);
|
||||
case MatchingAlgorithms.EXACT: return literal(filter, input);
|
||||
case MatchingAlgorithms.REGEX: return regex(filter, input);
|
||||
case MatchingAlgorithms.FUZZY: return fuzzy(filter, input);
|
||||
default:
|
||||
throw new Error(`Unknown algorithm: ${filter.matching_algorithm}`);
|
||||
}
|
||||
};
|
32
src/bot/handlers/events.ts
Normal file
32
src/bot/handlers/events.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { join } from "path";
|
||||
import { Collection } from "discord.js";
|
||||
import getAllFiles from "@bot/utils/getAllFiles";
|
||||
import Event from "@bot/components/event";
|
||||
import DiscordBot from "@bot/bot";
|
||||
|
||||
export default class EventHandler extends Collection<string, Event> {
|
||||
readonly client: DiscordBot;
|
||||
|
||||
constructor(client: DiscordBot) {
|
||||
super();
|
||||
this.client = client
|
||||
this.init();
|
||||
}
|
||||
|
||||
private async init() {
|
||||
const eventsDirectory = join(__dirname, "../events");
|
||||
const modules = getAllFiles(eventsDirectory);
|
||||
|
||||
for (const module of modules) {
|
||||
const imported = await import(module);
|
||||
const eventClass = imported.default || imported;
|
||||
const event: Event = new eventClass(this.client);
|
||||
this.set(event.name, event);
|
||||
|
||||
this.client[event.once ? "once" : "on"](
|
||||
event.name,
|
||||
(...args: unknown[]) => event.execute(...args)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
39
src/bot/handlers/interactions.ts
Normal file
39
src/bot/handlers/interactions.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { join } from "path";
|
||||
import { Collection, REST, Routes } from "discord.js";
|
||||
import getAllFiles from "@bot/utils/getAllFiles";
|
||||
import Interaction from "@bot/components/interaction";
|
||||
import DiscordBot from "@bot/bot";
|
||||
|
||||
export default class InteractionHandler extends Collection<string, Interaction> {
|
||||
readonly client: DiscordBot;
|
||||
|
||||
constructor(client: DiscordBot) {
|
||||
super();
|
||||
this.client = client;
|
||||
this.init();
|
||||
}
|
||||
|
||||
private async init() {
|
||||
const interactionsDirectory = join(__dirname, "../interactions");
|
||||
const modules = getAllFiles(interactionsDirectory);
|
||||
|
||||
for (const module of modules) {
|
||||
const imported = await import(module);
|
||||
const interactionClass = imported.default || imported;
|
||||
const interaction: Interaction = new interactionClass(this.client);
|
||||
this.set(interaction.name, interaction);
|
||||
}
|
||||
}
|
||||
|
||||
async deploy() {
|
||||
const interactions = this.map(inter => inter.toJSON())
|
||||
const rest = new REST({ version: "10" }).setToken(process.env.BOT_TOKEN!);
|
||||
|
||||
for (const guild of this.client.guilds.cache.values()) {
|
||||
rest.put(
|
||||
Routes.applicationGuildCommands(process.env.CLIENT_ID!, guild.id),
|
||||
{ body: interactions }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
20
src/bot/interactions/ping.ts
Normal file
20
src/bot/interactions/ping.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { CommandInteraction, EmbedBuilder } from "discord.js";
|
||||
import Interaction from "@bot/components/interaction";
|
||||
|
||||
export default class Ping extends Interaction {
|
||||
name = "ping";
|
||||
description = "Measure the bot's ability to respond.";
|
||||
|
||||
async execute(interaction: CommandInteraction) {
|
||||
const execTime = Math.abs(Date.now() - interaction.createdTimestamp);
|
||||
const apiLatency = Math.floor(this.client.ws.ping);
|
||||
|
||||
const embed = new EmbedBuilder();
|
||||
embed.addFields([
|
||||
{ name: "Command Time", value: `${execTime}ms` },
|
||||
{ name: "Discord API Latency", value: `${apiLatency}ms` }
|
||||
])
|
||||
|
||||
return interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
}
|
17
src/bot/interactions/trigger.ts
Normal file
17
src/bot/interactions/trigger.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { CommandInteraction } from "discord.js";
|
||||
import Interaction from "@bot/components/interaction";
|
||||
import { triggerTask } from "@bot/task";
|
||||
|
||||
|
||||
export default class Trigger extends Interaction {
|
||||
name = "trigger";
|
||||
description = "Perform a single process of the feeds."
|
||||
|
||||
async execute(interaction: CommandInteraction) {
|
||||
await triggerTask(this.client);
|
||||
const execTime = Math.abs(Date.now() - interaction.createdTimestamp);
|
||||
return interaction.editReply({
|
||||
content: `Completed in \`${execTime}ms\``
|
||||
});
|
||||
}
|
||||
}
|
151
src/bot/task.ts
Normal file
151
src/bot/task.ts
Normal file
@ -0,0 +1,151 @@
|
||||
import { Client, EmbedBuilder, Guild, HexColorString, Channel as DiscordChannel, TextChannel } from "discord.js";
|
||||
import RssParser from "rss-parser";
|
||||
import { parse as HtmlParser } from "node-html-parser";
|
||||
import { Feed, Filter, MessageStyle, Channel, MatchingAlgorithms } from "../../generated/prisma";
|
||||
import * as filters from "@bot/filter";
|
||||
import prisma from "@server/prisma";
|
||||
import { getLogger } from "@server/../log";
|
||||
|
||||
const logger = getLogger(__filename);
|
||||
|
||||
export const triggerTask = async (client: Client) => {
|
||||
for (const [_, guild] of client.guilds.cache) {
|
||||
await processGuild(guild, client);
|
||||
}
|
||||
};
|
||||
|
||||
interface ExpandedFeed extends Feed {
|
||||
channels: Channel[],
|
||||
filters: Filter[],
|
||||
message_style: MessageStyle
|
||||
}
|
||||
|
||||
const processGuild = async (guild: Guild, client: Client) => {
|
||||
const feeds = await prisma.feed.findMany({
|
||||
where: { guild_id: guild.id, active: true },
|
||||
include: { channels: true, filters: true, message_style: true }
|
||||
}) as ExpandedFeed[];
|
||||
|
||||
for (const feed of feeds) {
|
||||
await processFeed(feed, client);
|
||||
}
|
||||
};
|
||||
|
||||
const getParsedUrl = async (url: string) => {
|
||||
try {
|
||||
return new RssParser().parseURL(url)
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error);
|
||||
return undefined
|
||||
}
|
||||
};
|
||||
|
||||
const processFeed = async (feed: ExpandedFeed, client: Client) => {
|
||||
const parsed = await getParsedUrl(feed.url);
|
||||
if (!parsed) return;
|
||||
|
||||
logger.debug(`Processing feed: ${feed.name}`);
|
||||
|
||||
for (const channelId of feed.channels.map(channel => channel.channel_id)) {
|
||||
const channel = client.channels.cache.get(channelId);
|
||||
if (channel) await processItems(parsed.items, feed, channel);
|
||||
}
|
||||
};
|
||||
|
||||
const processItems = async (items: RssParser.Item[], feed: ExpandedFeed, channel: DiscordChannel) => {
|
||||
logger.debug(`Processing ${items.length} items`);
|
||||
|
||||
for (let i = items.length; i--;) {
|
||||
if (new Date(items[i].pubDate!) < feed.published_threshold) {
|
||||
logger.debug(`skipping outdated item: ${items[i].title}`)
|
||||
items.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!(await Promise.all(feed.filters.map(f => passesFilter(f, items[i])))).every(Boolean)) {
|
||||
items.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`Processing ${items.length} items (post-filter)`)
|
||||
|
||||
const batchSize = 4;
|
||||
const totalBatches = Math.floor((items.length + batchSize - 1) / batchSize);
|
||||
|
||||
logger.debug(`batchSize: ${batchSize}, totalBatches: ${totalBatches}`)
|
||||
|
||||
for (let batchNumber = 0; batchNumber * batchSize < items.length; batchNumber++) {
|
||||
logger.debug(`Processing items batch [${batchNumber+1}/${totalBatches}]`);
|
||||
|
||||
const i = batchNumber * batchSize;
|
||||
const batch = items.slice(i, i + batchSize);
|
||||
|
||||
const embeds = await createEmbedFromItems(batch, feed, batchNumber, totalBatches);
|
||||
|
||||
await (channel as TextChannel).send({ embeds: embeds });
|
||||
}
|
||||
};
|
||||
|
||||
const createEmbedFromItems = async (items: RssParser.Item[], feed: ExpandedFeed, batchNumber: number, totalBatches: number) => {
|
||||
if (!items.length) {
|
||||
throw new Error("Items empty, expected at least 1 item.");
|
||||
}
|
||||
|
||||
const mainEmbed = new EmbedBuilder();
|
||||
const embeds = [mainEmbed]
|
||||
|
||||
mainEmbed.setTitle(totalBatches > 1 ? `${feed.name} [${batchNumber+1}/${totalBatches}]` : feed.name);
|
||||
mainEmbed.setColor(feed.message_style.colour as HexColorString);
|
||||
mainEmbed.setURL(process.env.PUBLIC_URL ?? null);
|
||||
|
||||
if (items.length == 1) {
|
||||
mainEmbed.setImage(await getItemImageUrl(items[0].link ?? "") ?? null);
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const contentSnippet = item.contentSnippet + `\n[View Article](${item.link})`;
|
||||
mainEmbed.addFields({
|
||||
name: item.title ?? "- no title found -",
|
||||
value: contentSnippet ?? "- no desc found -",
|
||||
inline: false
|
||||
})
|
||||
|
||||
if (embeds.length <= 5) {
|
||||
const imageEmbed = new EmbedBuilder({ title: "dummy", url: process.env.PUBLIC_URL });
|
||||
imageEmbed.setImage(await getItemImageUrl(item.link ?? "") ?? null);
|
||||
embeds.push(imageEmbed);
|
||||
}
|
||||
}
|
||||
|
||||
return embeds
|
||||
};
|
||||
|
||||
const getItemImageUrl = async (url: string) => {
|
||||
const response = await fetch(url);
|
||||
const html = HtmlParser.parse(await response.text());
|
||||
|
||||
const imageElement = html.querySelector("meta[property='og:image']");
|
||||
if (!imageElement) return "";
|
||||
|
||||
return imageElement.getAttribute("content");
|
||||
};
|
||||
|
||||
const passesFilter = async (filter: Filter, item: RssParser.Item) => {
|
||||
if (!filter.value.trim()) return !filter.is_whitelist;
|
||||
|
||||
let matchFound = false;
|
||||
|
||||
if (filter.matching_algorithm === MatchingAlgorithms.ALL) {
|
||||
matchFound = filters.all(filter, `${item.title} ${item.content}`);
|
||||
} else {
|
||||
matchFound = (
|
||||
filters.mapAlgorithmToFunction(filter, item.title ?? "")
|
||||
|| filters.mapAlgorithmToFunction(filter, item.content ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug(`Filter result: matchFound=${matchFound}, is_whitelist=${filter.is_whitelist}, willSend=${filter.is_whitelist ? matchFound : !matchFound}`);
|
||||
|
||||
return filter.is_whitelist ? matchFound : !matchFound;
|
||||
};
|
29
src/bot/utils/getAllFiles.ts
Normal file
29
src/bot/utils/getAllFiles.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import fs from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
/**
|
||||
* Recursively gets all .ts or .js files in a directory and its subdirectories.
|
||||
* @param directoryPath - The directory to start from.
|
||||
* @param existingResult - Optional array to accumulate results.
|
||||
* @returns A list of full paths to .ts or .js files.
|
||||
*/
|
||||
const getAllFiles = (directoryPath: string, existingResult?: string[]) => {
|
||||
const fileNames = fs.readdirSync(directoryPath);
|
||||
const result: string[] = existingResult ?? [];
|
||||
|
||||
for (const fileName of fileNames) {
|
||||
const fullPath = join(directoryPath, fileName);
|
||||
const stat = fs.statSync(fullPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
getAllFiles(fullPath, result);
|
||||
} else if (fileName.endsWith(".ts") || fileName.endsWith(".js")) {
|
||||
result.push(fullPath);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export default getAllFiles;
|
@ -1,125 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
@import "preline/variants.css";
|
||||
|
||||
@config "../../../../tailwind.config.js";
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentColor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentColor);
|
||||
}
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 200, 700;
|
||||
font-display: swap;
|
||||
src: url("/static/fonts/inter-variablefont.ttf");
|
||||
}
|
||||
|
||||
/* Datatables */
|
||||
|
||||
.dt-layout-row:has(.dt-search),
|
||||
.dt-layout-row:has(.dt-length),
|
||||
.dt-layout-row:has(.dt-paging) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.cj-table {
|
||||
@apply min-w-full divide-y divide-gray-200 dark:divide-neutral-700;
|
||||
}
|
||||
|
||||
.cj-thead {
|
||||
@apply border-none bg-gray-50 dark:bg-neutral-800;
|
||||
}
|
||||
|
||||
.cj-table-footer {
|
||||
@apply px-6 py-4 gap-3 flex justify-between items-center border-t
|
||||
border-gray-200 dark:border-neutral-700;
|
||||
}
|
||||
|
||||
.cj-table-paging-btn {
|
||||
@apply 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;
|
||||
}
|
||||
|
||||
.cj-table-checkbox {
|
||||
@apply form-checkbox shrink-0 disabled:opacity-50 rounded-sm
|
||||
text-blue-600 focus:ring-blue-500 border-gray-300
|
||||
dark:bg-neutral-800 dark:border-neutral-600 dark:checked:bg-blue-500
|
||||
dark:checked:border-blue-500 dark:focus:ring-offset-gray-800;
|
||||
}
|
||||
|
||||
.cj-table-link {
|
||||
@apply block px-6 py-4 text-blue-500 hover:text-blue-600 focus:text-blue-600
|
||||
dark:text-blue-400 dark:hover:text-blue-500 dark:focus:text-blue-500
|
||||
text-nowrap cursor-pointer
|
||||
}
|
||||
|
||||
.cj-table-text {
|
||||
@apply text-sm text-gray-500 dark:text-neutral-500 text-nowrap;
|
||||
}
|
||||
|
||||
/* Select Box */
|
||||
|
||||
.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 bg-white border border-gray-200 rounded-lg text-start text-sm
|
||||
text-gray-800 shadow-xs hover:bg-gray-50 focus:outline-hidden focus:bg-gray-50
|
||||
before:absolute before:inset-0 before:z-1 dark:bg-neutral-900 dark:border-neutral-700
|
||||
dark:text-neutral-200 dark:hover:bg-neutral-800 dark:focus:bg-neutral-800;
|
||||
}
|
||||
|
||||
.cj-select-dropdown {
|
||||
@apply mt-2 z-50 w-20 max-h-72 p-1 space-y-0.5 bg-white border border-gray-200
|
||||
rounded-lg shadow-md overflow-hidden overflow-y-auto [&::-webkit-scrollbar]:w-2
|
||||
[&::-webkit-scrollbar-thumb]: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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Layout Sidebar */
|
||||
|
||||
.sidebar-btn {
|
||||
@apply
|
||||
w-full
|
||||
flex
|
||||
items-center
|
||||
gap-x-3.5
|
||||
py-2
|
||||
px-2.5
|
||||
text-sm
|
||||
rounded-lg
|
||||
focus:outline-hidden
|
||||
text-gray-800
|
||||
hover:bg-gray-100
|
||||
focus:bg-gray-100
|
||||
dark:bg-neutral-800
|
||||
dark:hover:bg-neutral-700
|
||||
dark:focus:bg-neutral-700
|
||||
dark:text-neutral-200;
|
||||
}
|
378
src/client/src/css/main.css
Normal file
378
src/client/src/css/main.css
Normal file
@ -0,0 +1,378 @@
|
||||
@import "tailwindcss";
|
||||
@import "./preline";
|
||||
@import "../../../../node_modules/preline/src/plugins/datepicker/styles.css";
|
||||
|
||||
@config "../../../../tailwind.config.js";
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentColor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentColor);
|
||||
}
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 200, 700;
|
||||
font-display: swap;
|
||||
src: url("/public/fonts/inter-variablefont.ttf");
|
||||
}
|
||||
|
||||
/* Datatables */
|
||||
|
||||
.dt-layout-row:has(.dt-search),
|
||||
.dt-layout-row:has(.dt-length),
|
||||
.dt-layout-row:has(.dt-paging) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.cj-table {
|
||||
@apply min-w-full divide-y divide-gray-200 dark:divide-neutral-700;
|
||||
}
|
||||
|
||||
.cj-thead {
|
||||
@apply border-none bg-gray-50 dark:bg-neutral-800;
|
||||
}
|
||||
|
||||
.cj-table-header {
|
||||
@apply px-6 py-3 text-start;
|
||||
}
|
||||
|
||||
.cj-table-header-content {
|
||||
@apply flex justify-between items-center gap-x-2;
|
||||
}
|
||||
|
||||
.cj-table-header-content > span {
|
||||
@apply text-xs font-semibold uppercase tracking-wide text-gray-800 dark:text-neutral-200 text-nowrap;
|
||||
}
|
||||
|
||||
.cj-table-header-content > svg {
|
||||
@apply size-3.5 ms-1 -me-0.5 text-gray-400 dark:text-neutral-500;
|
||||
}
|
||||
|
||||
.cj-table-header-content > svg > path:nth-child(1) {
|
||||
@apply hs-datatable-ordering-desc:text-blue-600 dark:hs-datatable-ordering-desc:text-blue-500;
|
||||
}
|
||||
|
||||
.cj-table-header-content > svg > path:nth-child(2) {
|
||||
@apply hs-datatable-ordering-asc:text-blue-600 dark:hs-datatable-ordering-asc:text-blue-500;
|
||||
}
|
||||
|
||||
.cj-table-footer {
|
||||
@apply px-6 py-4 gap-3 flex justify-between items-center;
|
||||
}
|
||||
|
||||
.cj-table-paging-btn {
|
||||
@apply 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;
|
||||
}
|
||||
|
||||
.cj-table-checkbox {
|
||||
@apply form-checkbox shrink-0 disabled:opacity-50 rounded-sm
|
||||
text-blue-600 focus:ring-blue-500 border-gray-300
|
||||
dark:bg-neutral-800 dark:border-neutral-600 dark:checked:bg-blue-500
|
||||
dark:checked:border-blue-500 dark:focus:ring-offset-gray-800;
|
||||
}
|
||||
|
||||
.cj-table-link {
|
||||
@apply block px-6 py-4 text-blue-500 hover:text-blue-600 focus:text-blue-600
|
||||
dark:text-blue-400 dark:hover:text-blue-500 dark:focus:text-blue-500
|
||||
text-nowrap cursor-pointer
|
||||
}
|
||||
|
||||
.cj-table-text {
|
||||
@apply text-sm text-gray-500 dark:text-neutral-500 text-nowrap;
|
||||
}
|
||||
|
||||
.cj-table-paging-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 bg-white border border-gray-200 rounded-lg text-start text-sm
|
||||
text-gray-800 shadow-xs hover:bg-gray-50 focus:outline-hidden focus:bg-gray-50
|
||||
before:absolute before:inset-0 before:z-1 dark:bg-neutral-900 dark:border-neutral-700
|
||||
dark:text-neutral-200 dark:hover:bg-neutral-800 dark:focus:bg-neutral-800;
|
||||
}
|
||||
|
||||
.cj-table-paging-select-dropdown {
|
||||
@apply mt-2 z-50 w-20 max-h-72 p-1 space-y-0.5 bg-white border border-gray-200
|
||||
rounded-lg shadow-md overflow-hidden overflow-y-auto [&::-webkit-scrollbar]:w-2
|
||||
[&::-webkit-scrollbar-thumb]: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;
|
||||
}
|
||||
|
||||
.cj-table-paging-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;
|
||||
}
|
||||
|
||||
/* Tag Select */
|
||||
|
||||
.cj-tag-select-wrapper {
|
||||
@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
|
||||
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;
|
||||
}
|
||||
|
||||
.cj-tag-select-input {
|
||||
@apply
|
||||
px-2
|
||||
rounded-xs
|
||||
order-1
|
||||
text-sm
|
||||
outline-hidden
|
||||
dark:bg-neutral-900
|
||||
dark:placeholder-neutral-500
|
||||
dark:text-neutral-400;
|
||||
}
|
||||
|
||||
.cj-tag-select-option {
|
||||
@apply flex items-center rounded-lg cursor-pointer py-2 ps-2 pe-4 w-full
|
||||
text-gray-500
|
||||
hover:bg-gray-100
|
||||
focus:bg-gray-100
|
||||
|
||||
dark:text-neutral-200
|
||||
dark:bg-neutral-900
|
||||
dark:hover:bg-neutral-800
|
||||
dark:focus:bg-neutral-800;
|
||||
}
|
||||
|
||||
.cj-tag-select-option [data-icon] {
|
||||
@apply size-8 me-2 flex shrink-0 items-center justify-center text-gray-500 dark:text-neutral-500;
|
||||
}
|
||||
|
||||
.cj-tag-select-option [data-title] {
|
||||
@apply text-sm font-semibold text-gray-800 dark:text-neutral-200;
|
||||
}
|
||||
|
||||
.cj-tag-select-option [data-description] {
|
||||
@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 */
|
||||
|
||||
.sidebar-btn {
|
||||
@apply
|
||||
w-full
|
||||
flex
|
||||
items-center
|
||||
gap-x-3.5
|
||||
py-2
|
||||
px-2.5
|
||||
text-sm
|
||||
rounded-lg
|
||||
focus:outline-hidden
|
||||
text-gray-800
|
||||
hover:bg-gray-100
|
||||
focus:bg-gray-100
|
||||
dark:bg-neutral-800
|
||||
dark:hover:bg-neutral-700
|
||||
dark:focus:bg-neutral-700
|
||||
dark:text-neutral-200;
|
||||
}
|
||||
|
||||
/* Form Controls */
|
||||
|
||||
.text-input-label {
|
||||
@apply block text-sm font-medium mb-2 dark:text-white
|
||||
}
|
||||
|
||||
.text-input {
|
||||
@apply
|
||||
py-3
|
||||
px-4
|
||||
block
|
||||
w-full
|
||||
rounded-lg
|
||||
text-sm
|
||||
disabled:opacity-50
|
||||
disabled:pointer-events-none
|
||||
text-neutral-600
|
||||
border-gray-200
|
||||
focus:border-blue-500
|
||||
focus:ring-blue-500
|
||||
dark:bg-neutral-900
|
||||
dark:border-neutral-700
|
||||
dark:text-neutral-400
|
||||
dark:placeholder-neutral-500
|
||||
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; }
|
76
src/client/src/css/preline.css
Normal file
76
src/client/src/css/preline.css
Normal file
@ -0,0 +1,76 @@
|
||||
/* Custom written preline variants file */
|
||||
/* The actual file in preline/variants has '@import' statements out of order, which fail with postcss. */
|
||||
|
||||
/* Preline */
|
||||
@import 'preline/src/plugins/dropdown/variants.css';
|
||||
@import 'preline/src/plugins/remove-element/variants.css';
|
||||
@import 'preline/src/plugins/tooltip/variants.css';
|
||||
@import 'preline/src/plugins/accordion/variants.css';
|
||||
@import 'preline/src/plugins/tree-view/variants.css';
|
||||
@import 'preline/src/plugins/collapse/variants.css';
|
||||
@import 'preline/src/plugins/tabs/variants.css';
|
||||
@import 'preline/src/plugins/overlay/variants.css';
|
||||
@import 'preline/src/plugins/scrollspy/variants.css';
|
||||
@import 'preline/src/plugins/carousel/variants.css';
|
||||
@import 'preline/src/plugins/select/variants.css';
|
||||
@import 'preline/src/plugins/input-number/variants.css';
|
||||
@import 'preline/src/plugins/pin-input/variants.css';
|
||||
@import 'preline/src/plugins/strong-password/variants.css';
|
||||
@import 'preline/src/plugins/stepper/variants.css';
|
||||
@import 'preline/src/plugins/combobox/variants.css';
|
||||
@import 'preline/src/plugins/layout-splitter/variants.css';
|
||||
@import 'preline/src/plugins/scroll-nav/variants.css';
|
||||
@import 'preline/src/plugins/datatable/variants.css';
|
||||
@import 'preline/src/plugins/range-slider/variants.css';
|
||||
@import 'preline/src/plugins/file-upload/variants.css';
|
||||
@import 'preline/src/plugins/datepicker/variants.css';
|
||||
@import 'preline/src/plugins/theme-switch/variants.css';
|
||||
|
||||
/* States */
|
||||
@custom-variant hs-success {
|
||||
|
||||
&.success {
|
||||
@slot;
|
||||
}
|
||||
|
||||
.success & {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant hs-error {
|
||||
|
||||
&.error {
|
||||
@slot;
|
||||
}
|
||||
|
||||
.error & {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
/* Apexcharts */
|
||||
@custom-variant hs-apexcharts-tooltip-dark {
|
||||
&.dark {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sortable.js */
|
||||
@custom-variant hs-dragged {
|
||||
&.dragged {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
/* Toastify */
|
||||
@custom-variant hs-toastify-on {
|
||||
|
||||
&.toastify.on {
|
||||
@slot;
|
||||
}
|
||||
|
||||
.toastify.on & {
|
||||
@slot;
|
||||
}
|
||||
}
|
635
src/client/src/ts/guild/feeds.ts
Normal file
635
src/client/src/ts/guild/feeds.ts
Normal file
@ -0,0 +1,635 @@
|
||||
import { formatTimestamp, verifyChannels } from "../main";
|
||||
import HSDropdown from "preline/dist/dropdown";
|
||||
import HSSelect, { ISelectOptions } from "preline/dist/select";
|
||||
import HSOverlay, { IOverlayOptions } from "preline/dist/overlay";
|
||||
import HSDataTable, { IDataTableOptions } from "preline/dist/datatable";
|
||||
import { Api, AjaxSettings, ConfigColumnDefs } from "datatables.net-dt";
|
||||
import { TextChannel } from "discord.js";
|
||||
import "datatables.net-select-dt"
|
||||
import prisma from "../../../../../generated/prisma";
|
||||
|
||||
declare let guildId: string;
|
||||
declare let channels: Array<TextChannel>;
|
||||
|
||||
// #region DataTable
|
||||
|
||||
const emptyTableHtml: string = `
|
||||
<div class="max-w-md w-full min-h-[400px] flex flex-col justify-center mx-auto px-6 py-4">
|
||||
<div class="flex justify-center items-center size-[46px] bg-gray-100 rounded-lg dark:bg-neutral-800">
|
||||
<svg class="shrink-0 size-6 text-gray-600 dark:text-neutral-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11a9 9 0 0 1 9 9"></path><path d="M4 4a16 16 0 0 1 16 16"></path><circle cx="5" cy="19" r="1"></circle></svg>
|
||||
</div>
|
||||
<h2 class="mt-5 font-semibold text-gray-800 dark:text-white">
|
||||
No results found
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-neutral-400">
|
||||
Refine your search or create a new feed.
|
||||
Alternatively, use a template to deploy a ready-made feed.
|
||||
</p>
|
||||
|
||||
<div class="mt-5 flex flex-col sm:flex-row gap-2">
|
||||
<button type="button" class="open-edit-modal-js py-2 px-3 inline-flex justify-center items-center gap-x-2 text-sm font-medium rounded-lg border border-transparent bg-blue-600 text-white hover:bg-blue-700 focus:outline-hidden focus:bg-blue-700 disabled:opacity-50 disabled:pointer-events-none">
|
||||
<svg class="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
|
||||
Create a feed
|
||||
</button>
|
||||
<button type="button" onclick="alert('not implemented');" class="py-2 px-3 inline-flex justify-center items-center gap-x-2 text-sm font-medium rounded-lg border border-gray-200 bg-white text-gray-800 shadow-xs hover:bg-gray-50 focus:outline-hidden focus:bg-gray-50 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-800 dark:border-neutral-700 dark:text-white dark:hover:bg-neutral-700 dark:focus:bg-neutral-700">
|
||||
Use a Template
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const columnDefs: ConfigColumnDefs[] = [
|
||||
{ // Select checkbox column
|
||||
target: 0,
|
||||
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="">
|
||||
<span class="sr-only">Select Row</span>
|
||||
</label>
|
||||
</div>
|
||||
`}
|
||||
},
|
||||
{
|
||||
target: 1,
|
||||
data: "name",
|
||||
orderable: true,
|
||||
searchable: true,
|
||||
className: "size-px whitespace-nowrap",
|
||||
render: (data: string, _type: string, row: prisma.Feed) => { return `
|
||||
<span class="cj-table-link open-edit-modal-js max-w-[250px] truncate" data-id="${row.id}">
|
||||
${data}
|
||||
</span>
|
||||
`}
|
||||
},
|
||||
{
|
||||
target: 2,
|
||||
data: "url",
|
||||
orderable: true,
|
||||
searchable: true,
|
||||
className: "size-px whitespace-nowrap",
|
||||
render: (data: string) => { return `
|
||||
<a href="${data}" class="cj-table-link max-w-[450px] truncate">
|
||||
${data}
|
||||
</a>
|
||||
`}
|
||||
},
|
||||
{
|
||||
target: 3,
|
||||
data: "channels",
|
||||
orderable: false,
|
||||
searchable: false,
|
||||
className: "size-px",
|
||||
render: (data: prisma.Channel[], type: string, row: prisma.Feed) => {
|
||||
if (type !== "display") { return data; }
|
||||
if (!data.length) { return ""; }
|
||||
|
||||
const wrapper = $("<div>").addClass("flex flex-nowrap gap-1 px-6 py-4");
|
||||
const tag = $("<span>").addClass("inline-flex items-center whitespace-nowrap gap-1 py-1 px-2.5 border text-xs rounded-md bg-white dark:bg-neutral-800 border-gray-200 dark:border-neutral-700 text-gray-800 dark:text-neutral-200");
|
||||
|
||||
if (!verifyChannels(data, channels)) {
|
||||
wrapper.text("invalid channels").addClass("whitespace-nowrap");
|
||||
return wrapper.get(0);
|
||||
}
|
||||
|
||||
const firstChannelName = "# " + channels.find(c => c.id === data[0].channel_id).name;
|
||||
wrapper.append(tag.clone().text(firstChannelName));
|
||||
|
||||
// No need to run the dropdown code if there's no more to show
|
||||
if (data.length === 1) {
|
||||
return wrapper.get(0);
|
||||
}
|
||||
|
||||
data.shift();
|
||||
|
||||
if (data.length <= 1) {
|
||||
const secondChannelName = "# " + channels.find(c => c.id === data[0].channel_id).name;
|
||||
wrapper.append(tag.clone().text(secondChannelName));
|
||||
return wrapper.get(0);
|
||||
}
|
||||
|
||||
const dropdown = $("<div>").addClass("hs-dropdown inline-block");
|
||||
const dropdownBtn = $("<button>").attr("id", `channelDrop-${row.id}`).attr("type", "button").addClass("cursor-pointer inline-flex items-center gap-1 py-1 px-2.5 border text-xs rounded-md bg-white dark:bg-neutral-800 border-gray-200 dark:border-neutral-700 text-gray-800 dark:text-neutral-200");
|
||||
const dropdownMenu = $("<div>").addClass("hs-dropdown-menu hidden opacity-0 hs-dropdown-open:opacity-100 transition-[opacity,margin] overflow-hidden z-10 w-fit max-w-64 border p-2 rounded-md bg-gray-200 dark:bg-neutral-700 border-gray-300 dark:border-neutral-600");
|
||||
|
||||
dropdown.append(dropdownBtn.text(`+${data.length}`));
|
||||
|
||||
data.forEach(channel => {
|
||||
const channelName = "# " + channels.find(c => c.id === channel.channel_id).name;
|
||||
dropdownMenu.append(tag.clone().text(channelName));
|
||||
});
|
||||
|
||||
dropdown.append(dropdownMenu);
|
||||
wrapper.append(dropdown);
|
||||
return wrapper.get(0);
|
||||
}
|
||||
},
|
||||
{
|
||||
target: 4,
|
||||
data: "filters",
|
||||
orderable: false,
|
||||
searchable: false,
|
||||
className: "size-px whitespace-nowrap",
|
||||
render: (data: prisma.Filter[], type: string, row: prisma.Feed) => {
|
||||
if (type !== "display") return data;
|
||||
if (!data.length) return "";
|
||||
|
||||
const wrapper = $("<div>").addClass("flex flex-nowrap gap-1 px-6 py-4");
|
||||
const tag = $("<span>").addClass("inline-flex items-center whitespace-nowrap gap-1 py-1 px-2.5 border text-xs rounded-md bg-white dark:bg-neutral-800 border-gray-200 dark:border-neutral-700 text-gray-800 dark:text-neutral-200");
|
||||
|
||||
wrapper.append(tag.clone().text(data[0].name));
|
||||
|
||||
if (data.length === 1) {
|
||||
return wrapper.get(0);
|
||||
}
|
||||
|
||||
data.shift();
|
||||
|
||||
if (data.length <= 1) {
|
||||
wrapper.append(tag.clone().text(data[0].name));
|
||||
return wrapper.get(0);
|
||||
}
|
||||
|
||||
const dropdown = $("<div>").addClass("hs-dropdown inline-block");
|
||||
const dropdownBtn = $("<button>").attr("id", `channelDrop-${row.id}`).attr("type", "button").addClass("cursor-pointer inline-flex items-center gap-1 py-1 px-2.5 border text-xs rounded-md bg-white dark:bg-neutral-800 border-gray-200 dark:border-neutral-700 text-gray-800 dark:text-neutral-200");
|
||||
const dropdownMenu = $("<div>").addClass("hs-dropdown-menu hidden opacity-0 hs-dropdown-open:opacity-100 transition-[opacity,margin] overflow-hidden z-10 w-fit max-w-64 border p-2 rounded-md bg-gray-200 dark:bg-neutral-700 border-gray-300 dark:border-neutral-600");
|
||||
|
||||
dropdown.append(dropdownBtn.text(`+${data.length}`));
|
||||
|
||||
data.forEach(filter => {
|
||||
dropdownMenu.append(tag.clone().text(filter.name));
|
||||
});
|
||||
|
||||
dropdown.append(dropdownMenu);
|
||||
wrapper.append(dropdown);
|
||||
return wrapper.get(0);
|
||||
}
|
||||
},
|
||||
{
|
||||
target: 5,
|
||||
data: null, // "message_style_id"
|
||||
orderable: false,
|
||||
searchable: false,
|
||||
className: "size-px whitespace-nowrap",
|
||||
render: (_data: unknown, type: string, row: ExpandedFeed) => {
|
||||
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">
|
||||
<span class="cj-table-text">
|
||||
${formatTimestamp(data)}
|
||||
</span>
|
||||
</div>
|
||||
`}
|
||||
},
|
||||
{
|
||||
target: 7,
|
||||
data: "active",
|
||||
orderable: true,
|
||||
searchable: false,
|
||||
className: "size-px whitespace-nowrap",
|
||||
render: (data: boolean) => {
|
||||
const wrapper = $("<div>").addClass("px-6 py-4");
|
||||
const badge = $("<span>").addClass("py-1 px-1.5 inline-flex items-center gap-x-1 text-xs font-medium rounded-full");
|
||||
const label = $("<span>");
|
||||
|
||||
if (data) {
|
||||
badge.addClass("bg-teal-100 text-teal-800 dark:bg-teal-500/10 dark:text-teal-500");
|
||||
badge.append($('<svg class="size-2.5" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/></svg>'))
|
||||
.append(label.text("Active"));
|
||||
} else {
|
||||
badge.addClass("bg-red-100 text-red-800 dark:bg-red-500/10 dark:text-red-500");
|
||||
badge.append($('<svg class="size-2.5" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/></svg>'))
|
||||
.append(label.text("Inactive"));
|
||||
}
|
||||
|
||||
wrapper.append(badge);
|
||||
return wrapper.get(0);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const ajaxSettings: AjaxSettings = {
|
||||
url: `/guild/${guildId}/feeds/api/datatable`,
|
||||
type: "POST",
|
||||
contentType: "application/json",
|
||||
dataSrc: "data",
|
||||
data: (data: unknown) => {
|
||||
if (data === undefined) return;
|
||||
// TODO,
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
};
|
||||
|
||||
const tableOptions: IDataTableOptions = {
|
||||
ajax: ajaxSettings,
|
||||
serverSide: true,
|
||||
processing: true,
|
||||
select: {
|
||||
style: "multi",
|
||||
selector: "td:first-child input[type='checkbox']"
|
||||
},
|
||||
columnDefs: columnDefs,
|
||||
pagingOptions: { pageBtnClasses: "hidden" },
|
||||
rowSelectingOptions: { selectAllSelector: "#selectAllBox" },
|
||||
language: {
|
||||
zeroRecords: emptyTableHtml,
|
||||
emptyTable: emptyTableHtml,
|
||||
loadingRecords: "Placeholder loading message..."
|
||||
},
|
||||
drawCallback: () => HSDropdown.autoInit(),
|
||||
rowCallback: (row: HTMLTableRowElement) => {
|
||||
$(row).addClass("bg-white dark:bg-neutral-900");
|
||||
}
|
||||
};
|
||||
|
||||
let table: HSDataTable;
|
||||
|
||||
window.addEventListener("preline:ready", () => {
|
||||
const tableEl = $("#table").get(0);
|
||||
|
||||
if (HSDataTable.getInstance(tableEl, true)) return;
|
||||
|
||||
table = new HSDataTable(tableEl, tableOptions);
|
||||
|
||||
(table as any).dataTable
|
||||
.on("select", onTableSelectChange)
|
||||
.on("deselect", onTableSelectChange)
|
||||
.on("draw", onTableSelectChange);
|
||||
});
|
||||
|
||||
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");
|
||||
if (selectedRowsCount === 0) {
|
||||
$elem.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
$elem.show();
|
||||
};
|
||||
|
||||
$("#selectAllBox").on("change", function() {
|
||||
const dt: Api = (table as any).dataTable;
|
||||
|
||||
if ((this as HTMLInputElement).checked) {
|
||||
dt.rows().select();
|
||||
return;
|
||||
}
|
||||
|
||||
dt.rows().deselect();
|
||||
});
|
||||
|
||||
$("#deleteRowsBtn").on("click", async () => {
|
||||
const dt: Api = (table as any).dataTable;
|
||||
const rowsData = dt.rows({ selected: true }).data().toArray();
|
||||
const rowIds = rowsData.map((row: prisma.Feed) => row.id);
|
||||
|
||||
await $.ajax({
|
||||
url: `/guild/${guildId}/feeds/api`,
|
||||
method: "delete",
|
||||
dataType: "json",
|
||||
data: { ids: rowIds },
|
||||
success: () => {
|
||||
dt.draw();
|
||||
dt.rows().deselect();
|
||||
},
|
||||
error: error => {
|
||||
alert(typeof error === "object" ? JSON.stringify(error, null, 4) : error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Table Paging Select
|
||||
|
||||
const pageSelectOptions: ISelectOptions = {
|
||||
toggleTag: '<button type="button" aria-expanded="false"></button>',
|
||||
optionTemplate: `
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<span data-title></span>
|
||||
<span class="hidden hs-selected:block">
|
||||
<svg class="shrink-0 size-3.5 text-blue-600 dark:text-blue-500" xmlns="http:.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</span>
|
||||
</div>`,
|
||||
toggleClasses: "cj-table-paging-select-toggle",
|
||||
optionClasses: "cj-table-paging-select-option",
|
||||
dropdownClasses: "cj-table-paging-select-dropdown",
|
||||
dropdownSpace: 10,
|
||||
dropdownScope: "parent",
|
||||
dropdownPlacement: "top",
|
||||
dropdownVerticalFixedPlacement: null
|
||||
};
|
||||
|
||||
window.addEventListener("preline:ready", () => {
|
||||
const selectEl = $("#selectPageSize-js").get(0);
|
||||
if (!HSSelect.getInstance(selectEl, true)) {
|
||||
new HSSelect(selectEl, pageSelectOptions);
|
||||
}
|
||||
});
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Edit Modal
|
||||
|
||||
const closeEditModal = () => { editModal.close() };
|
||||
|
||||
const openEditModal = async (id: number | undefined) => {
|
||||
$("#editForm").removeClass("submitted");
|
||||
editModal.open();
|
||||
|
||||
if (id === undefined) {
|
||||
clearEditModalData();
|
||||
return;
|
||||
}
|
||||
|
||||
loadEditModalData(id);
|
||||
};
|
||||
|
||||
$(document).on("click", ".open-edit-modal-js", async event => {
|
||||
await openEditModal($(event.target).data("id"));
|
||||
});
|
||||
|
||||
const editModalOptions: IOverlayOptions = {};
|
||||
|
||||
let editModal: HSOverlay;
|
||||
|
||||
window.addEventListener("preline:ready", () => {
|
||||
const modalEl = $("#editModal").get(0);
|
||||
if (!HSOverlay.getInstance(modalEl, true)) {
|
||||
editModal = new HSOverlay(modalEl, editModalOptions);
|
||||
}
|
||||
});
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Edit Form
|
||||
|
||||
interface ExpandedFeed extends prisma.Feed {
|
||||
channels: prisma.Channel[];
|
||||
filters: prisma.Feed[];
|
||||
message_style: prisma.MessageStyle | undefined;
|
||||
}
|
||||
|
||||
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 loadEditModalData = async (id: number) => {
|
||||
const feed: ExpandedFeed = await $.ajax({
|
||||
url: `/guild/${guildId}/feeds/api?id=${id}`,
|
||||
method: "get"
|
||||
});
|
||||
|
||||
$(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",
|
||||
|
||||
tagsItemTemplate: `
|
||||
<div class="flex flex-nowrap items-center relative z-10 bg-white border border-gray-200 rounded-lg p-1 m-1 dark:bg-neutral-900 dark:border-neutral-700 ">
|
||||
<div class="size-6 flex justify-center items-center" data-icon></div>
|
||||
<div class="whitespace-nowrap text-gray-800 dark:text-neutral-200" data-title></div>
|
||||
<div class="inline-flex shrink-0 justify-center items-center size-5 ms-2 rounded-lg text-gray-800 bg-gray-200 hover:bg-gray-300 focus:outline-hidden focus:ring-2 focus:ring-gray-400 text-sm dark:bg-neutral-700/50 dark:hover:bg-neutral-700 dark:text-neutral-400 cursor-pointer" data-remove>
|
||||
<svg class="shrink-0 size-3" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
optionTemplate: `
|
||||
<div class="cj-tag-select-option">
|
||||
<div data-icon></div>
|
||||
<div>
|
||||
<div data-title></div>
|
||||
<div data-description></div>
|
||||
</div>
|
||||
<div class="ms-auto">
|
||||
<span class="hidden hs-selected:block">
|
||||
<svg class="shrink-0 size-4 text-blue-600" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M12.736 3.97a.733.733 0 0 1 1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.065.02L3.217 8.384a.757.757 0 0 1 0-1.06.733.733 0 0 1 1.047 0l3.052 3.093 5.4-6.425a.247.247 0 0 1 .02-.022Z"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
tagsInputId: "formChannelsInput",
|
||||
wrapperClasses: "cj-tag-select-wrapper",
|
||||
dropdownClasses: "cj-tag-select-dropdown w-full",
|
||||
tagsInputClasses: "cj-tag-select-input",
|
||||
|
||||
dropdownScope: "window",
|
||||
dropdownSpace: 10,
|
||||
dropdownPlacement: "bottom",
|
||||
dropdownVerticalFixedPlacement: null,
|
||||
|
||||
hasSearch: false,
|
||||
searchNoResultClasses: "cj-tag-select-search-no-results",
|
||||
};
|
||||
|
||||
const filterSelectOptions: ISelectOptions = {
|
||||
placeholder: "Select option....",
|
||||
mode: "tags",
|
||||
|
||||
tagsItemTemplate: `
|
||||
<div class="flex flex-nowrap items-center relative z-10 bg-white border border-gray-200 rounded-lg p-1 m-1 dark:bg-neutral-900 dark:border-neutral-700 ">
|
||||
<div class="size-6 flex justify-center items-center">
|
||||
<svg class="shrink-0 size-[16px]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z" /></svg>
|
||||
</div>
|
||||
<div class="whitespace-nowrap text-gray-800 dark:text-neutral-200" data-title></div>
|
||||
<div class="inline-flex shrink-0 justify-center items-center size-5 ms-2 rounded-lg text-gray-800 bg-gray-200 hover:bg-gray-300 focus:outline-hidden focus:ring-2 focus:ring-gray-400 text-sm dark:bg-neutral-700/50 dark:hover:bg-neutral-700 dark:text-neutral-400 cursor-pointer" data-remove>
|
||||
<svg class="shrink-0 size-3" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
optionTemplate: `
|
||||
<div class="cj-tag-select-option">
|
||||
<div data-icon>
|
||||
<svg class="shrink-0 size-[18px]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div data-title></div>
|
||||
<div data-description></div>
|
||||
</div>
|
||||
<div class="ms-auto">
|
||||
<span class="hidden hs-selected:block">
|
||||
<svg class="shrink-0 size-4 text-blue-600" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M12.736 3.97a.733.733 0 0 1 1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.065.02L3.217 8.384a.757.757 0 0 1 0-1.06.733.733 0 0 1 1.047 0l3.052 3.093 5.4-6.425a.247.247 0 0 1 .02-.022Z"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
tagsInputId: "formFiltersInput",
|
||||
wrapperClasses: "cj-tag-select-wrapper",
|
||||
dropdownClasses: "cj-tag-select-dropdown w-full",
|
||||
tagsInputClasses: "cj-tag-select-input",
|
||||
|
||||
dropdownScope: "window",
|
||||
dropdownSpace: 10,
|
||||
dropdownPlacement: "bottom",
|
||||
dropdownVerticalFixedPlacement: null,
|
||||
|
||||
// API
|
||||
apiUrl: `/guild/${guildId}/filters/api/select`,
|
||||
apiQuery: "limit=15",
|
||||
apiFieldsMap: {
|
||||
id: "id",
|
||||
val: "id",
|
||||
title: "name",
|
||||
description: "value",
|
||||
name: "title"
|
||||
},
|
||||
apiSearchQueryKey: "search",
|
||||
hasSearch: false,
|
||||
searchNoResultClasses: "cj-tag-select-search-no-results",
|
||||
|
||||
};
|
||||
|
||||
const styleSelectOptions: ISelectOptions = {
|
||||
placeholder: "Select option...",
|
||||
|
||||
toggleTag: '<button type="button" aria-expanded="false"><span data-title></span></button>',
|
||||
optionTemplate: `
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<span data-title></span>
|
||||
<span class="hidden hs-selected:block">
|
||||
<svg class="shrink-0 size-3.5 text-blue-600 dark:text-blue-500" xmlns="http:.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</span>
|
||||
</div>`,
|
||||
toggleClasses: "cj-select-toggle select-input",
|
||||
optionClasses: "cj-select-option",
|
||||
dropdownClasses: "cj-select-dropdown",
|
||||
wrapperClasses: "peer",
|
||||
dropdownSpace: 10,
|
||||
dropdownScope: "parent",
|
||||
dropdownPlacement: "top",
|
||||
dropdownVerticalFixedPlacement: null,
|
||||
|
||||
apiUrl: `/guild/${guildId}/styles/api/select`,
|
||||
// apiQuery: "limit=15",
|
||||
apiFieldsMap: {
|
||||
id: "id",
|
||||
val: "id",
|
||||
title: "name",
|
||||
description: "value",
|
||||
name: "title"
|
||||
},
|
||||
apiSearchQueryKey: "search",
|
||||
hasSearch: false,
|
||||
optionAllowEmptyOption: true
|
||||
};
|
||||
|
||||
let channelSelect: HSSelect;
|
||||
let filterSelect: HSSelect;
|
||||
let styleSelect: HSSelect;
|
||||
|
||||
window.addEventListener("preline:ready", () => {
|
||||
const exists = (element: HTMLElement) => HSSelect.getInstance(element, true);
|
||||
|
||||
const channelEl = $("#formChannels").get(0);
|
||||
const filterEl = $("#formFilters").get(0);
|
||||
const styleEl = $("#formMessageStyle").get(0);
|
||||
|
||||
if (exists(channelEl) || exists(filterEl) || exists(styleEl)) 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 => {
|
||||
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
|
419
src/client/src/ts/guild/filters.ts
Normal file
419
src/client/src/ts/guild/filters.ts
Normal file
@ -0,0 +1,419 @@
|
||||
import { formatTimestamp } from "../main";
|
||||
import HSDropdown from "preline/dist/dropdown";
|
||||
import HSSelect, { ISelectOptions } from "preline/dist/select";
|
||||
import HSOverlay, { IOverlayOptions } from "preline/dist/overlay";
|
||||
import HSDataTable, { IDataTableOptions } from "preline/dist/datatable";
|
||||
import { Api, AjaxSettings, ConfigColumnDefs } from "datatables.net-dt";
|
||||
import "datatables.net-select-dt"
|
||||
import prisma from "../../../../../generated/prisma";
|
||||
|
||||
declare let guildId: string;
|
||||
declare const matchingAlgorithms: { [key: string]: string };
|
||||
|
||||
// #region DataTable
|
||||
|
||||
const emptyTableHtml: string = `
|
||||
<div class="max-w-md w-full min-h-[400px] flex flex-col justify-center mx-auto px-6 py-4">
|
||||
<div class="flex justify-center items-center size-[46px] bg-gray-100 rounded-lg dark:bg-neutral-800">
|
||||
<svg class="shrink-0 size-6 text-gray-600 dark:text-neutral-400" xmlns="http://www.w3.org/2000/svg" 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>
|
||||
<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 filter.
|
||||
Alternatively, use a template to deploy a ready-made filter.
|
||||
</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">
|
||||
<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 filter
|
||||
</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.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">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.Filter) => { return `
|
||||
<span class="cj-table-link open-edit-modal-js max-w-[250px] truncate" data-id="${row.id}">
|
||||
${data}
|
||||
</span>
|
||||
`}
|
||||
},
|
||||
{
|
||||
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>
|
||||
`}
|
||||
},
|
||||
{
|
||||
target: 3,
|
||||
data: "matching_algorithm",
|
||||
orderable: true,
|
||||
searchable: false,
|
||||
className: "size-px whitespace-nowrap",
|
||||
render: (data: string, type: string) => {
|
||||
if (type !== "display") return data;
|
||||
|
||||
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");
|
||||
|
||||
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");
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
{
|
||||
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>
|
||||
`}
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
const ajaxSettings: AjaxSettings = {
|
||||
url: `/guild/${guildId}/filters/api/datatable`,
|
||||
type: "POST",
|
||||
contentType: "application/json",
|
||||
dataSrc: "data",
|
||||
data: (data: unknown) => {
|
||||
if (data === undefined) return;
|
||||
// TODO,
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
};
|
||||
|
||||
const tableOptions: IDataTableOptions = {
|
||||
ajax: ajaxSettings,
|
||||
serverSide: true,
|
||||
processing: true,
|
||||
select: {
|
||||
style: "multi",
|
||||
selector: "td:first-child input[type='checkbox']"
|
||||
},
|
||||
columnDefs: columnDefs,
|
||||
pagingOptions: { pageBtnClasses: "hidden" },
|
||||
rowSelectingOptions: { selectAllSelector: "#selectAllBox" },
|
||||
language: {
|
||||
zeroRecords: emptyTableHtml,
|
||||
emptyTable: emptyTableHtml,
|
||||
loadingRecords: "Placeholder loading message..."
|
||||
},
|
||||
drawCallback: () => HSDropdown.autoInit(),
|
||||
rowCallback: (row: HTMLTableRowElement) => {
|
||||
$(row).addClass("bg-white dark:bg-neutral-900");
|
||||
}
|
||||
};
|
||||
|
||||
let table: HSDataTable;
|
||||
|
||||
window.addEventListener("preline:ready", () => {
|
||||
const tableEl = $("#table").get(0);
|
||||
|
||||
if (HSDataTable.getInstance(tableEl, true)) return;
|
||||
|
||||
table = new HSDataTable(tableEl, tableOptions);
|
||||
|
||||
(table as any).dataTable
|
||||
.on("select", onTableSelectChange)
|
||||
.on("deselect", onTableSelectChange)
|
||||
.on("draw", onTableSelectChange);
|
||||
});
|
||||
|
||||
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");
|
||||
if (selectedRowsCount === 0) {
|
||||
$elem.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
$elem.show();
|
||||
};
|
||||
|
||||
$("#selectAllBox").on("change", function() {
|
||||
const dt: Api = (table as any).dataTable;
|
||||
|
||||
if ((this as HTMLInputElement).checked) {
|
||||
dt.rows().select();
|
||||
return;
|
||||
}
|
||||
|
||||
dt.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 Table Paging Select
|
||||
|
||||
const pageSelectOptions: ISelectOptions = {
|
||||
toggleTag: '<button type="button" aria-expanded="false"></button>',
|
||||
optionTemplate: `
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<span data-title></span>
|
||||
<span class="hidden hs-selected:block">
|
||||
<svg class="shrink-0 size-3.5 text-blue-600 dark:text-blue-500" xmlns="http:.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</span>
|
||||
</div>`,
|
||||
toggleClasses: "cj-table-paging-select-toggle",
|
||||
optionClasses: "cj-table-paging-select-option",
|
||||
dropdownClasses: "cj-table-paging-select-dropdown",
|
||||
dropdownSpace: 10,
|
||||
dropdownScope: "parent",
|
||||
dropdownPlacement: "top",
|
||||
dropdownVerticalFixedPlacement: null
|
||||
};
|
||||
|
||||
window.addEventListener("preline:ready", () => {
|
||||
const selectEl = $("#selectPageSize-js").get(0);
|
||||
if (!HSSelect.getInstance(selectEl, true)) {
|
||||
new HSSelect(selectEl, pageSelectOptions);
|
||||
}
|
||||
});
|
||||
|
||||
// #region Edit Modal
|
||||
|
||||
const closeEditModal = () => { editModal.close() };
|
||||
|
||||
const openEditModal = async (id: number | undefined) => {
|
||||
$("#editForm").removeClass("submitted");
|
||||
editModal.open();
|
||||
|
||||
if (id === undefined) {
|
||||
clearEditModalData();
|
||||
return;
|
||||
}
|
||||
|
||||
loadEditModalData(id);
|
||||
};
|
||||
|
||||
$(document).on("click", ".open-edit-modal-js", async event => {
|
||||
await openEditModal($(event.target).data("id"));
|
||||
});
|
||||
|
||||
const editModalOptions: IOverlayOptions = {};
|
||||
|
||||
let editModal: HSOverlay;
|
||||
|
||||
window.addEventListener("preline:ready", () => {
|
||||
const modalEl = $("#editModal").get(0);
|
||||
if (!HSOverlay.getInstance(modalEl, true)) {
|
||||
editModal = new HSOverlay(modalEl, editModalOptions);
|
||||
}
|
||||
});
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Edit Form
|
||||
|
||||
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);
|
||||
|
||||
algorithmSelect.setValue(filter.matching_algorithm);
|
||||
};
|
||||
|
||||
$("#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}/filters/api`,
|
||||
dataType: "json",
|
||||
method: method,
|
||||
data: data,
|
||||
success: () => {
|
||||
(table as any).dataTable.draw();
|
||||
closeEditModal();
|
||||
},
|
||||
error: error => {
|
||||
alert(JSON.stringify(error, null, 4));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
let algorithmSelect: HSSelect;
|
||||
|
||||
window.addEventListener("preline:ready", () => {
|
||||
const algorithmEl = $("#formAlgorithm").get(0)
|
||||
|
||||
if (HSSelect.getInstance(algorithmEl, true)) return;
|
||||
|
||||
algorithmSelect = new HSSelect(algorithmEl, algorithmSelectOptions);
|
||||
|
||||
Object.entries(matchingAlgorithms).forEach(([key, description]) => {
|
||||
algorithmSelect.addOption({
|
||||
title: description,
|
||||
val: key
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// #endregion
|
549
src/client/src/ts/guild/styles.ts
Normal file
549
src/client/src/ts/guild/styles.ts
Normal file
@ -0,0 +1,549 @@
|
||||
import { formatTimestamp, genHexString } from "../main";
|
||||
import HSDropdown from "preline/dist/dropdown";
|
||||
import HSSelect, { ISelectOptions } from "preline/dist/select";
|
||||
import HSOverlay, { IOverlayOptions } from "preline/dist/overlay";
|
||||
import HSDataTable, { IDataTableOptions } from "preline/dist/datatable";
|
||||
import { Api, AjaxSettings, ConfigColumnDefs } from "datatables.net-dt";
|
||||
import "datatables.net-select-dt";
|
||||
import prisma from "../../../../../generated/prisma";
|
||||
|
||||
declare let guildId: string;
|
||||
declare const textMutators: { [key: string]: string };
|
||||
|
||||
// #region DataTable
|
||||
|
||||
const emptyTableHtml: string = `
|
||||
<div class="max-w-md w-full min-h-[400px] flex flex-col justify-center mx-auto px-6 py-4">
|
||||
<div class="flex justify-center items-center size-[46px] bg-gray-100 rounded-lg dark:bg-neutral-800">
|
||||
<svg class="shrink-0 size-6" 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, type: string) => {
|
||||
if (type !== "display") return data;
|
||||
|
||||
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", data);
|
||||
const label = $("<span>").addClass("py-1 px-2.5 text-xs text-gray-800 dark:text-neutral-200");
|
||||
label.text(data);
|
||||
|
||||
badge.append(colour).append(label);
|
||||
wrapper.append(badge);
|
||||
return wrapper.get(0);
|
||||
}
|
||||
},
|
||||
{
|
||||
target: 3,
|
||||
data: "title_mutator",
|
||||
orderable: true,
|
||||
searchable: false,
|
||||
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: false,
|
||||
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");
|
||||
}
|
||||
};
|
||||
|
||||
let table: HSDataTable;
|
||||
|
||||
window.addEventListener("preline:ready", () => {
|
||||
const tableEl = $("#table").get(0);
|
||||
|
||||
if (HSDataTable.getInstance(tableEl, true)) return;
|
||||
|
||||
table = new HSDataTable(tableEl, tableOptions);
|
||||
|
||||
(table as any).dataTable
|
||||
.on("select", onTableSelectChange)
|
||||
.on("deselect", onTableSelectChange)
|
||||
.on("draw", onTableSelectChange);
|
||||
});
|
||||
|
||||
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");
|
||||
if (selectedRowsCount === 0) {
|
||||
$elem.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
$elem.show();
|
||||
};
|
||||
|
||||
$("#selectAllBox").on("change", function() {
|
||||
const dt: Api = (table as any).dataTable;
|
||||
|
||||
if ((this as HTMLInputElement).checked) {
|
||||
dt.rows().select();
|
||||
return;
|
||||
}
|
||||
|
||||
dt.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 Table Paging Select
|
||||
|
||||
const pageSelectOptions: ISelectOptions = {
|
||||
toggleTag: '<button type="button" aria-expanded="false"></button>',
|
||||
optionTemplate: `
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<span data-title></span>
|
||||
<span class="hidden hs-selected:block">
|
||||
<svg class="shrink-0 size-3.5 text-blue-600 dark:text-blue-500" xmlns="http:.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</span>
|
||||
</div>`,
|
||||
toggleClasses: "cj-table-paging-select-toggle",
|
||||
optionClasses: "cj-table-paging-select-option",
|
||||
dropdownClasses: "cj-table-paging-select-dropdown",
|
||||
dropdownSpace: 10,
|
||||
dropdownScope: "parent",
|
||||
dropdownPlacement: "top",
|
||||
dropdownVerticalFixedPlacement: null
|
||||
};
|
||||
|
||||
window.addEventListener("preline:ready", () => {
|
||||
const selectEl = $("#selectPageSize-js").get(0);
|
||||
if (!HSSelect.getInstance(selectEl, true)) {
|
||||
new HSSelect(selectEl, pageSelectOptions);
|
||||
}
|
||||
});
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Edit Modal
|
||||
|
||||
const closeEditModal = () => { editModal.close() };
|
||||
|
||||
const openEditModal = async (id: number | undefined) => {
|
||||
$("#editForm").removeClass("submitted");
|
||||
editModal.open();
|
||||
|
||||
if (id === undefined) {
|
||||
clearEditModalData();
|
||||
return;
|
||||
}
|
||||
|
||||
loadEditModalData(id);
|
||||
};
|
||||
|
||||
$(document).on("click", ".open-edit-modal-js", async event => {
|
||||
await openEditModal($(event.target).data("id"));
|
||||
});
|
||||
|
||||
const editModalOptions: IOverlayOptions = {};
|
||||
|
||||
let editModal: HSOverlay;
|
||||
|
||||
window.addEventListener("preline:ready", () => {
|
||||
const modalEl = $("#editModal").get(0);
|
||||
if (!HSOverlay.getInstance(modalEl, true)) {
|
||||
editModal = new HSOverlay(modalEl, editModalOptions);
|
||||
}
|
||||
});
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Edit Form
|
||||
|
||||
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(style.colour);
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
$("#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}/styles/api`,
|
||||
dataType: "json",
|
||||
method: method,
|
||||
data: data,
|
||||
success: () => {
|
||||
(table as any).dataTable.draw()
|
||||
closeEditModal();
|
||||
},
|
||||
error: error => {
|
||||
alert(JSON.stringify(error, null, 4));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
let titleMutatorSelect: HSSelect;
|
||||
let descriptionMutatorSelect: HSSelect;
|
||||
|
||||
window.addEventListener("preline:ready", () => {
|
||||
const exists = (element: HTMLElement) => HSSelect.getInstance(element, true);
|
||||
|
||||
const titleEl = $("#formTitleMutator").get(0);
|
||||
const descEl = $("#formDescriptionMutator").get(0);
|
||||
|
||||
if (exists(titleEl) || exists(descEl)) return;
|
||||
|
||||
titleMutatorSelect = new HSSelect(titleEl, mutatorSelectOptions);
|
||||
titleMutatorSelect.addOption({ title: "None", val: "" });
|
||||
|
||||
descriptionMutatorSelect = new HSSelect(descEl, mutatorSelectOptions);
|
||||
descriptionMutatorSelect.addOption({ title: "None", val: "" });
|
||||
|
||||
Object.entries(textMutators).forEach(([key, description]) => {
|
||||
const option = {title: description, val: key};
|
||||
titleMutatorSelect.addOption(option);
|
||||
descriptionMutatorSelect.addOption(option);
|
||||
});
|
||||
});
|
||||
|
||||
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)));
|
||||
|
||||
|
||||
// #endregion
|
@ -1,3 +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');
|
||||
@ -22,15 +51,29 @@ export const formatTimestamp = (timestamp: string | number) => {
|
||||
? timestamp.replace(" ", "T")
|
||||
: timestamp
|
||||
);
|
||||
const now = new Date();
|
||||
const difference = now.getTime() - date.getTime();
|
||||
|
||||
// Day and short month (example: 21 Oct)
|
||||
const result = `${date.getDate()} ${date.toLocaleString("en-GB", { month: "short" })}`
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Difference is less than a year: 'DD MMM, HH:mm'
|
||||
// Or, difference is more than a year: 'DD MMM YYYY'
|
||||
return difference < 31536000000
|
||||
return now.getFullYear() === date.getFullYear()
|
||||
? result + `, ${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`
|
||||
: result + ` ${date.getFullYear()}`;
|
||||
}
|
||||
|
||||
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
23
src/client/src/types/client.d.ts
vendored
Normal 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 {};
|
@ -1,13 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES5",
|
||||
"outDir": "./public/js",
|
||||
"rootDir": "./typescript",
|
||||
"outDir": "./public/generated/js",
|
||||
"rootDir": "./src/ts",
|
||||
"baseUrl": ".",
|
||||
"sourceMap": false,
|
||||
"esModuleInterop": true,
|
||||
"noImplicitAny": true
|
||||
"noImplicitAny": true,
|
||||
"typeRoots": [
|
||||
"./src/types"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"./typescript/**/*"
|
||||
"./src/ts/**/*",
|
||||
"./src/types"
|
||||
]
|
||||
}
|
@ -1,222 +0,0 @@
|
||||
import $ from "jquery";
|
||||
import DataTable from "datatables.net";
|
||||
import HSDropdown from "@preline/dropdown";
|
||||
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 } from "../main";
|
||||
|
||||
// #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 text-gray-600 dark:text-neutral-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11a9 9 0 0 1 9 9"></path><path d="M4 4a16 16 0 0 1 16 16"></path><circle cx="5" cy="19" r="1"></circle></svg>
|
||||
</div>
|
||||
<h2 class="mt-5 font-semibold text-gray-800 dark:text-white">
|
||||
No results found
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-neutral-400">
|
||||
Create a feed and it will appear here.
|
||||
</p>
|
||||
|
||||
<div class="mt-5 flex flex-col sm:flex-row gap-2">
|
||||
<button type="button" class="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">
|
||||
<svg class="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
|
||||
Create a feed
|
||||
</button>
|
||||
<button type="button" onclick="alert('not implemented');" class="py-2 px-3 inline-flex justify-center items-center gap-x-2 text-sm font-medium rounded-lg border border-gray-200 bg-white text-gray-800 shadow-xs hover:bg-gray-50 focus:outline-hidden focus:bg-gray-50 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-800 dark:border-neutral-700 dark:text-white dark:hover:bg-neutral-700 dark:focus:bg-neutral-700">
|
||||
Use a Template
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const columnDefs: ConfigColumnDefs[] = [
|
||||
// Select checkbox column
|
||||
{
|
||||
target: 0,
|
||||
orderable: false,
|
||||
searchable: false,
|
||||
render(_data: unknown, _type: unknown, row: any) { return `
|
||||
<td class="size-px whitespace-nowrap">
|
||||
<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>
|
||||
</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">
|
||||
${data}
|
||||
</span>
|
||||
</td>
|
||||
`}
|
||||
},
|
||||
{
|
||||
target: 2,
|
||||
data: "url",
|
||||
orderable: true,
|
||||
searchable: true,
|
||||
render(data: string) { return `
|
||||
<td class="size-px whitespace-nowrap align-top">
|
||||
<span class="cj-table-link">
|
||||
${data}
|
||||
</span>
|
||||
</td>
|
||||
`}
|
||||
},
|
||||
{
|
||||
target: 3,
|
||||
data: "channels",
|
||||
orderable: false,
|
||||
searchable: false,
|
||||
render(data: string) {
|
||||
return `${data}`
|
||||
}
|
||||
},
|
||||
{
|
||||
target: 4,
|
||||
data: "filters",
|
||||
orderable: false,
|
||||
searchable: false,
|
||||
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: 5,
|
||||
data: "message_style",
|
||||
orderable: true,
|
||||
searchable: true,
|
||||
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: 6,
|
||||
data: "created_at",
|
||||
orderable: true,
|
||||
searchable: false,
|
||||
render(data: string) { return `
|
||||
<td class="size-px whitespace-nowrap align-top">
|
||||
<div class="px-6 py-4">
|
||||
<span class="cj-table-text">
|
||||
${formatTimestamp(data)}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
`}
|
||||
},
|
||||
{
|
||||
target: 7,
|
||||
data: "active",
|
||||
orderable: true,
|
||||
searchable: false,
|
||||
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>
|
||||
`}
|
||||
}
|
||||
];
|
||||
|
||||
const ajaxSettings: AjaxSettings = {
|
||||
url: `/guild/${1204426362794811453}/feeds/api/datatable`,
|
||||
dataSrc: "data",
|
||||
data: (data: unknown) => {
|
||||
if (data === undefined) return;
|
||||
// TODO
|
||||
}
|
||||
};
|
||||
|
||||
const tableOptions: IDataTableOptions = {
|
||||
ajax: ajaxSettings,
|
||||
serverSide: true,
|
||||
processing: true,
|
||||
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),
|
||||
tableOptions
|
||||
);
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Page Size Select Box
|
||||
// https://preline.co/plugins/html/advanced-select.html
|
||||
|
||||
(window as any).$hsSelectCollection = [];
|
||||
(window as any)["FloatingUIDOM"] = {
|
||||
computePosition: computePosition,
|
||||
autoUpdate: autoUpdate,
|
||||
offset: offset
|
||||
};
|
||||
|
||||
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-select-toggle",
|
||||
optionClasses: "cj-select-option",
|
||||
dropdownClasses: `cj-select-dropdown`,
|
||||
dropdownSpace: 10,
|
||||
dropdownScope: "parent",
|
||||
dropdownPlacement: "top",
|
||||
dropdownVerticalFixedPlacement: null
|
||||
};
|
||||
|
||||
const pageSizeSelect: HSSelect = new HSSelect(
|
||||
$("#selectPageSize-js").get(0),
|
||||
pageSelectOptions
|
||||
);
|
||||
|
||||
// #endregion
|
@ -9,8 +9,35 @@
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-xs dark:bg-neutral-900 dark:border-neutral-700">
|
||||
|
||||
<!-- Header -->
|
||||
<div>
|
||||
placeholder header content
|
||||
<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 feed</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
@ -18,86 +45,61 @@
|
||||
<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" 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">
|
||||
URL
|
||||
</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" 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">
|
||||
<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="channels" class="px-6 py-3 text-start --exclude-from-ordering">
|
||||
<div class="flex justify-between items-center gap-x-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-gray-800 dark:text-neutral-200">
|
||||
Channels
|
||||
</span>
|
||||
<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="px-6 py-3 text-start --exclude-from-ordering">
|
||||
<div class="flex justify-between items-center gap-x-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-gray-800 dark:text-neutral-200">
|
||||
Filters
|
||||
</span>
|
||||
<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="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">
|
||||
Style
|
||||
</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" class="cj-table-header --exclude-from-ordering">
|
||||
<div class="cj-table-header-content">
|
||||
<span>Style</span>
|
||||
</div>
|
||||
</th>
|
||||
<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">
|
||||
<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>
|
||||
</svg>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" data-dt-column="active" 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">
|
||||
Status
|
||||
</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" 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">
|
||||
<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>
|
||||
|
||||
@ -106,6 +108,7 @@
|
||||
<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>
|
||||
@ -125,7 +128,7 @@
|
||||
<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>
|
||||
<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>
|
||||
@ -138,4 +141,107 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% block("scripts").append('<script src="/static/bundles/guild/feeds.js"></script>'); %>
|
||||
<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">
|
||||
<h2 class="text-xl font-bold text-gray-800 dark:text-neutral-200">Feed</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-neutral-400">
|
||||
Manage your RSS feeds with filters and channel targets.
|
||||
</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="formUrl" class="text-input-label">URL</label>
|
||||
<input type="url" id="formUrl" name="url" 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">
|
||||
Source of RSS content.
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-red-500 hidden peer-invalid:group-[.submitted]:block">
|
||||
Please enter a valid URL.
|
||||
</p>
|
||||
</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>
|
||||
</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 & time.
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-red-500 hidden peer-invalid:group-[.submitted]:block">
|
||||
Please enter a date.
|
||||
</p>
|
||||
</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">
|
||||
<span class="flex flex-col">
|
||||
<span class="block text-sm dark:text-neutral-400">Active</span>
|
||||
<span class="block text-sm text-gray-500 dark:text-neutral-500">Inactive entries will not be processed.</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 channels = JSON.parse(`<%- JSON.stringify(
|
||||
guild.channels.cache
|
||||
.filter(channel => channel.type == 0)
|
||||
.sort((a, b) => a.rawPosition - b.rawPosition)
|
||||
.map(channel => channel.toJSON())
|
||||
) %>`);
|
||||
</script>
|
||||
<% block("scripts").append('<script src="/public/generated/js/guild/feeds.js"></script>'); %>
|
@ -2,4 +2,230 @@
|
||||
|
||||
<%- include("header") -%>
|
||||
|
||||
Filters 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 filter</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="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="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="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="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="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="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">Filter</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-neutral-400">
|
||||
Manage your filters to organise the content in your feeds.
|
||||
</p>
|
||||
</div>
|
||||
<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">
|
||||
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 matchingAlgorithms = JSON.parse(`<%- JSON.stringify( matchingAlgorithms ) %> `);
|
||||
</script>
|
||||
<% block("scripts").append('<script src="/public/generated/js/guild/filters.js"></script>'); %>
|
@ -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="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>'); %>
|
@ -4,16 +4,16 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><%= title %></title>
|
||||
<link rel="stylesheet" href="/static/css/tailwind.css">
|
||||
<link rel="stylesheet" href="/public/generated/css/main.css">
|
||||
</head>
|
||||
<body class="bg-neutral-100 dark:bg-neutral-900 text-gray-600 dark:text-gray-400 min-h-screen font-[Inter]">
|
||||
<body class="dark bg-neutral-100 dark:bg-neutral-900 text-gray-600 dark:text-gray-400 min-h-screen font-[Inter]">
|
||||
<%- include("sidebar") -%>
|
||||
|
||||
<div class="w-full lg:ps-64">
|
||||
<%- body -%>
|
||||
</div>
|
||||
|
||||
<script src="/static/bundles/main.js"></script>
|
||||
<script src="/public/generated/js/main.js"></script>
|
||||
<%- block("scripts").toString() %>
|
||||
</body>
|
||||
</html>
|
93
src/log.ts
Normal file
93
src/log.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import winston from "winston";
|
||||
import chalk from "chalk";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
const logFileDirectory = process.env.LOG_DIR || path.join(__dirname, "..", "logs");
|
||||
if (!fs.existsSync(logFileDirectory)) {
|
||||
fs.mkdirSync(logFileDirectory);
|
||||
}
|
||||
|
||||
const deleteLogFile =(filePath: string) => {
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
logger.info("Deleted expired log file", { filename: __filename });
|
||||
} catch (error) {
|
||||
logger.error("Failed to expired log file:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const cleanExpiredLogFiles = () => {
|
||||
const files = fs.readdirSync(logFileDirectory);
|
||||
const now = Date.now();
|
||||
const maxAgeMs = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(logFileDirectory, file);
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
if (stats.isFile() && now - stats.mtimeMs > maxAgeMs) {
|
||||
deleteLogFile(filePath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const { combine, timestamp, errors, printf } = winston.format;
|
||||
const timestampFormat = "YYYY-MM-DD HH:mm:ss";
|
||||
const levelColours: Record<string, any> = {
|
||||
info: chalk.green,
|
||||
warn: chalk.yellow,
|
||||
error: chalk.red,
|
||||
debug: chalk.magenta,
|
||||
}
|
||||
|
||||
const consoleFormat = combine(
|
||||
errors({ stack: true }),
|
||||
timestamp({ format: timestampFormat }),
|
||||
printf(({ timestamp, level, message, filename }) => {
|
||||
const levelColour = levelColours[level] || chalk.white;
|
||||
|
||||
level = levelColour(level);
|
||||
timestamp = chalk.cyan(timestamp);
|
||||
message = chalk.white(message);
|
||||
filename = chalk.white(filename || "unknown")
|
||||
|
||||
return `[${level}] (${filename}) ${timestamp}: ${message}`;
|
||||
})
|
||||
);
|
||||
|
||||
const fileFormat = combine(
|
||||
errors({ stack: true }),
|
||||
timestamp({ format: timestampFormat }),
|
||||
printf(({ timestamp, level, message, filename }) => {
|
||||
return `[${level}] (${filename || "unknown"}) ${timestamp}: ${message}`;
|
||||
})
|
||||
);
|
||||
|
||||
const sessionTimestamp = new Date().toISOString()
|
||||
.replace(/T/, "_")
|
||||
.replace(/:/g, "-")
|
||||
.replace(/\..+/, "");
|
||||
const sessionLogFile = path.join(logFileDirectory, `${sessionTimestamp}.log`);
|
||||
|
||||
export const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || "info",
|
||||
levels: winston.config.syslog.levels,
|
||||
transports: [
|
||||
new winston.transports.Console({
|
||||
level: "debug",
|
||||
format: consoleFormat
|
||||
}),
|
||||
new winston.transports.File({
|
||||
filename: sessionLogFile,
|
||||
level: "info",
|
||||
format: fileFormat
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
cleanExpiredLogFiles();
|
||||
|
||||
export const getLogger = (file: string) => {
|
||||
return logger.child({ filename: path.basename(file) });
|
||||
}
|
98
src/server/controllers/guild/api/dt.module.ts
Normal file
98
src/server/controllers/guild/api/dt.module.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { Request, Response } from "express";
|
||||
import prisma, { Prisma } from "@server/prisma";
|
||||
import { AjaxData, AjaxResponse } from "datatables.net-dt";
|
||||
|
||||
type ModelDelegateFindManyArgs = {
|
||||
skip?: number;
|
||||
take?: number;
|
||||
orderBy?: any;
|
||||
where?: any;
|
||||
include?: any;
|
||||
};
|
||||
|
||||
type ModelDelegate = {
|
||||
findMany(args: ModelDelegateFindManyArgs): Promise<any[]>;
|
||||
count(args?: { where?: any }): Promise<number>;
|
||||
};
|
||||
|
||||
interface DatatableQuery extends AjaxData {
|
||||
filters: { [key: string]: any };
|
||||
}
|
||||
|
||||
export const datatableRequest = async <TOrderBy, TWhere>(
|
||||
request: Request,
|
||||
response: Response,
|
||||
model: ModelDelegate,
|
||||
defaultOrderBy: TOrderBy,
|
||||
include?: object,
|
||||
baseWhere?: object
|
||||
) => {
|
||||
const query = request.body as unknown as DatatableQuery;
|
||||
|
||||
const orderBy = query.order?.length
|
||||
? { [query.columns[query.order[0].column].data]: query.order[0].dir } as unknown as TOrderBy
|
||||
: defaultOrderBy;
|
||||
|
||||
let filterWhere = query.search?.value
|
||||
? {
|
||||
OR: Object.values(query.columns)
|
||||
.filter(col => col.searchable)
|
||||
.map(col => ({
|
||||
[col.data]: { contains: query.search.value }
|
||||
})) as TWhere
|
||||
}
|
||||
: {};
|
||||
|
||||
filterWhere = { ...filterWhere, ...baseWhere };
|
||||
|
||||
const data = await model.findMany({
|
||||
skip: query.start,
|
||||
take: query.length,
|
||||
orderBy: orderBy,
|
||||
where: filterWhere,
|
||||
include: include,
|
||||
});
|
||||
|
||||
const recordsFiltered = await model.count({ where: filterWhere });
|
||||
const recordsTotal = await model.count({ where: baseWhere });
|
||||
|
||||
response.json({
|
||||
data,
|
||||
recordsFiltered,
|
||||
recordsTotal,
|
||||
draw: query.draw,
|
||||
} as AjaxResponse);
|
||||
};
|
||||
|
||||
export const oldDatatable = async (request: Request, response: Response) => {
|
||||
const query = request.body as unknown as DatatableQuery;
|
||||
|
||||
const orderBy: Prisma.FeedOrderByWithRelationInput = query.order?.length
|
||||
? { [query.columns[query.order[0].column].data]: query.order[0].dir }
|
||||
: { id: "asc" };
|
||||
|
||||
const where: Prisma.FeedWhereInput = query.search?.value
|
||||
? {
|
||||
OR: Object.values(query.columns)
|
||||
.filter(col => col.searchable)
|
||||
.map(col => ({
|
||||
[col.data]: { contains: query.search.value }
|
||||
})) as Prisma.FeedWhereInput[]
|
||||
}
|
||||
: {};
|
||||
|
||||
const data = await prisma.feed.findMany({
|
||||
skip: query.start,
|
||||
take: query.length,
|
||||
orderBy: orderBy,
|
||||
where: where,
|
||||
include: { channels: true },
|
||||
});
|
||||
|
||||
response.json(<AjaxResponse>{
|
||||
data: data,
|
||||
recordsFiltered: await prisma.feed.count({ where: where }),
|
||||
recordsTotal: await prisma.feed.count(),
|
||||
draw: query.draw
|
||||
});
|
||||
};
|
@ -1,137 +1,140 @@
|
||||
import { Request, Response } from "express";
|
||||
import prisma, { Prisma } from "@server/prisma";
|
||||
import { datatableRequest } from "@server/controllers/guild/api/dt.module";
|
||||
import { getLogger } from "@server/../log";
|
||||
|
||||
const logger = getLogger(__filename);
|
||||
|
||||
export const get = async (request: Request, response: Response) => {
|
||||
logger.info(`Getting feed: ${request.query.id}`);
|
||||
|
||||
if (!request.query.id) {
|
||||
response.status(400).json({ error: "missing 'id' query" });
|
||||
response.status(400).json({ error: "Missing 'id' query" });
|
||||
return;
|
||||
}
|
||||
|
||||
const feed = await prisma.feed.findUnique({
|
||||
where: { id: Number(request.query.id) },
|
||||
include: { channels: true }
|
||||
include: { channels: true, filters: true }
|
||||
});
|
||||
|
||||
if (!feed) {
|
||||
response.status(404).json({ message: "no result found" });
|
||||
response.status(404).json({ message: "No result found" });
|
||||
return;
|
||||
}
|
||||
|
||||
response.json(feed);
|
||||
};
|
||||
|
||||
export const post = async (request: Request, response: Response) => {
|
||||
const guildId = request.params.guildId;
|
||||
const { name, url, active, channels } = request.body;
|
||||
const unpackChannels = (channels: string[] | string | undefined) => {
|
||||
if (channels === undefined) return channels;
|
||||
|
||||
let feed;
|
||||
|
||||
try {
|
||||
feed = await prisma.feed.create({
|
||||
data: {
|
||||
name: name,
|
||||
url: url,
|
||||
guild_id: guildId,
|
||||
active: active,
|
||||
channels: channels
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error);
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
response.status(500).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
response.status(201).json(feed);
|
||||
return Array.isArray(channels)
|
||||
? channels.map(channelId => ({ channel_id: channelId }))
|
||||
: [{ channel_id: channels }];
|
||||
};
|
||||
|
||||
export const patch = async () => {} // TODO ...
|
||||
const unpackFilters = (filters: string[] | string | undefined) => {
|
||||
if (filters === undefined) return filters;
|
||||
|
||||
return Array.isArray(filters)
|
||||
? filters.map(filterId => ({ id: Number(filterId) }))
|
||||
: [{ id: Number(filters) }]
|
||||
};
|
||||
|
||||
export const post = async (request: Request, response: Response) => {
|
||||
logger.info(`Posting feed: ${request.body.url} - ${request.params.guildId}`);
|
||||
|
||||
const body = {
|
||||
...request.body,
|
||||
active: request.body.active === "on",
|
||||
message_style: Number(request.body.message_style) || null,
|
||||
published_threshold: new Date(request.body.published_threshold)
|
||||
};
|
||||
|
||||
const createInputData: Prisma.FeedUncheckedCreateInput = {
|
||||
guild_id: request.params.guildId,
|
||||
name: body.name,
|
||||
url: body.url,
|
||||
active: body.active,
|
||||
channels: { create: unpackChannels(body.channels) },
|
||||
filters: { connect: unpackFilters(body.filters) },
|
||||
message_style_id: body.message_style,
|
||||
published_threshold: body.published_threshold
|
||||
};
|
||||
|
||||
try {
|
||||
const createResponse = await prisma.feed.create({ data: createInputData });
|
||||
response.status(201).json(createResponse);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
const isPrismaError = error instanceof Prisma.PrismaClientKnownRequestError;
|
||||
response.status(500).json({ error: isPrismaError ? error.message : error });
|
||||
}
|
||||
};
|
||||
|
||||
export const patch = async (request: Request, response: Response) => {
|
||||
logger.info(`Patching feed: ${request.body.id} - ${request.params.guildId}`);
|
||||
|
||||
const body = {
|
||||
...request.body,
|
||||
active: request.body.active === "on",
|
||||
message_style: Number(request.body.message_style) || null,
|
||||
published_threshold: new Date(request.body.published_threshold)
|
||||
};
|
||||
|
||||
const updateInputData: Prisma.FeedUncheckedUpdateInput = {
|
||||
id: Number(body.id),
|
||||
name: body.name,
|
||||
url: body.url,
|
||||
active: body.active,
|
||||
channels: { deleteMany: {}, create: unpackChannels(body.channels) },
|
||||
filters: { set: [], connect: unpackFilters(body.filters) },
|
||||
message_style_id: body.message_style,
|
||||
published_threshold: body.published_threshold
|
||||
};
|
||||
|
||||
try {
|
||||
const updateArgs = { where: { id: Number(body.id) }, data: updateInputData };
|
||||
const updateResponse = await prisma.feed.update(updateArgs);
|
||||
response.status(200).json(updateResponse);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
const isPrismaError = error instanceof Prisma.PrismaClientKnownRequestError;
|
||||
response.status(500).json({ error: isPrismaError ? error.message : error });
|
||||
}
|
||||
};
|
||||
|
||||
export const del = async (request: Request, response: Response) => {
|
||||
const { ids } = request.body;
|
||||
const guildId = request.params.guildId;
|
||||
logger.info(`Deleting feed(s): ${request.body.ids} - ${request.params.guildId}`);
|
||||
|
||||
if (!ids || !Array.isArray(ids)) {
|
||||
response.status(400).json({ error: "invalid request body" });
|
||||
const ids = request.body.ids?.map((id: string) => Number(id));
|
||||
|
||||
if (!ids) {
|
||||
response.status(400).json({ error: `Couldn't parse ID's from request body` });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.feed.deleteMany({ where: {
|
||||
id: { in: ids },
|
||||
guild_id: guildId
|
||||
}});
|
||||
const deleteArgs = { where: { guild_id: request.params.guildId, id: { in: ids } } };
|
||||
await prisma.feed.deleteMany(deleteArgs);
|
||||
response.status(204).send();
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
const isPrismaError = error instanceof Prisma.PrismaClientKnownRequestError;
|
||||
response.status(500).json({ error: isPrismaError ? error.message : error });
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error);
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
response.status(500).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
response.status(204).json(null);
|
||||
};
|
||||
|
||||
interface DataTableResponse {
|
||||
data: any;
|
||||
recordsFiltered: number;
|
||||
recordsTotal: number;
|
||||
}
|
||||
|
||||
interface DatatableQuery {
|
||||
length: string;
|
||||
start: string;
|
||||
order: { column: string; dir: string }[];
|
||||
columns: { [key: string]: { data: string; searchable: string }};
|
||||
search: { value: string };
|
||||
filters: { [key: string]: any };
|
||||
}
|
||||
|
||||
export const datatable = async (request: Request, response: Response) => {
|
||||
const query = request.query as unknown as DatatableQuery;
|
||||
|
||||
const size: number = Number(query.length) || 10;
|
||||
const start: number = Number(query.start);
|
||||
const order: string = (query.order && query.columns[query.order[0].column].data) || "id";
|
||||
const direction: string = (query.order && query.order[0].dir) || "asc";
|
||||
const search: string = query.search?.value || "";
|
||||
|
||||
let dbQuery: any = {};
|
||||
|
||||
// TODO: filter request
|
||||
|
||||
if (search) {
|
||||
Object.values(query.columns)
|
||||
.filter(column => column.searchable === "true")
|
||||
.forEach((col: any) => {
|
||||
dbQuery["where"][col.data] = {
|
||||
contains: search,
|
||||
mode: "insensitive"
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const orderBy: any = {};
|
||||
orderBy[order] = direction;
|
||||
|
||||
const data = await prisma.feed.findMany({
|
||||
...dbQuery,
|
||||
skip: start,
|
||||
take: size,
|
||||
orderBy: orderBy,
|
||||
include: { channels: true }
|
||||
});
|
||||
|
||||
response.json(<DataTableResponse>{
|
||||
data: data,
|
||||
recordsFiltered: await prisma.feed.count({...dbQuery}),
|
||||
recordsTotal: await prisma.feed.count()
|
||||
});
|
||||
return await datatableRequest(
|
||||
request,
|
||||
response,
|
||||
prisma.feed,
|
||||
[{ 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
|
||||
);
|
||||
};
|
||||
|
||||
export default { get, post, patch, del, datatable };
|
145
src/server/controllers/guild/api/filter.controller.ts
Normal file
145
src/server/controllers/guild/api/filter.controller.ts
Normal file
@ -0,0 +1,145 @@
|
||||
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" });
|
||||
return;
|
||||
}
|
||||
|
||||
const filter = await prisma.filter.findUnique({
|
||||
where: { id: Number(request.query.id) }
|
||||
});
|
||||
|
||||
if (!filter) {
|
||||
response.status(404).json({ message: "no result found" });
|
||||
return;
|
||||
}
|
||||
|
||||
response.json(filter);
|
||||
};
|
||||
|
||||
export const post = async (request: Request, response: Response) => {
|
||||
const guildId = request.params.guildId;
|
||||
const { name, value, matching_algorithm, is_insensitive, is_whitelist } = request.body;
|
||||
|
||||
let filter;
|
||||
|
||||
try {
|
||||
filter = await prisma.filter.create({
|
||||
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 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) => {
|
||||
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.filter.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.filter,
|
||||
[{ updated_at: "desc" }, { id: "asc" }],
|
||||
{},
|
||||
{ guild_id: request.params.guildId }
|
||||
);
|
||||
};
|
||||
|
||||
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 };
|
176
src/server/controllers/guild/api/style.controller.ts
Normal file
176
src/server/controllers/guild/api/style.controller.ts
Normal 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 };
|
@ -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
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -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
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -2,14 +2,5 @@ import { PrismaClient, Prisma } from "@server/../../generated/prisma";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// const resolvePrismaCodeToHttpCode = (error: Prisma.PrismaClientKnownRequestError) => {
|
||||
// switch (error.code) {
|
||||
// case "P2011":
|
||||
// return 0;
|
||||
// default:
|
||||
// throw new Error(`Unhandled prisma error code: '${error.code}'`);
|
||||
// }
|
||||
// }
|
||||
|
||||
export { Prisma };
|
||||
export default prisma;
|
||||
export default prisma;
|
||||
|
@ -6,6 +6,8 @@ import styleController from "@server/controllers/guild/style.controller";
|
||||
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();
|
||||
|
||||
@ -24,10 +26,24 @@ router.get("/:guildId/content", contentController.get);
|
||||
|
||||
// API routes
|
||||
|
||||
router.get("/:guildId/feeds/api/datatable", feedApiController.datatable);
|
||||
router.post("/:guildId/feeds/api/datatable", feedApiController.datatable);
|
||||
router.get("/:guildId/feeds/api", feedApiController.get);
|
||||
router.post("/:guildId/feeds/api", feedApiController.post);
|
||||
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;
|
@ -2,7 +2,8 @@
|
||||
module.exports = {
|
||||
darkMode: "selector",
|
||||
content: [
|
||||
"./src/client/**/*.{html,js,ejs,ts}",
|
||||
"./src/client/src/**/*.{js,ts}",
|
||||
"./src/client/views/**/*.{html,ejs}",
|
||||
"./node_modules/preline/dist/*.js"
|
||||
],
|
||||
theme: {
|
||||
|
@ -25,6 +25,7 @@
|
||||
},
|
||||
"include": [
|
||||
"./src/*",
|
||||
"./src/server/**/*"
|
||||
"./src/server/**/*",
|
||||
"./src/bot/**/*"
|
||||
]
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user