Compare commits

..

160 Commits

Author SHA1 Message Date
22c35ae1da chore: remove useless log
All checks were successful
Build & Push Docker Image / build (push) Successful in 22s
Test & Build / build (push) Successful in 30s
2025-05-20 17:20:23 +01:00
ec2f62ab3b chore: child loggers only show filename without path now
Some checks failed
Test & Build / build (push) Has been cancelled
Build & Push Docker Image / build (push) Has been cancelled
2025-05-20 17:20:10 +01:00
dc3deffb32 refactor(api): cleaned the feed api code to be more maintainable, and added logging
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
Test & Build / build (push) Has been cancelled
2025-05-20 17:19:42 +01:00
99e1b0ef96 fix(actions): correct poorly written build command
All checks were successful
Build & Push Docker Image / build (push) Successful in 22s
Test & Build / build (push) Successful in 34s
2025-05-17 19:13:18 +01:00
93d5d29aab build: docker image workflow
Some checks failed
Build & Push Docker Image / build (push) Failing after 3s
Test & Build / build (push) Successful in 30s
2025-05-17 19:00:38 +01:00
9b07cc358b revert(logs): remove request logging middleware
All checks were successful
Build / build (push) Successful in 31s
2025-05-17 18:35:44 +01:00
50d3f1d0a8 build: add dockerfile
All checks were successful
Build / build (push) Successful in 34s
2025-05-17 00:17:38 +01:00
8dd702515d chore: fix incorrect env var defaults for 'host' & 'port'
All checks were successful
Build / build (push) Successful in 31s
2025-05-17 00:16:14 +01:00
5303d81b19 fix: fix bad logger import
All checks were successful
Build / build (push) Successful in 30s
2025-05-17 00:08:28 +01:00
d2a050e654 chore: include package-log.json
All checks were successful
Build / build (push) Successful in 30s
2025-05-16 23:46:20 +01:00
5ad695059e feat: add proper logging with winston
All checks were successful
Build / build (push) Successful in 59s
2025-05-16 23:22:04 +01:00
05359dedcb test(bot): write tests for fuzzy filter
All checks were successful
Build / build (push) Successful in 57s
2025-05-15 22:15:22 +01:00
99ebb9a578 chore: resolve all eslint errors
All checks were successful
Build / build (push) Successful in 1m4s
2025-05-15 21:33:57 +01:00
c4247bf2a6 chore: add linting
All checks were successful
Build / build (push) Successful in 1m0s
2025-05-15 17:18:34 +01:00
755bf32774 feat(bot): implement 'literal' filter
All checks were successful
Build / build (push) Successful in 51s
2025-05-15 17:12:33 +01:00
d6a014ec91 test: run tests after build to capture build env
All checks were successful
Build / build (push) Successful in 48s
2025-05-15 12:19:51 +01:00
8f6ce8867c over specify test name for 'all' filter
All checks were successful
Build / build (push) Successful in 49s
2025-05-15 12:17:29 +01:00
86e4f9d0d6 run workflow on bot branch
All checks were successful
Build / build (push) Successful in 57s
2025-05-15 12:15:50 +01:00
31ad266389 test: run tests in build workflow 2025-05-15 12:15:21 +01:00
3f58496fa0 test(bot): write test for 'all' filter 2025-05-15 12:14:29 +01:00
540de53cd0 feat(bot): implement 'all' filter 2025-05-15 12:14:15 +01:00
3feb464097 test(bot): write tests for ANY filter 2025-05-15 10:32:40 +01:00
cda89f824e test(bot): implement tests on the regex filter using jest 2025-05-15 01:18:46 +01:00
f294a751dc feat(bot): regex filter implementation 2025-05-15 01:17:44 +01:00
72fe545211 feat(bot): implement filtering on published threshold param 2025-05-15 01:17:13 +01:00
c99b6d6a46 docs: add readme listing environment variables 2025-05-13 18:48:27 +01:00
389ea6cf34 note 2025-05-13 16:58:59 +01:00
81dcf325c3 working on filters 2025-05-13 16:57:39 +01:00
f871a1d847 chore: include bot files 2025-05-13 16:57:23 +01:00
cf8713c1bb feat(bot): boilerplate for adding interaction commands and event listeners 2025-05-13 16:57:07 +01:00
55c6a7125a Merge branch 'master' into bot 2025-05-13 11:09:17 +01:00
13b736ed8b test(actions): fix test db connection during build action
All checks were successful
Build / build (push) Successful in 41s
2025-05-13 10:53:06 +01:00
82195c7030 Why can't I just test this locally, why does it require a push, oh why god, why
All checks were successful
Build / build (push) Successful in 44s
2025-05-13 10:49:50 +01:00
a33ba96115 AAAAAAAAAAAAAAAAAHHHHHHHH
Some checks failed
Build / build (push) Failing after 33s
2025-05-13 10:44:25 +01:00
61771af94f testing pain
Some checks failed
Build / build (push) Failing after 34s
2025-05-13 10:43:12 +01:00
07aac70d4b headbanging right now
Some checks failed
Build / build (push) Failing after 31s
2025-05-13 10:35:18 +01:00
e320a1d958 build.yaml
Some checks failed
Build / build (push) Failing after 30s
2025-05-13 10:23:59 +01:00
79b79382e2 build.yaml
Some checks failed
Build / build (push) Failing after 38s
2025-05-13 10:21:43 +01:00
0ce68139e1 change build.yaml
Some checks failed
Build / build (push) Failing after 30s
2025-05-13 10:18:32 +01:00
851467ab90 Merge branch 'master' of https://gitea.cor.bz/corbz/relay
Some checks failed
Build / build (push) Failing after 36s
2025-05-13 10:14:18 +01:00
524bb4fc02 chore: fix db con string in build.yaml 2025-05-13 10:13:14 +01:00
ba286e769b bot filter work 2025-05-12 23:45:37 +01:00
0297fb12b6 working on rss processing 2025-05-12 17:25:41 +01:00
fb011e80c2 chore: remove commented code from prisma.ts
Some checks failed
Build / build (push) Failing after 33s
2025-05-12 16:38:10 +01:00
fb76266250 fix: style colour being reset on edit modal
Some checks failed
Build / build (push) Failing after 37s
2025-05-12 11:24:31 +01:00
cc845d3adc feat: display colour preview in style table
Some checks failed
Build / build (push) Failing after 34s
2025-05-12 11:17:54 +01:00
c0ddec1c71 fix: feed table - pointer events on 'style' header despite lack of ordering
Some checks failed
Build / build (push) Failing after 40s
2025-05-11 18:46:04 +01:00
e5f04a2c7d fix: style table search broken due to searching on unsearchable columns
Some checks failed
Build / build (push) Failing after 36s
2025-05-11 18:36:08 +01:00
dc0a4a9be0 style: add missing semicolon
Some checks failed
Build / build (push) Failing after 29s
2025-05-11 18:22:55 +01:00
1d1f7005ed chore: remove commented code 2025-05-11 18:22:39 +01:00
d1957faf95 refactor: styles page client-side code
Some checks failed
Build / build (push) Failing after 33s
2025-05-11 18:22:10 +01:00
186e1dbf93 chore: remove unused packages
Some checks failed
Build / build (push) Failing after 41s
2025-05-11 18:18:30 +01:00
fc24f05903 refactor: filters client-side code
Some checks failed
Build / build (push) Failing after 45s
2025-05-09 17:17:32 +01:00
9b6eb86cd8 fix: feed page - missing ordering params and row select functionality
Some checks failed
Build / build (push) Failing after 45s
2025-05-09 16:41:54 +01:00
6b77d062f0 chore(release): 0.1.5
Some checks failed
Build / build (push) Failing after 36s
2025-05-09 15:56:46 +01:00
31c4779bf2 refactor: entire feeds front-end js
Some checks failed
Build / build (push) Has been cancelled
2025-05-09 15:56:30 +01:00
816da70229 fix: parse publish threshold on post/patch
Some checks failed
Build / build (push) Has been cancelled
2025-05-09 15:55:55 +01:00
9423a0f1ce working on proper client packing
Some checks failed
Build / build (push) Failing after 57s
2025-05-08 22:14:55 +01:00
d6382347d0 trying to figure out preline implementation
Some checks failed
Build / build (push) Failing after 32s
2025-05-08 01:16:06 +01:00
8f1ee46d6d build db step replacement (needs env var)
Some checks failed
Build / build (push) Has been cancelled
2025-05-08 01:15:30 +01:00
6761a9163b feat: publish threshold field on Feed model 2025-05-08 01:14:35 +01:00
bab3759423 chore: remove outdated seeds
Some checks failed
Build / build (push) Failing after 48s
2025-05-08 01:14:00 +01:00
79b76c3b58 fix bad import path
Some checks failed
Build / build (push) Failing after 40s
2025-05-06 18:09:30 +01:00
faaaaf6ac7 style: add missing semicolons 2025-05-06 18:09:05 +01:00
676885a004 chore: remove unused div
Some checks failed
Build / build (push) Has been cancelled
2025-05-06 18:08:44 +01:00
9f71c9c29e colours plus select height fix
Some checks failed
Build / build (push) Failing after 54s
2025-05-06 17:03:49 +01:00
3ac33dc00a fix: error when searching filters (algorithm isn't searchable via API)
Some checks failed
Build / build (push) Failing after 48s
2025-05-06 16:35:02 +01:00
d5af04c317 feat: select message style on feed
Some checks failed
Build / build (push) Failing after 52s
2025-05-06 16:19:25 +01:00
0dd928b8f4 working on logger
Some checks failed
Build / build (push) Failing after 54s
2025-05-05 23:39:22 +01:00
badd232d3d chore: shorten style table headers and use a unique icon for table 'no results found'.
Some checks failed
Build / build (push) Failing after 48s
2025-05-05 23:20:09 +01:00
6356bb1d06 feat(db): connect message style to feed 2025-05-05 23:19:23 +01:00
e9807ee6f6 feat: colour picker on message style edit modal
Some checks failed
Build / build (push) Failing after 40s
2025-05-05 23:06:05 +01:00
99a59c61e7 fix(api): style mutators causing errors if blank
Some checks failed
Build / build (push) Failing after 37s
2025-05-05 23:03:51 +01:00
200716988c style colour length to 7 from 6 (2)
Some checks failed
Build / build (push) Has been cancelled
2025-05-05 23:03:05 +01:00
a42e353aa4 style colour length to 7 from 6 2025-05-05 23:02:47 +01:00
cfde210a39 fix: mistakenly broken class on the style table delete button 2025-05-05 18:57:42 +01:00
be03788cfc feat: added styles page table and edit modal
Some checks failed
Build / build (push) Failing after 38s
2025-05-05 18:51:28 +01:00
2d8a26f392 fix: incorrect 'for' value being used on the editModal's 'value' label.
Some checks failed
Build / build (push) Has been cancelled
2025-05-05 18:50:48 +01:00
a1bd362799 feat(api): added message style api endpoints 2025-05-05 18:50:10 +01:00
6d1f4e6f7f feat: migrate from sqlite to postgresql, and add message_style model
Some checks failed
Build / build (push) Failing after 52s
2025-05-05 17:05:59 +01:00
b528153113 feat: create and update feeds with filters 2025-05-05 15:10:31 +01:00
e935d801e6 feat: filter selection and table render for feed view
All checks were successful
Build / build (push) Successful in 48s
2025-05-05 14:41:53 +01:00
ff992fefa7 chore: remove large "unused" preline import on main.ts
All checks were successful
Build / build (push) Successful in 46s
2025-05-05 14:40:26 +01:00
989f93addf build: minify client-side js on prod
Some checks failed
Build / build (push) Has been cancelled
2025-05-05 14:39:33 +01:00
a0d2711a51 fix: ID not being reset when creating new feeds, causing unintentional edits over existing feeds
All checks were successful
Build / build (push) Successful in 39s
2025-05-02 13:43:59 +01:00
73aed35ce0 feat: completed filter table and edit modal
All checks were successful
Build / build (push) Successful in 42s
2025-05-02 13:43:02 +01:00
e58d7343b1 feat(api): Functional API endpoints for filters 2025-05-02 13:42:13 +01:00
2259e3229a chore: remove commented styles
All checks were successful
Build / build (push) Successful in 47s
2025-05-02 13:41:39 +01:00
78fed2b2a3 chore: remove shadow from algorithm select 2025-05-02 13:39:14 +01:00
fa42eb3551 feat: css shorthands for generic select toggle
All checks were successful
Build / build (push) Successful in 53s
2025-05-02 13:31:29 +01:00
e4ab506abe fix(api): patching feeds would duplicate channels #4
All checks were successful
Build / build (push) Successful in 52s
2025-05-02 11:13:28 +01:00
2589cabec6 feat: delete selected feed rows
All checks were successful
Build / build (push) Successful in 58s
2025-05-01 20:38:25 +01:00
00a4c749f0 fix(api): expected ids to be integers, not strings
All checks were successful
Build / build (push) Successful in 57s
2025-05-01 20:37:19 +01:00
9d79d8dbef chore: add migration
Some checks failed
Build / build (push) Has been cancelled
2025-05-01 20:36:41 +01:00
79e331bdb9 fix(database): cascade channels on feed delete
Some checks failed
Build / build (push) Has been cancelled
2025-05-01 20:36:24 +01:00
dda5461a0d working on delete button
All checks were successful
Build / build (push) Successful in 59s
2025-05-01 19:33:46 +01:00
300041ec49 cleanup build action file
All checks were successful
Build / build (push) Successful in 53s
2025-05-01 14:34:18 +01:00
a768059e74 build test
All checks were successful
Build / build (push) Successful in 39s
2025-05-01 14:29:52 +01:00
e6c641575e set env var before db process
Some checks failed
Build / build (push) Failing after 47s
2025-05-01 14:26:45 +01:00
40a0211609 build(api): include database in test build process
Some checks failed
Build / build (push) Failing after 43s
2025-05-01 14:20:50 +01:00
671b1856c6 build(actions): add checkout to build action
All checks were successful
Build / build (push) Successful in 56s
2025-05-01 13:03:15 +01:00
3da72482ba build(actions): added action to test build process
Some checks failed
Build / build (push) Failing after 51s
2025-05-01 13:01:16 +01:00
22e252ce53 docs: add 'author', 'license' and 'description' values to package.json 2025-05-01 12:49:58 +01:00
179d6e3b3b chore: mute css lint warnings for tailwindcss rules 2025-05-01 12:46:16 +01:00
6b6af17731 fix: datatable count including entries from other guilds 2025-05-01 12:45:42 +01:00
7ef8b88aab chore: fix broken syntax 2025-05-01 12:35:03 +01:00
2a88e1c184 fix: channels select dropdown height breaking on smaller screens 2025-05-01 12:33:33 +01:00
84772852e3 feat: patch existing feeds 2025-05-01 12:32:32 +01:00
23ca09b4a2 chore(release): 0.1.4 2025-05-01 00:15:07 +01:00
b4aac14fb3 chore: until dark mode is added - default to dark 2025-05-01 00:14:54 +01:00
77c8d39142 fix: redraw feed datatable when new entries are created 2025-05-01 00:14:27 +01:00
ce51623637 fix(api): feed push fails if channels is undefined (left blank in UI) 2025-05-01 00:04:29 +01:00
a856925ab4 chore: temp disable 2025-04-30 23:59:35 +01:00
d8141e485c feat: functional 'create' modal for feed records 2025-04-30 23:59:20 +01:00
e0cb99974f chore: include preline dependency in main client file 2025-04-30 23:58:34 +01:00
9896f8e094 fix(api): correctly handle arguments for channels and active 2025-04-30 23:57:55 +01:00
acde6e1bbb chore: safe exit for database connection on SIGINT 2025-04-30 23:57:05 +01:00
054cb6c017 build: database indexes for guild_id 2025-04-30 23:55:36 +01:00
95c55f3ba7 build: add migration push and reset commands 2025-04-30 11:44:57 +01:00
8aedc84280 refactor: rearrange files for postcss & made custom preline variants.css to work with postcss 2025-04-30 11:31:55 +01:00
5e8fc3c8aa build: change to use postcss over tailwind/cli. 2025-04-30 11:30:00 +01:00
ad268096f3 chore: remove old devnote.txt file 2025-04-30 00:28:07 +01:00
1e5c8a821e chore: some interfaces for client-side... might remove if not usefull 2025-04-29 17:09:52 +01:00
39e67c1088 refactor: major refactor of client-side public generated code 2025-04-29 17:09:23 +01:00
db5178fef0 fix: include filters on feed table 2025-04-29 14:09:11 +01:00
5323009fd8 feat: util for verifying feed channels 2025-04-29 12:27:47 +01:00
637415b8ca feat: allow a custom 'where' clause parameter for datatable queries. 2025-04-29 12:27:19 +01:00
31b9063365 fix: replace unusable <td> renders with className parameters for col configs. 2025-04-29 12:26:25 +01:00
3acd08d922 refactor: move inline class names to css shorthand classes 2025-04-29 12:25:19 +01:00
6ff4bacddf feat: add filters guild page with table (missing full features) 2025-04-29 00:03:06 +01:00
0a5d32e6e8 chore: add placeholder edit modal 2025-04-29 00:01:53 +01:00
16134e1719 buid: alter database structure and migrations - add filters 2025-04-29 00:00:57 +01:00
60cb0083f5 chore: remove unused code + delete existing feeds before seeding 2025-04-28 23:57:06 +01:00
3774c0b6db build: shorthand commands for prisma format and prisma studio 2025-04-28 23:55:37 +01:00
9b5913cd77 feat: add '15' as a page size option for tables 2025-04-28 10:40:31 +01:00
1edc1d4016 feat: tag design for status and channels in feed table 2025-04-28 10:33:55 +01:00
336484c13a chore: include dummy channels with seeds 2025-04-28 10:31:04 +01:00
12dff02c6f refactor: formatTimestamp func now checks against current year, rather than year to date 2025-04-28 10:29:10 +01:00
f8724162ad feat: basic search functionality for feed table 2025-04-26 22:52:03 +01:00
48cd87749e chore(release): 0.1.3 2025-04-26 16:23:10 +01:00
07b163a674 feat: page size selector for feed table 2025-04-26 16:19:26 +01:00
6cdef67850 feat: preline select box for table pagination 2025-04-24 17:04:35 +01:00
3b9368dc22 chore: added some fake Feed data for testing - more robust testing will obsolete this in the future 2025-04-24 12:43:32 +01:00
87da78286c feat: added footer with pagination to feed table 2025-04-24 12:41:59 +01:00
c2d11120b0 build: added generate command for prisma orm 2025-04-24 12:41:21 +01:00
8450a9a7eb chore: working on datatables implementation for feeds 2025-04-24 01:36:39 +01:00
10d55be62d fix(api): include channels in datatable response for feeds 2025-04-23 14:30:25 +01:00
130fa63f69 fix: errors with feed datatables api endpoint 2025-04-23 14:09:59 +01:00
98b9bb1fba fix: new feed api endpoints using bad regex 2025-04-23 14:09:15 +01:00
911c213236 chore: remove unused feed api file 2025-04-23 12:15:21 +01:00
95124ec6e5 feat: exposed api routes for Feed objects 2025-04-23 12:13:14 +01:00
7ff73fae28 build: enable 'noEmplicitAny' in tsconfg 2025-04-23 12:12:16 +01:00
d03dc8ed90 feat: add API controller new Feed models 2025-04-22 23:39:54 +01:00
c9c22bd2ac ops: added data models for Feed & Channel objects 2025-04-22 23:39:19 +01:00
1c7ffa970a chore: remove blank test script 2025-04-22 23:37:55 +01:00
6f820fdcfc fix: missing preline dependencies 2025-04-22 20:57:35 +01:00
5e0ba9a9e6 chore: remove unused config line 2025-04-22 20:56:04 +01:00
d50a929fa9 chore(release): 0.1.2 2025-04-22 17:07:28 +01:00
1f3ec703eb chore: config changes 2025-04-22 17:07:11 +01:00
78ab28e73c feat: basic table on feeds view 2025-04-22 17:06:43 +01:00
64 changed files with 17358 additions and 148 deletions

12
.dockerignore Normal file
View 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/

View 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

View 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
View File

@ -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
View File

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

View File

@ -2,6 +2,89 @@
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)
### Features
* add API controller new Feed models ([d03dc8e](https://gitea.cor.bz/corbz/relay/commit/d03dc8ed9024424c9b92d957a86b2f93dd7c7724))
* added footer with pagination to feed table ([87da782](https://gitea.cor.bz/corbz/relay/commit/87da78286c951105626092415bec0714839ca335))
* exposed api routes for Feed objects ([95124ec](https://gitea.cor.bz/corbz/relay/commit/95124ec6e5f5c4fabbe2f7facb71f81f426aff56))
* page size selector for feed table ([07b163a](https://gitea.cor.bz/corbz/relay/commit/07b163a67440f41fe92a2badcb0b84bff9f972ff))
* preline select box for table pagination ([6cdef67](https://gitea.cor.bz/corbz/relay/commit/6cdef67850fce24b9d75013161c262e50e176e77))
### Bug Fixes
* **api:** include channels in datatable response for feeds ([10d55be](https://gitea.cor.bz/corbz/relay/commit/10d55be62d36189ab0f191aa8f1a6561c0f1374e))
* errors with feed datatables api endpoint ([130fa63](https://gitea.cor.bz/corbz/relay/commit/130fa63f69af73279c8489491a1829b6e79f0b0b))
* missing preline dependencies ([6f820fd](https://gitea.cor.bz/corbz/relay/commit/6f820fdcfc4713c6768b9d85c04477e2fa05cc39))
* new feed api endpoints using bad regex ([98b9bb1](https://gitea.cor.bz/corbz/relay/commit/98b9bb1fba8a00a8f2d23b70ee422018a5cbf281))
### [0.1.2](https://gitea.cor.bz/corbz/relay/compare/v0.1.1...v0.1.2) (2025-04-22)
### Features
* basic table on feeds view ([78ab28e](https://gitea.cor.bz/corbz/relay/commit/78ab28e73c8df021ce88b6f1ac9dbb5b46dce84b))
### [0.1.1](https://gitea.cor.bz/corbz/relay/compare/v0.1.0...v0.1.1) (2025-04-22)

15
Dockerfile Normal file
View 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
View 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.|||

View File

@ -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

View File

@ -1,19 +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,
// outBase: "",
outdir: "./src/client/public/bundles",
outdir: "./src/client/public/generated/js",
bundle: true,
target: ["es6"],
format: "iife",
sourcemap: false,
loader: {".ts": "ts"},
minify: true
sourcemap: false, // !isProdEnv,
minify: isProdEnv,
keepNames: !isProdEnv
}).catch(() => process.exit(1));

34
eslint.config.mjs Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@ -1,57 +1,85 @@
{
"name": "relay",
"version": "0.1.1",
"version": "0.1.5",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"test": "jest",
"start": "node ./dist/app.js",
"dev": "npx ts-node -r tsconfig-paths/register ./src/app.ts",
"dryrun": "npx ts-node -r tsconfig-paths/register ./src/app.ts",
"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": {
"@preline/datatable": "^3.0.0",
"@preline/dropdown": "^3.0.1",
"@floating-ui/dom": "^1.6.13",
"@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
View File

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
module.exports = {
plugins: [
require("postcss-import"),
require("@tailwindcss/postcss"),
require("autoprefixer")
]
}

View File

@ -1,5 +0,0 @@
-- CreateTable
CREATE TABLE "TestModel" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT
);

View File

@ -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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,7 +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[]
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], 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
}

14
prisma/seed.ts Normal file
View File

@ -0,0 +1,14 @@
import { PrismaClient } from "../generated/prisma";
const client = new PrismaClient();
async function main() { }
main()
.then(async () => { await client.$disconnect() })
.catch(async (error: unknown) => {
console.log(error);
await client.$disconnect();
process.exit(1);
});

View File

@ -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..."

View File

@ -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);
});
});

View 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: "Bidens 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: "Bidens 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);
});

View File

@ -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();

View 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");
}
}

View 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();
}
}

View 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
View 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
View 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}`);
}
};

View 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)
);
}
}
}

View 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 }
)
}
}
}

View 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] });
}
}

View 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
View 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;
};

View 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;

View File

@ -1,60 +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;
}
/* 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
View 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; }

View 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;
}
}

View 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

View 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

View 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

79
src/client/src/ts/main.ts Normal file
View File

@ -0,0 +1,79 @@
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');
inputs.forEach(input => {
(input as HTMLInputElement).addEventListener("keydown", (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key === "a") {
(event.target as HTMLInputElement).select();
}
});
});
});
/**
* Formats a given timestamp to one of two formats depending on its age.
* @param timestamp
* @returns 'DD MMM, HH:mm' if younger than 1 year, else 'DD MMM YYYY'
*/
export const formatTimestamp = (timestamp: string | number) => {
const date = new Date(
typeof timestamp === "string"
? timestamp.replace(" ", "T")
: timestamp
);
// 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 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
View File

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

View File

@ -1,12 +1,18 @@
{
"compilerOptions": {
"target": "ES5",
"outDir": "./public/js",
"rootDir": "./typescript",
"outDir": "./public/generated/js",
"rootDir": "./src/ts",
"baseUrl": ".",
"sourceMap": false,
"esModuleInterop": true
"esModuleInterop": true,
"noImplicitAny": true,
"typeRoots": [
"./src/types"
]
},
"include": [
"./typescript/**/*"
"./src/ts/**/*",
"./src/types"
]
}

View File

@ -1,3 +0,0 @@
$(document).ready(() => {
console.log("ready!");
});

View File

@ -2,4 +2,246 @@
<%- include("header") -%>
Feeds 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 feed</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" 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" 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" class="cj-table-header --exclude-from-ordering">
<div class="cj-table-header-content">
<span>Channels</span>
</div>
</th>
<th scope="col" class="cj-table-header --exclude-from-ordering">
<div class="cj-table-header-content">
<span>Filters</span>
</div>
</th>
<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" 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>
</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">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 &amp; time.
</p>
<p class="mt-2 text-sm text-red-500 hidden peer-invalid:group-[.submitted]:block">
Please enter a date.
</p>
</div>
<div>
<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>'); %>

View File

@ -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>'); %>

View File

@ -2,4 +2,312 @@
<%- include("header") -%>
Styles page placeholder
<div id="table" class="--prevent-on-load-init max-w-full px-4 sm:px-6">
<div class="flex flex-col">
<div class="-m-1.5">
<div class="max-w-full min-w-full p-1.5 inline-block align-middle">
<div class="bg-white border border-gray-200 rounded-lg shadow-xs dark:bg-neutral-900 dark:border-neutral-700">
<!-- Header -->
<div class="px-6 py-4 gap-3 flex flex-nowrap justify-between items-center">
<div class="hidden sm:block sm:col-span-1">
<label for="search" class="sr-only">Search</label>
<div class="relative">
<input type="text" id="search" class="form-input px-3 ps-11 block w-full border-gray-200 rounded-lg text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 dark:placeholder-neutral-500 dark:focus:ring-neutral-600" placeholder="Search" data-hs-datatable-search="">
<div class="absolute inset-y-0 start-0 flex items-center pointer-events-none ps-4">
<svg class="shrink-0 size-4 text-gray-400 dark:text-neutral-500" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
</div>
</div>
</div>
<div class="sm:col-span-2">
<button type="button" id="deleteRowsBtn" disabled class="py-2 px-3 inline-flex items-center gap-x-2 text-sm font-medium rounded-lg border border-gray-200 bg-white text-red-500 shadow-xs hover:bg-gray-50 focus:outline-hidden focus:bg-gray-50 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-900 dark:border-neutral-700 dark:hover:bg-neutral-800 dark:focus:bg-neutral-800" href="#">
<svg class="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
<span>
<span class="hidden sm:inline">Delete</span>
<span class="rows-selected-count-js zero-empty-js before:content-['('] after:content-[')'] empty:hidden"></span>
</span>
</button>
<button type="button" class="open-edit-modal-js py-2 px-3 inline-flex items-center gap-x-2 text-sm font-medium rounded-lg border border-transparent bg-blue-600 text-white hover:bg-blue-700 focus:outline-hidden focus:bg-blue-700 disabled:opacity-50 disabled:pointer-events-none">
<svg class="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
<span>
Create
<span class="hidden sm:inline">a style</span>
</span>
</button>
</div>
</div>
<!-- Table -->
<div class="min-w-full overflow-x-auto">
<table class="cj-table">
<thead class="cj-thead">
<tr>
<th scope="col" class="cj-table-header --exclude-from-ordering">
<label for="selectAllBox" class="flex">
<input type="checkbox" id="selectAllBox" class="cj-table-checkbox">
<span class="sr-only">Checkbox</span>
</label>
</th>
<th scope="col" data-dt-column="name" class="cj-table-header">
<div class="cj-table-header-content cursor-pointer">
<span>Name</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m7 15 5 5 5-5"></path><path d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
<th scope="col" data-dt-column="colour" class="cj-table-header">
<div class="cj-table-header-content cursor-pointer">
<span>Colour</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m7 15 5 5 5-5"></path><path d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
<th scope="col" data-dt-column="title_mutator" class="cj-table-header">
<div class="cj-table-header-content cursor-pointer">
<span>Title Mutator</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m7 15 5 5 5-5"></path><path d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
<th scope="col" data-dt-column="description_mutator" class="cj-table-header">
<div class="cj-table-header-content cursor-pointer">
<span>Description Mutator</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m7 15 5 5 5-5"></path><path d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
<th scope="col" data-dt-column="show_author" class="cj-table-header">
<div class="cj-table-header-content cursor-pointer">
<span>Author</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m7 15 5 5 5-5"></path><path d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
<th scope="col" data-dt-column="show_image" class="cj-table-header">
<div class="cj-table-header-content cursor-pointer">
<span>Image</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m7 15 5 5 5-5"></path><path d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
<th scope="col" data-dt-column="show_thumbnail" class="cj-table-header">
<div class="cj-table-header-content cursor-pointer">
<span>Thumbnail</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m7 15 5 5 5-5"></path><path d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
<th scope="col" data-dt-column="show_footer" class="cj-table-header">
<div class="cj-table-header-content cursor-pointer">
<span>Footer</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m7 15 5 5 5-5"></path><path d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
<th scope="col" data-dt-column="show_timestamp" class="cj-table-header">
<div class="cj-table-header-content cursor-pointer">
<span>Timestamp</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m7 15 5 5 5-5"></path><path d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
<th scope="col" data-dt-column="created_at" class="cj-table-header">
<div class="cj-table-header-content cursor-pointer">
<span>Created at</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m7 15 5 5 5-5"></path><path d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
</tr>
</thead>
</table>
</div>
<div class="cj-table-footer">
<div class="max-w-sm space-y-3">
<select id="selectPageSize-js" data-hs-datatable-page-entities="">
<option value="5">5</option>
<option value="10" selected>10</option>
<option value="15">15</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
<div class="sm:inline-flex items-center gap-x-2" data-hs-datatable-info="">
<p class="text-sm text-gray dark:text-neutral-400">
<span data-hs-datatable-info-from=""></span>
to
<span data-hs-datatable-info-to=""></span>
of
<span data-hs-datatable-info-length=""></span>
</p>
</div>
<div class="inline-flex gap-x-2" data-hs-datatable-paging="">
<button type="button" class="cj-table-paging-btn" data-hs-datatable-paging-prev="">
<svg class="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>
Prev
</button>
<div class="flex items-center space-x-1" data-hs-datatable-paging-pages=""></div>
<button type="button" class="cj-table-paging-btn" data-hs-datatable-paging-next="">
Next
<svg class="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="editModal" class="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>'); %>

View File

@ -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 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
View 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) });
}

View 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
});
};

View File

@ -0,0 +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" });
return;
}
const feed = await prisma.feed.findUnique({
where: { id: Number(request.query.id) },
include: { channels: true, filters: true }
});
if (!feed) {
response.status(404).json({ message: "No result found" });
return;
}
response.json(feed);
};
const unpackChannels = (channels: string[] | string | undefined) => {
if (channels === undefined) return channels;
return Array.isArray(channels)
? channels.map(channelId => ({ channel_id: channelId }))
: [{ channel_id: channels }];
};
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) => {
logger.info(`Deleting feed(s): ${request.body.ids} - ${request.params.guildId}`);
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 {
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 });
}
};
export const datatable = async (request: Request, response: Response) => {
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 };

View 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 };

View File

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

View File

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

View File

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

6
src/server/prisma.ts Normal file
View File

@ -0,0 +1,6 @@
import { PrismaClient, Prisma } from "@server/../../generated/prisma";
const prisma = new PrismaClient();
export { Prisma };
export default prisma;

View File

@ -5,6 +5,10 @@ import filterController from "@server/controllers/guild/filter.controller";
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();
router.get("/:guildId", (request: Request, response: Response) => {
@ -13,9 +17,33 @@ router.get("/:guildId", (request: Request, response: Response) => {
return;
});
// Web routes
router.get("/:guildId/feeds", feedController.get);
router.get("/:guildId/filters", filterController.get);
router.get("/:guildId/styles", styleController.get);
router.get("/:guildId/content", contentController.get);
// API routes
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;

View File

@ -2,7 +2,8 @@
module.exports = {
darkMode: "selector",
content: [
"./src/client/**/*.{html,js,ejs}",
"./src/client/src/**/*.{js,ts}",
"./src/client/views/**/*.{html,ejs}",
"./node_modules/preline/dist/*.js"
],
theme: {

View File

@ -20,10 +20,12 @@
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true
},
"include": [
"./src/*",
"./src/server/**/*"
"./src/server/**/*",
"./src/bot/**/*"
]
}