Compare commits

...

73 Commits

Author SHA1 Message Date
b27af9c035 ts convert 2025-04-19 12:47:00 +01:00
25db5c73fb tags on filter table 2025-03-16 23:09:03 +00:00
92b097fcca preline migrate - popper to floating ui 2025-03-11 10:33:59 +00:00
23b4247de3 fix sidebar broken from upgrade 2025-03-06 10:21:11 +00:00
2806cafe82 migrate to tailwind 4 2025-03-05 15:15:57 +00:00
237b5686e4 algorithm select 2025-03-05 12:11:17 +00:00
8586090732 upgrade preline (fixes broken selects) 2025-03-05 12:10:57 +00:00
a65fd5ddb2 no need for isInstance param 2025-03-05 00:52:52 +00:00
d246e01dd7 fix bad js class 2025-03-05 00:52:33 +00:00
e032c51185 edit filter via modal 2025-03-04 23:59:04 +00:00
9fc386a973 form progress 2025-03-04 12:07:20 +00:00
86e380ec51 parse sub channel data on GET api 2025-02-25 14:58:56 +00:00
411c9e0597 ignore generated tailwind file 2025-02-19 12:06:14 +00:00
48636926b2 regen tailwind 2025-02-19 12:04:32 +00:00
0d74aaeffc small width filter modal 2025-02-19 12:04:08 +00:00
6fb96a49ad fix broken active status icons 2025-02-19 12:03:39 +00:00
a6dafe871b remove unused 'config.json' 2025-02-19 09:29:11 +00:00
c05d578116 sub table work 2025-02-19 09:29:11 +00:00
3590f8e9ce remove unused code 2025-02-19 09:28:29 +00:00
ccbf4c538e large border radius 2025-02-19 09:28:29 +00:00
74142fb48e remove commented code 2025-02-19 09:28:29 +00:00
b66c6f173d add filters 2025-02-19 00:29:20 +00:00
7434cd1d09 large border radius 2025-02-12 00:54:26 +00:00
08a286db13 sub deletion 2025-02-11 21:54:40 +00:00
a146a4793c form submission and validation work 2025-02-10 22:19:34 +00:00
f590936b2c form validation styling (very hard!) 2025-02-09 14:06:27 +00:00
6a68f81e59 change console logs to console debug 2025-02-09 14:06:11 +00:00
7ba12db083 ui work & sub table improvements 2025-02-07 17:54:26 +00:00
443b1cb223 backend fixes with api routes and datatables route 2025-02-07 17:53:52 +00:00
c53435027c add datatables select plugin 2025-02-07 17:53:15 +00:00
f262d821e0 more test data 2025-02-07 17:52:55 +00:00
ae53457344 server side filters 2025-02-05 17:13:02 +00:00
dede2ee06e sub view tweaks 2025-02-04 19:21:31 +00:00
0776130d37 get channels with sub api 2025-02-04 19:21:19 +00:00
f0a431b93e status view (unfinished) 2025-02-04 19:20:40 +00:00
51b923b101 working on api for subscriptions 2025-02-04 00:25:13 +00:00
ca38ecbf57 pagination & page size 2025-02-03 02:13:22 +00:00
88680f58a4 datatables api path & modal placeholder 2025-02-03 01:15:51 +00:00
2a3ebd5b24 fix incorrect url path 'styles' 2025-02-03 01:09:45 +00:00
5dafa6955f add popperjs 2025-02-03 01:09:24 +00:00
e007272efa better test data 2025-02-02 22:15:25 +00:00
1319d147a5 datatable code for subscriptions 2025-02-02 21:48:01 +00:00
7ce25d324d datatable query helper 2025-02-02 21:46:34 +00:00
31ce135b3a db integration and scripts 2025-02-02 00:56:55 +00:00
4577bb72b9 add datatables (not finished) 2025-01-30 13:03:10 +00:00
501c15207b border on guild header 2025-01-30 13:02:42 +00:00
56ecdf03a6 style changes (wider screen) 2025-01-30 11:01:36 +00:00
cc2e19e166 basic table - learning how 2025-01-30 11:01:20 +00:00
1a0b4af712 fix authentication issues 2025-01-29 18:35:04 +00:00
d276281ce9 logout and auth url 2025-01-29 18:20:28 +00:00
3bbfed025d discord authentication 2025-01-29 17:12:39 +00:00
0a26a8b4a8 sidebar organised 2025-01-29 12:36:59 +00:00
efdda2e18b attachGuilds middleware only where needed 2025-01-29 12:36:42 +00:00
58c2eb9ac6 guild pages and header 2025-01-29 11:32:57 +00:00
118c6f7991 login stuffs 2025-01-28 23:59:15 +00:00
cdaf525512 theme toggle 2025-01-28 23:33:01 +00:00
e188869811 Inter font 2025-01-28 15:37:54 +00:00
60f77a2691 member/channel count 2025-01-28 15:09:33 +00:00
ca470154d4 sidebar user menu (unfinished) 2025-01-28 15:09:23 +00:00
6fe3ce4b34 build file 2025-01-28 13:52:30 +00:00
5dbb6620c7 complete move to typescript 2025-01-28 13:52:25 +00:00
fc424ddec7 move to ts #1 2025-01-27 23:14:49 +00:00
ac16048dd5 guild view and guilds middleware 2025-01-26 22:37:12 +00:00
0a4298f49b multi-column sidebar working 2025-01-26 19:55:38 +00:00
de2c3c960f writing page layout 2025-01-26 01:02:29 +00:00
56f91ba239 discord bot and guilds on sidebar 2025-01-23 10:56:43 +00:00
ff6f445703 use selector for dark mode over browser pref 2025-01-23 10:56:10 +00:00
24b80fcbdc margin accordion items 2025-01-22 16:41:33 +00:00
0a402ea21a basic layout and tailwind 2025-01-22 16:38:20 +00:00
2b13681334 remove old code 2025-01-22 10:07:20 +00:00
e15664e39a basic templating 2025-01-21 00:27:54 +00:00
b3974987a1 update nodejs 2025-01-16 22:46:03 +00:00
e98c36b270 remove python 2025-01-15 21:19:31 +00:00
72 changed files with 7025 additions and 181 deletions

12
.gitignore vendored
View File

@ -1,7 +1,7 @@
__pycache__
*.pyc
venv/
.env
*.log
*.log.*
.vscode/
.vscode/
node_modules/
package-lock.json
dist/
*.sqlite
tailwind.css

View File

View File

@ -1,25 +0,0 @@
import logging
from discord import Intents
from discord.ext import commands
log = logging.getLogger(__name__)
class DiscordBot(commands.Bot):
def __init__(self):
super().__init__(
command_prefix="@",
intents=Intents.all()
)
async def on_ready(self):
await self.wait_until_ready()
await self.tree.sync()
log.info("Bot is synced and ready")
async def load_cogs(self, cog_path: str):
log.info("Loading cogs")
for path in cog_path.iterdir():
if path.suffix == ".py":
await self.load_extension(f"cogs.{path.stem}")

View File

@ -1,51 +0,0 @@
{
"version": 1,
"disable_existing_loggers": false,
"formatters": {
"simple": {
"format": "%(levelname)s %(message)s"
},
"detail": {
"format": "[%(asctime)s] [%(levelname)s] [%(name)s]: %(message)s"
},
"complex": {
"format": "[%(levelname)s|%(module)s|L%(lineno)d] %(asctime)s %(message)s",
"datefmt": "%Y-%m-%dT%H:%M:%S%z"
}
},
"handlers": {
"stdout": {
"class": "logging.StreamHandler",
"level": "DEBUG",
"formatter": "simple",
"stream": "ext://sys.stdout"
},
"file": {
"class": "logging.handlers.RotatingFileHandler",
"level": "DEBUG",
"formatter": "complex",
"filename": "logs/pyrss.log",
"maxBytes": 1048576,
"backupCount": 3
},
"queue_handler": {
"class": "logging.handlers.QueueHandler",
"handlers": [
"stdout",
"file"
],
"respect_handler_level": true
}
},
"loggers": {
"root": {
"level": "DEBUG",
"handlers": [
"queue_handler"
]
},
"discord": {
"level": "INFO"
}
}
}

53
main.py
View File

@ -1,53 +0,0 @@
import json
import atexit
import asyncio
import logging
import logging.config
from os import getenv
from pathlib import Path
from dotenv import load_dotenv
load_dotenv(override=True)
from bot.bot import DiscordBot
from web.app import create_app
BASE_DIR = Path(__file__).parent
async def start_web():
app = create_app()
await app.run_task(host="0.0.0.0", port=5000)
async def start_bot(token: str):
async with DiscordBot() as bot:
await bot.load_cogs(BASE_DIR / "bot" / "cogs")
await bot.start(token, reconnect=True)
def setup_logging():
# load config from file
log_config_path = BASE_DIR / "logs" / "config.json"
if not log_config_path.exists():
raise FileNotFoundError("Logging config not found")
with open(log_config_path, "r") as file:
logging_config = json.load(file)
logging.config.dictConfig(logging_config)
# create queue handler for non-blocking logs
queue_handler = logging.getHandlerByName("queue_handler")
if queue_handler is not None:
queue_handler.listener.start()
atexit.register(queue_handler.listener.stop)
async def main():
bot_token = getenv("BOT_TOKEN")
if not bot_token:
raise ValueError("'BOT_TOKEN' is missing")
setup_logging()
await asyncio.gather(start_web(), start_bot(bot_token))
if __name__ == "__main__":
asyncio.run(main())

62
package.json Normal file
View File

@ -0,0 +1,62 @@
{
"name": "pyrss-ng",
"version": "0.0.0",
"main": "src/app.ts",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"tailwind": "npx @tailwindcss/cli -i ./src/client/public/css/main.css -o ./src/client/public/css/tailwind.css",
"build:server": "./scripts/build.sh",
"build:client": "npx tsc --project ./tsconfig.client.json",
"build": "npm run tailwind && npm run build:client && npm run build:server",
"dev": "cross-env TS_NODE_PROJECT=tsconfig.server.json nodemon -r tsconfig-paths/register --exec 'npm run build:client && ts-node' ./src/app.ts",
"start": "node dist/app.js"
},
"repository": {
"type": "git",
"url": "https://gitea.cor.bz/corbz/pyrss-ng.git"
},
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"@floating-ui/dom": "^1.6.13",
"@popperjs/core": "^2.11.8",
"@preline/datatable": "^2.5.2",
"@tailwindcss/cli": "^4.0.9",
"canvas": "^3.1.0",
"connect-flash": "^0.1.1",
"datatables.net-dt": "^2.2.1",
"datatables.net-select-dt": "^3.0.0",
"discord.js": "^14.17.3",
"dotenv": "^16.4.7",
"ejs": "^3.1.10",
"ejs-mate": "^4.0.0",
"express": "^4.21.2",
"express-session": "^1.18.1",
"jquery": "^3.7.1",
"knex": "^3.1.0",
"ncp": "^2.0.0",
"passport": "^0.7.0",
"passport-discord": "^0.1.4",
"preline": "^3.0.0",
"sqlite3": "^5.1.7",
"tsconfig-paths": "^4.2.0"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.10",
"@types/connect-flash": "^0.0.40",
"@types/ejs": "^3.1.5",
"@types/express": "^5.0.0",
"@types/express-session": "^1.18.1",
"@types/jquery": "^3.5.32",
"@types/node": "^22.10.10",
"@types/passport": "^1.0.17",
"@types/passport-discord": "^0.1.14",
"@zerollup/ts-transform-paths": "^1.7.18",
"nodemon": "^3.1.9",
"tailwindcss": "^4.0.9",
"ts-node": "^10.9.2",
"tsc-alias": "^1.8.10",
"typescript": "^5.7.3"
}
}

View File

@ -1,27 +0,0 @@
aiofiles==24.1.0
aiohappyeyeballs==2.4.4
aiohttp==3.11.11
aiosignal==1.3.2
attrs==24.3.0
blinker==1.9.0
click==8.1.8
discord.py==2.4.0
Flask==3.1.0
frozenlist==1.5.0
h11==0.14.0
h2==4.1.0
hpack==4.0.0
Hypercorn==0.17.3
hyperframe==6.0.1
idna==3.10
itsdangerous==2.2.0
Jinja2==3.1.5
MarkupSafe==3.0.2
multidict==6.1.0
priority==2.0.0
propcache==0.2.1
python-dotenv==1.0.1
Quart==0.20.0
Werkzeug==3.1.3
wsproto==1.2.0
yarl==1.18.3

18
scripts/build.sh Executable file
View File

@ -0,0 +1,18 @@
cd "$(dirname "$0")/../"
echo "Erasing previous dist folder"
rm -rf ./dist
echo "Compiling tailwind css ..."
npx tailwindcss -i ./src/client/public/css/main.css -o ./src/client/public/css/tailwind.css
echo "Compiling typescript ..."
npx tsc --project ./tsconfig.server.json
echo "Copying client files"
cp -r src/client dist/client
echo "Building typescript path aliases ..."
npx tsc-alias -p ./tsconfig.server.json
echo "Done!"

2
scripts/migrate.sh Executable file
View File

@ -0,0 +1,2 @@
cd "$(dirname "$0")/../"
npx knex migrate:latest --knexfile ./src/knexfile.ts

2
scripts/seeds.sh Executable file
View File

@ -0,0 +1,2 @@
cd "$(dirname "$0")/../"
npx knex seed:run --knexfile ./src/knexfile.ts

62
src/app.ts Normal file
View File

@ -0,0 +1,62 @@
import path from "path";
import express from "express";
import engine from "ejs-mate";
import passport from "passport";
import session from "express-session";
import flash from "connect-flash";
import dotenv from "dotenv";
dotenv.config();
import "@bot/bot";
import { setupPassport } from "@server/controllers/auth.web.controller";
import { attachUser } from "@server/middleware/attachUser";
import { flashMiddleware } from "@server/middleware/flash";
import { ensureAuthenticated } from "@server/middleware/authenticated";
// import routers & middleware
import { attachGuilds } from "@server/middleware/attachGuilds";
import homeWebRouter from "@server/routes/home.web.routes";
import guildApiRouter from "@server/routes/guild.api.routes";
import guildWebRouter from "@server/routes/guild.web.routes";
import authWebRouter from "@server/routes/auth.web.routes";
const app = express();
app.engine("ejs", engine);
app.set("views", path.resolve(__dirname, "client/views"));
app.set("view engine", "ejs");
app.use(express.urlencoded({ extended: true }));
// Public files, including 3rd party resources (foreign)
app.use("/static", express.static(path.resolve(__dirname, "client/public")));
app.use("/static/foreign/preline.js", express.static(path.resolve(__dirname, "../node_modules/preline/dist/preline.js")));
app.use("/static/foreign/jquery.js", express.static(path.resolve(__dirname, "../node_modules/jquery/dist/jquery.min.js")));
app.use("/static/foreign/dataTables.js", express.static(path.resolve(__dirname, "../node_modules/datatables.net/js/dataTables.min.js")));
app.use("/static/foreign/dataTablesSelect.js", express.static(path.resolve(__dirname, "../node_modules/datatables.net-select/js/dataTables.select.min.js")));
app.use("/static/foreign/floatingUiCore.js", express.static(path.resolve(__dirname, "../node_modules/@floating-ui/core/dist/floating-ui.core.umd.min.js")));
app.use("/static/foreign/floatingUiDOM.js", express.static(path.resolve(__dirname, "../node_modules/@floating-ui/dom/dist/floating-ui.dom.umd.min.js")));
// User authentication & sessions
app.use(session({
secret: "unsecure-development-secret",
resave: true,
saveUninitialized: true
}));
setupPassport(passport);
app.use(passport.initialize());
app.use(passport.session());
app.use(flash());
app.use(flashMiddleware);
// register routers & middleware
app.use("/auth", authWebRouter);
app.use("/guild", ensureAuthenticated, attachUser, attachGuilds, guildWebRouter, guildApiRouter);
app.use("/", ensureAuthenticated, attachUser, attachGuilds, homeWebRouter);
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server is listening on http://localhost:${PORT}`);
});

21
src/bot/bot.ts Normal file
View File

@ -0,0 +1,21 @@
// const { Client, GatewayIntentBits } = require("discord.js");
import { Client, GatewayIntentBits, ActivityType } from "discord.js";
export const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildWebhooks
]
});
client.on("ready", () => {
if (!client.user) {
throw Error("Client is null");
}
client.user.setActivity("new sources", { type: ActivityType.Watching });
console.log(`Discord Bot '${client.user.displayName}' is online!`)
});
client.login(process.env.BOT_TOKEN);

View File

@ -0,0 +1,272 @@
@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);
}
}
/* flex items-center gap-x-3.5 py-2 px-2.5 bg-gray-100 text-sm text-gray-800 rounded-lg hover:bg-gray-100 focus:outline-none focus:bg-gray-100 dark:bg-neutral-700 dark:text-white
hs-accordion-toggle w-full text-start flex items-center gap-x-3.5 py-2 px-2.5 text-sm text-gray-800 rounded-lg hover:bg-gray-100 focus:outline-none focus:bg-gray-100 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:text-neutral-200 */
@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;
}
/* End Datatables */
/* Sidebar Components */
.mobile-sidebar-header {
@apply
sticky
top-0
inset-x-0
z-20
lg:hidden;
}
.mobile-sidebar-header .mobile-sidebar-nav {
@apply
flex
p-4
sm:px-6
border-b
bg-white
dark:bg-neutral-800
dark:border-neutral-700;
}
.mobile-sidebar-header .mobile-sidebar-nav .mobile-sidebar-brand {
@apply text-xl font-semibold;
}
.sidebar-aside {
@apply
/* Position and Display */
transform
w-64
h-full
fixed
top-0
bottom-0
/* Colours and Border */
bg-white
border-e
border-gray-200
dark:bg-neutral-800
dark:border-neutral-700;
}
.sidebar-aside.main-sidebar {
@apply
[--auto-close:lg]
lg:block
lg:translate-x-0
lg:end-auto
lg:bottom-0
-translate-x-full
transition-all
duration-200
ease-in
start-0
z-60
}
.sidebar-aside.guild-sidebar {
@apply
[--auto-close:lg]
lg:block
lg:end-auto
lg:bottom-0
-translate-x-full
transition-all
duration-200
lg:duration-100
ease-in
start-0
lg:start-64
z-59
}
.sidebar-aside .sidebar-container {
@apply
relative
flex
flex-col
h-full
max-h-full;
}
.sidebar-aside .sidebar-container .sidebar-header {
@apply
px-6
pt-4
flex
justify-between
items-center;
}
.sidebar-aside .sidebar-container .sidebar-header .sidebar-brand {
@apply
flex-none
rounded-xl
text-xl
inline-block
font-semibold
focus:outline-hidden
focus:opacity-80;
}
.sidebar-aside .sidebar-container .sidebar-content {
@apply
h-full
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
}
.sidebar-aside .sidebar-container .sidebar-content .sidebar-nav {
@apply
px-4
py-6
w-full
h-full
flex
flex-col
flex-wrap;
}
.sidebar-nav .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;
}
/* End Sidebar Components */
/* Guild Header */
.guild-header-btn {
@apply
text-sm
text-gray-800
hover:text-blue-600
focus:outline-hidden
focus:text-blue-600
dark:text-neutral-200
dark:hover:text-blue-500
dark:focus:text-blue-500;
}
.guild-header-btn.active {
@apply text-blue-600;
}
/* End Guild Header */
/* 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
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
}
/* End Form Controls */

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,286 @@
const emptyTableHtml = `
<div class="max-w-md w-full min-h-[400px] flex flex-col justify-center mx-auto px-6 py-4">
<div class="flex justify-center items-center size-[46px] bg-gray-100 rounded-lg dark:bg-neutral-800">
<svg class="shrink-0 size-6 text-gray-600 dark:text-neutral-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11a9 9 0 0 1 9 9"></path><path d="M4 4a16 16 0 0 1 16 16"></path><circle cx="5" cy="19" r="1"></circle></svg>
</div>
<h2 class="mt-5 font-semibold text-gray-800 dark:text-white">
No results found
</h2>
<p class="mt-2 text-sm text-gray-600 dark:text-neutral-400">
Create a filter and it will appear here.
</p>
<div class="mt-5 flex flex-col sm:flex-row gap-2">
<button type="button" class="openFilterModal-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="#filterModal">
<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 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>
`;
var table;
const defineTable = () => {
table = new HSDataTable("#table", {
ajax: {
url: `/guild/${guildId}/filters/api/datatable`,
dataSrc: "data",
data: (d) => {
if (d === undefined) { return ;}
d.filters = {};
const is_whitelist = $("input[name='filterType']:checked").val();
d.filters.is_whitelist = is_whitelist;
}
},
serverSide: true,
processing: true,
selecting: true,
pagingOptions: {
pageBtnClasses: "hidden"
},
rowSelectingOptions: {
selectAllSelector: "#selectAllBox"
},
language: {
zeroRecords: emptyTableHtml,
emptyTable: emptyTableHtml,
loading: "Placeholder Loading Message...",
},
rowCallback: (row, data, index) => {
$(row).addClass("bg-white dark:bg-neutral-900");
},
drawCallback: () => {
HSDropdown.autoInit();
},
select: {
style: "multi",
selector: "td:first-child input[type='checkbox']"
},
columnDefs: [
{
// Row select checkbox
targets: 0,
orderable: false,
searchable: false,
render: (data, type, row) => {
return `
<td class="size-px whitespace-nowrap">
<div class="ps-6 py-4">
<label for="rowSelect${row.id}-js" class="flex">
<input type="checkbox" id="rowSelect${row.id}-js" class="form-checkbox shrink-0 border-gray-300 rounded-sm text-blue-600 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-800 dark:border-neutral-600 dark:checked:bg-blue-500 dark:checked:border-blue-500 dark:focus:ring-offset-gray-800" data-hs-datatable-row-selecting-individual="">
<span class="sr-only">Checkbox</span>
</label>
</div>
</td>
`;
}
},
{
// Name
targets: 1,
data: "name",
orderable: true,
searchable: true,
render: (data, _type, row) => {
return `
<td class="size-px whitespace-nowrap align-top">
<span class="openFilterModal-js 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" data-id="${row.id}">
${data}
</sp>
</td>
`;
}
},
{
// Match
targets: 2,
data: "match",
orderable: true,
searchable: true,
render: data => {
return `
<td class="size-px whitespace-nowrap align-top">
<a href="#" class="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">
${data}
</a>
</td>
`;
}
},
{
// Algorithm
target: 3,
data: "algorithm",
orderable: true,
searchable: true,
render: (data, type, row) => {
return `
<td class="size-px whitespace-nowrap align-top">
<a href="#" class="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">
${data}
</a>
</td>
`;
}
},
{
// Filters
target: 4,
data: "is_insensitive",
orderable: true,
searchable: true,
render: data => {
wrapper = $("<div>").addClass("px-6 py-4");
badge = $("<span>").addClass("py-1 px-1.5 inline-flex items-center text-xs font-medium rounded-full");
label = $("<span>");
if (data) {
badge.addClass("bg-red-100 text-red-800 dark:bg-red-500/10 dark:bg-red-500");
badge.append(label.text("No"));
} else {
badge.addClass("bg-teal-100 text-teal-800 dark:bg-teal-500/10 dark:text-teal-500");
badge.append(label.text("Yes"));
}
wrapper.append(badge);
return wrapper.get(0);
}
},
{
// Whitelist
target: 5,
data: "is_whitelist",
orderable: true,
searchable: true,
render: data => {
wrapper = $("<div>").addClass("px-6 py-4");
badge = $("<span>").addClass("py-1 px-1.5 inline-flex items-center text-xs font-medium rounded-full");
label = $("<span>");
if (data) {
badge.addClass("bg-teal-100 text-teal-800 dark:bg-teal-500/10 dark:text-teal-500");
badge.append(label.text("Whitelist"));
} else {
badge.addClass("bg-red-100 text-red-800 dark:bg-red-500/10 dark:bg-red-500");
badge.append(label.text("Blacklist"));
}
wrapper.append(badge);
return wrapper.get(0);
}
},
{
// Created At
target: 6,
data: "created_at",
orderable: true,
searchable: true,
render: data => {
return `
<td class="size-px whitespace-nowrap align-top">
<div class="px-6 py-4">
<span class="text-sm text-gray-500 dark:text-neutral-500 text-nowrap">
${formatTimestamp(data)}
</span>
</div>
</td>
`;
}
}
]
})
table.dataTable
.on("select", onTableSelectChange)
.on("deselect", onTableSelectChange)
.on("draw", onTableSelectChange);
}
// Ensure the datatable recognises when all rows are selected, otherwise rows are only visually selected
$("#selectAllBox").on("change", function() {
this.checked ? table.dataTable.rows().select() : table.dataTable.rows().deselect();
});
const onTableSelectChange = () => {
const selectedRowCount = table.dataTable.rows({ selected: true }).count();
$("#deleteRowsBtn").prop("disabled", selectedRowCount === 0);
$(".rows-selected-count-js").text(selectedRowCount);
const $elem = $(".rows-selected-count-js.zero-empty-js");
selectedRowCount === 0 ? $elem.hide() : $elem.show();
}
$(window).ready(() => {
setTimeout(defineTable, 500);
});
$("input[name='filterType']").on("change", () => {
table.dataTable.draw();
});
const openFilterForm = async id => {
$("#filterForm").removeClass("submitted");
const formAlgorithmSelect = HSSelect.getInstance("#formAlgorithm");
formAlgorithmSelect.setValue("");
if (id === -1) {
$("#formName").val("");
$("#formMatch").val("");
$("#formWhitelist").prop("checked", false);
$("#formInsensitive").prop("checked", false);
} else {
const data = await $.ajax({
url: `/guild/${guildId}/filters/api?id=${id}`,
method: "get"
});
$("#formName").val(data.name);
$("#formMatch").val(data.match);
$("#formWhitelist").prop("checked", data.is_whitelist);
$("#formInsensitive").prop("checked", data.is_insensitive);
formAlgorithmSelect.setValue(data.algorithm);
}
HSOverlay.open($("#filterModal").get(0));
}
const closeFilterForm = () => {
$("#filterForm").removeClass("submitted");
HSOverlay.close($("#filterModal").get(0));
}
$(document).on("click", ".openFilterModal-js", event => {
openFilterForm($(event.target).data("id") || -1);
});
const submitForm = async event => {
event.preventDefault();
const form = $(event.target).get(0);
$(form).addClass("submitted");
if (!form.checkValidity()) { return; }
await $.ajax({
url: `/guild/${guildId}/filters/api`,
method: "post",
dataType: "json",
data: $(event.target).serializeArray(),
success: () => {
table.dataTable.draw();
closeFilterForm();
},
error: error => {
alert(JSON.stringify(error, null, 4));
}
});
}
$("#filterForm").on("submit", submitForm);

View File

@ -0,0 +1,359 @@
//#region Table
const emptyTableHtml = `
<div class="max-w-md w-full min-h-[400px] flex flex-col justify-center mx-auto px-6 py-4">
<div class="flex justify-center items-center size-[46px] bg-gray-100 rounded-lg dark:bg-neutral-800">
<svg class="shrink-0 size-6 text-gray-600 dark:text-neutral-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11a9 9 0 0 1 9 9"></path><path d="M4 4a16 16 0 0 1 16 16"></path><circle cx="5" cy="19" r="1"></circle></svg>
</div>
<h2 class="mt-5 font-semibold text-gray-800 dark:text-white">
No results found
</h2>
<p class="mt-2 text-sm text-gray-600 dark:text-neutral-400">
Create a subscription and it will appear here.
</p>
<div class="mt-5 flex flex-col sm:flex-row gap-2">
<button type="button" class="openSubModal-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="#subModal">
<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 subscription
</button>
<button type="button" onclick="alert('not implemented');" 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">
Use a Template
</button>
</div>
</div>
`;
var table;
const defineTable = () => {
table = new HSDataTable("#table", {
ajax: {
url: `/guild/${guildId}/subscriptions/api/datatable`,
dataSrc: "data",
data: (d) => {
if (d === undefined) { return ;}
d.filters = {};
const active = $("input[name='filterActive']:checked").val();
d.filters.active = active;
}
},
serverSide: true,
processing: true,
selecting: true,
pagingOptions: {
pageBtnClasses: "hidden"
},
rowSelectingOptions: {
selectAllSelector: "#selectAllBox"
},
language: {
zeroRecords: emptyTableHtml,
emptyTable: emptyTableHtml,
loading: "Placeholder Loading Message...",
},
rowCallback: (row, data, index) => {
$(row).addClass("bg-white dark:bg-neutral-900");
},
drawCallback: () => {
HSDropdown.autoInit();
},
select: {
style: "multi",
selector: "td:first-child input[type='checkbox']"
},
columnDefs: [
{
// Row select checkbox
targets: 0,
orderable: false,
searchable: false,
render: (data, type, row) => {
return `
<td class="size-px whitespace-nowrap">
<div class="ps-6 py-4">
<label for="rowSelect${row.id}-js" class="flex">
<input type="checkbox" id="rowSelect${row.id}-js" class="form-checkbox shrink-0 border-gray-300 rounded-sm text-blue-600 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-800 dark:border-neutral-600 dark:checked:bg-blue-500 dark:checked:border-blue-500 dark:focus:ring-offset-gray-800" data-hs-datatable-row-selecting-individual="">
<span class="sr-only">Checkbox</span>
</label>
</div>
</td>
`;
}
},
{
// Name
targets: 1,
data: "name",
orderable: true,
searchable: true,
render: (data, _type, row) => {
return `
<td class="size-px whitespace-nowrap align-top">
<span class="openSubModal-js 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" data-id="${row.id}">
${data}
</span>
</td>
`;
}
},
{
// Url
targets: 2,
data: "url",
orderable: true,
searchable: true,
render: data => {
return `
<td class="size-px whitespace-nowrap align-top">
<a href="${data}" class="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" target="_blank">
${data}
</a>
</td>
`;
}
},
{
// Channels
target: 3,
data: "channels",
orderable: false,
searchable: false,
render: (data, type, row) => {
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");
const firstChannelName = "# " + channels.find(c => c.id === data[0]).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);
}
else if (data.length <= 2) {
const secondChannelName = "# " + channels.find(c => c.id === data[1]).name;
wrapper.append(tag.clone().text(secondChannelName));
data.shift();
return wrapper.get(0);
}
// drop the first element to exclude it from the dropdown
data.shift();
const dropdown = $("<div>").addClass("hs-dropdown inline-block");
const dropdownBtn = $("<button>").attr("id", `channelDrop-${row.id}`).attr("type", "button").addClass("cursor-pointer inline-flex items-center gap-1 py-1 px-2.5 border text-xs rounded-md bg-white dark:bg-neutral-800 border-gray-200 dark:border-neutral-700 text-gray-800 dark:text-neutral-200");
const dropdownMenu = $("<div>").addClass("hs-dropdown-menu hidden opacity-0 hs-dropdown-open:opacity-100 transition-[opacity,margin] overflow-hidden z-10 w-fit max-w-64 border p-2 rounded-md bg-gray-200 dark:bg-neutral-700 border-gray-300 dark:border-neutral-600");
dropdown.append(dropdownBtn.text(`+${data.length}`));
data.forEach(channelId => {
channelName = "# " + channels.find(c => c.id === channelId).name;
dropdownMenu.append(tag.clone().text(channelName));
});
dropdown.append(dropdownMenu);
wrapper.append(dropdown);
return wrapper.get(0);
}
},
{
// Filters
target: 4,
data: "filters",
orderable: false,
searchable: false,
render: (data, type, row) => {
return `
`;
}
},
{
// Style
target: 5,
style: "style",
orderable: false,
searchable: false,
render: (data, type, row) => {
return `
<td class="size-px whitespace-nowrap align-top">
<div class="px-6 py-4 text-center">
<div class="inline-block rounded-md size-5 bg-red-500 mx-auto">
</div>
</div>
</td>
`;
}
},
{
// Created At
target: 6,
data: "created_at",
orderable: true,
searchable: true,
render: data => {
return `
<td class="size-px whitespace-nowrap align-top">
<div class="px-6 py-4">
<span class="text-sm text-gray-500 dark:text-neutral-500 text-nowrap">
${formatTimestamp(data)}
</span>
</div>
</td>
`;
}
},
{
// Status
target: 7,
data: "active",
orderable: true,
searchable: true,
render: (_data, _type, row) => {
wrapper = $("<div>").addClass("px-6 py-4");
badge = $("<span>").addClass("py-1 px-1.5 inline-flex items-center gap-x-1 text-xs font-medium rounded-full");
label = $("<span>");
if (row.active) {
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);
}
}
]
})
table.dataTable
.on("select", onTableSelectChange)
.on("deselect", onTableSelectChange)
.on("draw", onTableSelectChange);
}
//#endregion Table
// Ensure the datatable recognises when all rows are selected, otherwise rows are only visually selected
$("#selectAllBox").on("change", function() {
this.checked ? table.dataTable.rows().select() : table.dataTable.rows().deselect();
});
const onTableSelectChange = () => {
const selectedRowCount = table.dataTable.rows({ selected: true }).count();
$("#deleteRowsBtn").prop("disabled", selectedRowCount === 0);
$(".rows-selected-count-js").text(selectedRowCount);
const $elem = $(".rows-selected-count-js.zero-empty-js");
selectedRowCount === 0 ? $elem.hide() : $elem.show();
}
$("#deleteRowsBtn").on("click", async () => {
const rowIds = table.dataTable.rows({ selected: true }).data().toArray().map(row => row.id);
console.log(JSON.stringify(rowIds))
await $.ajax({
url: `/guild/${guildId}/subscriptions/api`,
method: "delete",
dataType: "json",
data: { ids: rowIds },
success: () => {
table.dataTable.draw();
table.dataTable.rows().deselect();
},
error: error => {
alert(typeof error === "object" ? JSON.stringify(error, null, 4) : error);
}
});
});
$(window).ready(() => {
setTimeout(defineTable, 500);
});
$("input[name='filterActive']").on("change", () => {
table.dataTable.draw();
});
const openSubForm = async id => {
$("#subForm").removeClass("submitted");
// Always clear the selects to avoid bugs
HSSelect.getInstance("#formStyle", true).element.setValue([]);
HSSelect.getInstance("#formChannels", true).element.setValue([]);
HSSelect.getInstance("#formFilters", true).element.setValue([]);
$("#formChannelsInput").css("width", "");
$("#formFiltersInput").css("width", "");
// New Subscription: Clear the form values
if (id === -1) {
$("#formName").val("");
$("#formUrl").val("");
$("#formPublishedThreshold").val(new Date().toISOString().slice(0, 16));
$("#formActive").prop("checked", true);
}
// Existing subscription: Load the values
else {
const data = await $.ajax({
url: `/guild/${guildId}/subscriptions/api?id=${id}`,
method: "get",
});
$("#formName").val(data.name);
$("#formUrl").val(data.url);
$("#formPublishedThreshold").val(new Date(data.created_at));
HSSelect.getInstance("#formStyle", true).element.setValue([]);
HSSelect.getInstance("#formChannels", true).element.setValue(data.channels);
HSSelect.getInstance("#formFilters", true).element.setValue([]);
// $("#formChannelsInput").css("width", "");
// $("#formFiltersInput").css("width", "");
$("#formActive").prop("checked", data.active);
}
HSOverlay.open($("#subModal").get(0));
}
const closeSubForm = () => {
$("#subForm").removeClass("submitted");
HSOverlay.close($("#subModal").get(0));
}
$(document).on("click", ".openSubModal-js", event => {
openSubForm($(event.target).data("id") || -1);
});
const submitForm = async event => {
event.preventDefault();
const form = $(event.target).get(0);
$(form).addClass("submitted");
if (!form.checkValidity()) { return; }
await $.ajax({
url: `/guild/${guildId}/subscriptions/api`,
method: "post",
dataType: "json",
data: $(event.target).serializeArray(),
success: () => {
table.dataTable.draw();
closeSubForm();
},
error: error => {
alert(JSON.stringify(error, null, 4));
}
});
}
$("#subForm").on("submit", submitForm);

View File

@ -0,0 +1,22 @@
"use strict";
window.addEventListener("load", () => {
const inputs = document.querySelectorAll('.dt-container thead input');
inputs.forEach(input => {
input.addEventListener("keydown", (event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "a") {
event.target.select();
}
});
});
});
const formatTimestamp = (timestamp) => {
const date = new Date(typeof timestamp === "string"
? timestamp.replace(" ", "T")
: timestamp);
const now = new Date();
const difference = now.getTime() - date.getTime();
const result = `${date.getDate()} ${date.toLocaleString("en-GB", { month: "short" })}`;
return difference < 31536000000
? result + `, ${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`
: result + ` ${date.getFullYear()}`;
};

View File

@ -0,0 +1,286 @@
const emptyTableHtml: string = `
<div class="max-w-md w-full min-h-[400px] flex flex-col justify-center mx-auto px-6 py-4">
<div class="flex justify-center items-center size-[46px] bg-gray-100 rounded-lg dark:bg-neutral-800">
<svg class="shrink-0 size-6 text-gray-600 dark:text-neutral-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11a9 9 0 0 1 9 9"></path><path d="M4 4a16 16 0 0 1 16 16"></path><circle cx="5" cy="19" r="1"></circle></svg>
</div>
<h2 class="mt-5 font-semibold text-gray-800 dark:text-white">
No results found
</h2>
<p class="mt-2 text-sm text-gray-600 dark:text-neutral-400">
Create a filter and it will appear here.
</p>
<div class="mt-5 flex flex-col sm:flex-row gap-2">
<button type="button" class="openFilterModal-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="#filterModal">
<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 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>
`;
var table: any;
const defineTable = () => {
table = new HSDataTable($("#table")[0], {
ajax: {
url: `/guild/${guildId}/filters/api/datatable`,
dataSrc: "data",
data: (d: any) => {
if (d === undefined) { return ;}
d.filters = {};
const is_whitelist = $("input[name='filterType']:checked").val();
d.filters.is_whitelist = is_whitelist;
}
},
serverSide: true,
processing: true,
selecting: true,
pagingOptions: {
pageBtnClasses: "hidden"
},
rowSelectingOptions: {
selectAllSelector: "#selectAllBox"
},
language: {
zeroRecords: emptyTableHtml,
emptyTable: emptyTableHtml,
loading: "Placeholder Loading Message...",
},
rowCallback: (row: any, data: any, index: number) => {
$(row).addClass("bg-white dark:bg-neutral-900");
},
drawCallback: () => {
HSDropdown.autoInit();
},
select: {
style: "multi",
selector: "td:first-child input[type='checkbox']"
},
columnDefs: [
{
// Row select checkbox
targets: 0,
orderable: false,
searchable: false,
render: (data, type, row) => {
return `
<td class="size-px whitespace-nowrap">
<div class="ps-6 py-4">
<label for="rowSelect${row.id}-js" class="flex">
<input type="checkbox" id="rowSelect${row.id}-js" class="form-checkbox shrink-0 border-gray-300 rounded-sm text-blue-600 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-800 dark:border-neutral-600 dark:checked:bg-blue-500 dark:checked:border-blue-500 dark:focus:ring-offset-gray-800" data-hs-datatable-row-selecting-individual="">
<span class="sr-only">Checkbox</span>
</label>
</div>
</td>
`;
}
},
{
// Name
targets: 1,
data: "name",
orderable: true,
searchable: true,
render: (data, _type, row) => {
return `
<td class="size-px whitespace-nowrap align-top">
<span class="openFilterModal-js 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" data-id="${row.id}">
${data}
</sp>
</td>
`;
}
},
{
// Match
targets: 2,
data: "match",
orderable: true,
searchable: true,
render: data => {
return `
<td class="size-px whitespace-nowrap align-top">
<a href="#" class="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">
${data}
</a>
</td>
`;
}
},
{
// Algorithm
target: 3,
data: "algorithm",
orderable: true,
searchable: true,
render: (data, type, row) => {
return `
<td class="size-px whitespace-nowrap align-top">
<a href="#" class="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">
${data}
</a>
</td>
`;
}
},
{
// Filters
target: 4,
data: "is_insensitive",
orderable: true,
searchable: true,
render: data => {
wrapper = $("<div>").addClass("px-6 py-4");
badge = $("<span>").addClass("py-1 px-1.5 inline-flex items-center text-xs font-medium rounded-full");
label = $("<span>");
if (data) {
badge.addClass("bg-red-100 text-red-800 dark:bg-red-500/10 dark:bg-red-500");
badge.append(label.text("No"));
} else {
badge.addClass("bg-teal-100 text-teal-800 dark:bg-teal-500/10 dark:text-teal-500");
badge.append(label.text("Yes"));
}
wrapper.append(badge);
return wrapper.get(0);
}
},
{
// Whitelist
target: 5,
data: "is_whitelist",
orderable: true,
searchable: true,
render: data => {
wrapper = $("<div>").addClass("px-6 py-4");
badge = $("<span>").addClass("py-1 px-1.5 inline-flex items-center text-xs font-medium rounded-full");
label = $("<span>");
if (data) {
badge.addClass("bg-teal-100 text-teal-800 dark:bg-teal-500/10 dark:text-teal-500");
badge.append(label.text("Whitelist"));
} else {
badge.addClass("bg-red-100 text-red-800 dark:bg-red-500/10 dark:bg-red-500");
badge.append(label.text("Blacklist"));
}
wrapper.append(badge);
return wrapper.get(0);
}
},
{
// Created At
target: 6,
data: "created_at",
orderable: true,
searchable: true,
render: data => {
return `
<td class="size-px whitespace-nowrap align-top">
<div class="px-6 py-4">
<span class="text-sm text-gray-500 dark:text-neutral-500 text-nowrap">
${formatTimestamp(data)}
</span>
</div>
</td>
`;
}
}
]
})
table.dataTable
.on("select", onTableSelectChange)
.on("deselect", onTableSelectChange)
.on("draw", onTableSelectChange);
}
// Ensure the datatable recognises when all rows are selected, otherwise rows are only visually selected
$("#selectAllBox").on("change", function() {
this.checked ? table.dataTable.rows().select() : table.dataTable.rows().deselect();
});
const onTableSelectChange = () => {
const selectedRowCount = table.dataTable.rows({ selected: true }).count();
$("#deleteRowsBtn").prop("disabled", selectedRowCount === 0);
$(".rows-selected-count-js").text(selectedRowCount);
const $elem = $(".rows-selected-count-js.zero-empty-js");
selectedRowCount === 0 ? $elem.hide() : $elem.show();
}
$(window).ready(() => {
setTimeout(defineTable, 500);
});
$("input[name='filterType']").on("change", () => {
table.dataTable.draw();
});
const openFilterForm = async id => {
$("#filterForm").removeClass("submitted");
const formAlgorithmSelect = HSSelect.getInstance("#formAlgorithm");
formAlgorithmSelect.setValue("");
if (id === -1) {
$("#formName").val("");
$("#formMatch").val("");
$("#formWhitelist").prop("checked", false);
$("#formInsensitive").prop("checked", false);
} else {
const data = await $.ajax({
url: `/guild/${guildId}/filters/api?id=${id}`,
method: "get"
});
$("#formName").val(data.name);
$("#formMatch").val(data.match);
$("#formWhitelist").prop("checked", data.is_whitelist);
$("#formInsensitive").prop("checked", data.is_insensitive);
formAlgorithmSelect.setValue(data.algorithm);
}
HSOverlay.open($("#filterModal").get(0));
}
const closeFilterForm = () => {
$("#filterForm").removeClass("submitted");
HSOverlay.close($("#filterModal").get(0));
}
$(document).on("click", ".openFilterModal-js", event => {
openFilterForm($(event.target).data("id") || -1);
});
const submitForm = async event => {
event.preventDefault();
const form = $(event.target).get(0);
$(form).addClass("submitted");
if (!form.checkValidity()) { return; }
await $.ajax({
url: `/guild/${guildId}/filters/api`,
method: "post",
dataType: "json",
data: $(event.target).serializeArray(),
success: () => {
table.dataTable.draw();
closeFilterForm();
},
error: error => {
alert(JSON.stringify(error, null, 4));
}
});
}
$("#filterForm").on("submit", submitForm);

View File

@ -0,0 +1,36 @@
// 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'
*/
const formatTimestamp = (timestamp: string | number) => {
const date = new Date(
typeof timestamp === "string"
? timestamp.replace(" ", "T")
: timestamp
);
const now = new Date();
const difference = now.getTime() - date.getTime();
// Day and short month (example: 21 Oct)
const result = `${date.getDate()} ${date.toLocaleString("en-GB", { month: "short" })}`
// Difference is less than a year: 'DD MMM, HH:mm'
// Or, difference is more than a year: 'DD MMM YYYY'
return difference < 31536000000
? result + `, ${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`
: result + ` ${date.getFullYear()}`;
}

1
src/client/public/types/globals.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare const guildId: string;

83
src/client/public/types/preline.d.ts vendored Normal file
View File

@ -0,0 +1,83 @@
import type INoUiSlider from 'nouislider';
import { ICollectionItem } from 'preline/src/interfaces';
import { IStaticMethods } from 'preline/src/static/interfaces';
import type HSCopyMarkup from 'preline/src/plugins/copy-markup';
import type HSAccordion from 'preline/src/plugins/accordion';
import type HSCarousel from 'preline/src/plugins/carousel';
import type HSCollapse from 'preline/src/plugins/collapse';
import type HSComboBox from 'preline/src/plugins/combobox';
import type HSDataTable from 'preline/src/plugins/datatable';
import type HSDropdown from 'preline/src/plugins/dropdown';
import type HSFileUpload from 'preline/src/plugins/file-upload';
import type HSInputNumber from 'preline/src/plugins/input-number';
import type HSLayoutSplitter from 'preline/src/plugins/layout-splitter';
import type HSOverlay from 'preline/src/plugins/overlay';
import type HSPinInput from 'preline/src/plugins/pin-input';
import type HSRangeSlider from 'preline/src/plugins/range-slider';
import type HSRemoveElement from 'preline/src/plugins/remove-element';
import type HSScrollNav from 'preline/src/plugins/scroll-nav';
import type HSScrollspy from 'preline/src/plugins/scrollspy';
import type HSSelect from 'preline/src/plugins/select';
import type HSStepper from 'preline/src/plugins/stepper';
import type HSStrongPassword from 'preline/src/plugins/strong-password';
import type HSTabs from 'preline/src/plugins/tabs';
import type HSTextareaAutoHeight from 'preline/src/plugins/textarea-auto-height';
import type HSThemeSwitch from 'preline/src/plugins/theme-switch';
import type HSToggleCount from 'preline/src/plugins/toggle-count';
import type HSTogglePassword from 'preline/src/plugins/toggle-password';
import type HSTooltip from 'preline/src/plugins/tooltip';
import type HSTreeView from 'preline/src/plugins/tree-view';
declare global {
var noUiSlider: typeof INoUiSlider;
var FloatingUIDOM: {
computePosition: (
reference: Element,
floating: HTMLElement,
options?: any
) => Promise<{ x: number; y: number; placement: string }>;
autoUpdate: (
reference: Element,
floating: HTMLElement,
update: () => void,
) => () => void;
offset: (offset: number | [number, number]) => any;
flip: () => any;
};
interface Window {
HS_CLIPBOARD_SELECTOR: string;
HSStaticMethods: IStaticMethods;
$hsCopyMarkupCollection: ICollectionItem<HSCopyMarkup>[];
$hsAccordionCollection: ICollectionItem<HSAccordion>[];
$hsCarouselCollection: ICollectionItem<HSCarousel>[];
$hsCollapseCollection: ICollectionItem<HSCollapse>[];
$hsComboBoxCollection: ICollectionItem<HSComboBox>[];
$hsDataTableCollection: ICollectionItem<HSDataTable>[];
$hsDropdownCollection: ICollectionItem<HSDropdown>[];
$hsFileUploadCollection: ICollectionItem<HSFileUpload>[];
$hsInputNumberCollection: { id: number; element: HSInputNumber }[];
$hsLayoutSplitterCollection: ICollectionItem<HSLayoutSplitter>[];
$hsOverlayCollection: ICollectionItem<HSOverlay>[];
$hsPinInputCollection: ICollectionItem<HSPinInput>[];
$hsRemoveElementCollection: ICollectionItem<HSRemoveElement>[];
$hsRangeSliderCollection: ICollectionItem<HSRangeSlider>[];
$hsScrollNavCollection: ICollectionItem<HSScrollNav>[];
$hsScrollspyCollection: ICollectionItem<HSScrollspy>[];
$hsSelectCollection: ICollectionItem<HSSelect>[];
$hsStepperCollection: ICollectionItem<HSStepper>[];
$hsStrongPasswordCollection: ICollectionItem<HSStrongPassword>[];
$hsTabsCollection: ICollectionItem<HSTabs>[];
$hsTextareaAutoHeightCollection: ICollectionItem<HSTextareaAutoHeight>[];
$hsThemeSwitchCollection: ICollectionItem<HSThemeSwitch>[];
$hsToggleCountCollection: ICollectionItem<HSToggleCount>[];
$hsTogglePasswordCollection: ICollectionItem<HSTogglePassword>[];
$hsTooltipCollection: ICollectionItem<HSTooltip>[];
$hsTreeViewCollection: ICollectionItem<HSTreeView>[];
}
}

View File

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<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">
</head>
<body class="bg-neutral-100 dark:bg-neutral-900 text-gray-600 dark:text-gray-400 font-[Inter]">
<main class="flex flex-col justify-center min-h-[100vh]">
<div class="text-center p-2 sm:p-5 sm-pb-0">
<div class="max-w-[28rem] mx-auto">
<h1 class="text-[6rem] text-gray-800 dark:text-neutral-200">404</h1>
<h2 class="text-3xl text-gray-800 dark:text-neutral-200">Page not found</h2>
<p class="">Sorry, the page you're looking for cannot be found.</p>
<div>
<button type="button">
<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="m12 19-7-7 7-7"></path><path d="M19 12H5"></path></svg>
Back to home
</button>
</div>
</div>
</div>
</main>
<script src="/static/preline.js"></script>
</body>
</html>

View File

@ -0,0 +1,7 @@
<footer class="fixed left-0 right-0 bottom-0 lg:ps-64 h-10 sm:h-16">
<div class="flex justify-between items-center p-2 sm:p-5">
<p class="text-xs sm:text-sm">
&copy; 2025 Xord
</p>
</div>
</footer>

View File

@ -0,0 +1,7 @@
<% layout("layout") -%>
<%- include("guildHeader") -%>
<div class="p-4">
Content
</div>

View File

@ -0,0 +1,311 @@
<% layout("layout") -%>
<%- include("guildHeader") -%>
<!-- Table Section -->
<div id="table" class="--prevent-on-load-init max-w-full overflow-hidden px-4 sm:px-6">
<!-- Card -->
<div class="flex flex-col">
<div class="-m-1.5">
<div class="max-w-full p-1.5 min-w-full inline-block align-middle">
<div class="bg-white border border-gray-200 rounded-lg shadow-xs overflow-hidden dark:bg-neutral-900 dark:border-neutral-800">
<!-- Header -->
<div class="px-6 py-4 gap-3 flex flex-nowrap justify-between items-center border-b border-gray-200 dark:border-neutral-700">
<!-- Input -->
<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" name="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>
<!-- End Input -->
<div class="sm:col-span-2 md:grow">
<div class="flex justify-end gap-x-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>
<div class="hs-dropdown [--placement:bottom-right] relative inline-block h-full" data-hs-dropdown-auto-close="inside">
<button id="hs-as-table-table-filter-dropdown" 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" aria-haspopup="menu" aria-expanded="false" aria-label="Dropdown">
<svg class="shrink-0 size-3.5" 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="M7 12h10"/><path d="M10 18h4"/></svg>
Filter
</button>
<div class="hs-dropdown-menu transition-[opacity,margin] duration hs-dropdown-open:opacity-100 opacity-0 hidden divide-y divide-gray-200 min-w-48 z-10 bg-white shadow-md rounded-lg mt-2 dark:divide-neutral-700 dark:bg-neutral-800 dark:border dark:border-neutral-700" role="menu" aria-orientation="vertical" aria-labelledby="hs-as-table-table-filter-dropdown">
<div class="divide-y divide-gray-200 dark:divide-neutral-700">
<label for="hs-as-filters-dropdown-all" class="flex py-2.5 px-3">
<input type="radio" name="filterType" class="form-radio shrink-0 mt-0.5 border-gray-200 rounded-full text-blue-600 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-800 dark:border-neutral-700 dark:checked:bg-blue-500 dark:checked:border-blue-500 dark:focus:ring-offset-gray-800" id="hs-as-filters-dropdown-all" checked value="">
<span class="ms-3 text-sm text-gray-800 dark:text-neutral-200">All</span>
</label>
<label for="hs-as-filters-dropdown-published" class="flex py-2.5 px-3">
<input type="radio" name="filterType" class="form-radio shrink-0 mt-0.5 border-gray-200 rounded-full text-blue-600 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-800 dark:border-neutral-700 dark:checked:bg-blue-500 dark:checked:border-blue-500 dark:focus:ring-offset-gray-800" id="hs-as-filters-dropdown-published" value="1">
<span class="ms-3 text-sm text-gray-800 dark:text-neutral-200">Whitelist</span>
</label>
<label for="hs-as-filters-dropdown-pending" class="flex py-2.5 px-3">
<input type="radio" name="filterType" class="form-radio shrink-0 mt-0.5 border-gray-200 rounded-full text-blue-600 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-800 dark:border-neutral-700 dark:checked:bg-blue-500 dark:checked:border-blue-500 dark:focus:ring-offset-gray-800" id="hs-as-filters-dropdown-pending" value="0">
<span class="ms-3 text-sm text-gray-800 dark:text-neutral-200">Blacklist</span>
</label>
</div>
</div>
</div>
<button type="button" class="openFilterModal-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>
Add
<span class="hidden sm:inline">filter</span>
</span>
</button>
</div>
</div>
</div>
<!-- End Header -->
<div class="min-w-full overflow-x-auto">
<!-- Table -->
<table class="min-w-full divide-y divide-gray-200 dark:divide-neutral-700">
<thead class="bg-gray-50 dark:bg-neutral-800 border-none">
<tr>
<th scope="col" class="ps-6 py-3 text-start --exclude-from-ordering">
<label for="hs-at-with-checkboxes-main" class="flex ml-[1px]">
<input type="checkbox" id="selectAllBox" class="form-checkbox shrink-0 border-gray-300 rounded-sm text-blue-600 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-800 dark:border-neutral-600 dark:checked:bg-blue-500 dark:checked:border-blue-500 dark:focus:ring-offset-gray-800">
<span class="sr-only">Checkbox</span>
</label>
</th>
<th scope="col" data-dt-column="name" class="px-6 py-3 text-start">
<div class="flex justify-between items-center gap-x-2 cursor-pointer">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-800 dark:text-neutral-200">
Name
</span>
<svg class="size-3.5 ms-1 -me-0.5 text-gray-400 dark:text-neutral-500" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path class="hs-datatable-ordering-desc:text-blue-600 dark:hs-datatable-ordering-desc:text-blue-500" d="m7 15 5 5 5-5"></path>
<path class="hs-datatable-ordering-asc:text-blue-600 dark:hs-datatable-ordering-asc:text-blue-500" d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
<th scope="col" class="px-6 py-3 text-start">
<div class="flex justify-between items-center gap-x-2 cursor-pointer">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-800 dark:text-neutral-200">
Match
</span>
<svg class="size-3.5 ms-1 -me-0.5 text-gray-400 dark:text-neutral-500" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path class="hs-datatable-ordering-desc:text-blue-600 dark:hs-datatable-ordering-desc:text-blue-500" d="m7 15 5 5 5-5"></path>
<path class="hs-datatable-ordering-asc:text-blue-600 dark:hs-datatable-ordering-asc:text-blue-500" d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
<th scope="col" class="px-6 py-3 text-start">
<div class="flex justify-between items-center gap-x-2 cursor-pointer">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-800 dark:text-neutral-200">
Algorithm
</span>
<svg class="size-3.5 ms-1 -me-0.5 text-gray-400 dark:text-neutral-500" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path class="hs-datatable-ordering-desc:text-blue-600 dark:hs-datatable-ordering-desc:text-blue-500" d="m7 15 5 5 5-5"></path>
<path class="hs-datatable-ordering-asc:text-blue-600 dark:hs-datatable-ordering-asc:text-blue-500" d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
<th scope="col" class="px-6 py-3 text-start">
<div class="flex justify-between items-center gap-x-2 cursor-pointer">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-800 dark:text-neutral-200 text-nowrap">
Case-Sensitive
</span>
<svg class="size-3.5 ms-1 -me-0.5 text-gray-400 dark:text-neutral-500" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path class="hs-datatable-ordering-desc:text-blue-600 dark:hs-datatable-ordering-desc:text-blue-500" d="m7 15 5 5 5-5"></path>
<path class="hs-datatable-ordering-asc:text-blue-600 dark:hs-datatable-ordering-asc:text-blue-500" d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
<th scope="col" class="px-6 py-3 text-start">
<div class="flex justify-between items-center gap-x-2 cursor-pointer">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-800 dark:text-neutral-200">
Type
</span>
<svg class="size-3.5 ms-1 -me-0.5 text-gray-400 dark:text-neutral-500" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path class="hs-datatable-ordering-desc:text-blue-600 dark:hs-datatable-ordering-desc:text-blue-500" d="m7 15 5 5 5-5"></path>
<path class="hs-datatable-ordering-asc:text-blue-600 dark:hs-datatable-ordering-asc:text-blue-500" d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
<th scope="col" class="px-6 py-3 text-start">
<div class="flex justify-between items-center gap-x-2 cursor-pointer">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-800 dark:text-neutral-200 text-nowrap">
Created at
</span>
<svg class="size-3.5 ms-1 -me-0.5 text-gray-400 dark:text-neutral-500" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path class="hs-datatable-ordering-desc:text-blue-600 dark:hs-datatable-ordering-desc:text-blue-500" d="m7 15 5 5 5-5"></path>
<path class="hs-datatable-ordering-asc:text-blue-600 dark:hs-datatable-ordering-asc:text-blue-500" d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-neutral-700">
</tbody>
</table>
<!-- End Table -->
</div>
<!-- Footer -->
<div class="px-6 py-4 gap-3 flex justify-between items-center border-t border-gray-200 dark:border-neutral-700">
<div class="max-w-sm space-y-3">
<select data-hs-select='{
"toggleTag": "<button type=\"button\" aria-expanded=\"false\"></button>",
"toggleClasses": "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",
"dropdownClasses": "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",
"dropdownScope": "window",
"optionClasses": "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",
"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>"
}' class="hidden" data-hs-datatable-page-entities="">
<option value="1">1</option>
<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>
</select>
</div>
<div class="hidden sm:inline-flex items-center gap-x-2" data-hs-datatable-info="">
<p class="text-sm text-gray-600 dark:text-neutral-400">
Showing
<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="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-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="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-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>
<!-- End Footer -->
</div>
</div>
</div>
</div>
<!-- End Card -->
</div>
<!-- End Table Section -->
<!-- Popup -->
<div id="filterModal" class="hs-overlay hidden size-full fixed top-0 start-0 z-80 overflow-x-hidden overflow-y-auto pointer-events-none" role="dialog" tabindex="-1" aria-labelledby="hs-scale-animation-modal-label">
<div class="hs-overlay-animation-target hs-overlay-open:scale-100 hs-overlay-open:opacity-100 scale-95 opacity-0 ease-in-out transition-all duration-200 sm:max-w-lg sm:w-full m-3 sm: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">
Filter out unwanted content from your feeds.
</p>
</div>
<form id="filterForm" 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="formMatch" class="text-input-label">Match</label>
<input type="text" id="formMatch" name="match" 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 statement to match against.
</p>
<p class="mt-2 text-sm text-red-500 hidden peer-invalid:group-[.submitted]:block">
Please enter a match.
</p>
</div>
<div class="relative">
<label for="formAlgorithm" class="text-input-label">Algorithm</label>
<select id="formAlgorithm" name="algorithm" class="peer" data-hs-select='{
"placeholder": "Select option...",
"toggleTag": "<button type=\"button\" aria-expanded=\"false\"></button>",
"toggleClasses": "form-select hs-select-disabled:pointer-events-none hs-select-disabled:opacity-50 select-input peer-invalid:group-[.submitted]:border-red-500 peer-invalid:group-[.submitted]:ring-red-500",
"dropdownScope": "window",
"wrapperClasses": "peer",
"dropdownClasses": "z-80 w-full max-h-72 p-1 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]: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",
"optionClasses": "py-2 px-4 w-full text-sm text-gray-800 cursor-pointer hover:bg-gray-100 rounded-lg focus:outline-hidden focus:bg-gray-100 hs-select-disabled:pointer-events-none hs-select-disabled:opacity-50 dark:bg-neutral-900 dark:hover:bg-neutral-800 dark:text-neutral-200 dark:focus:bg-neutral-800",
"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>"
}' class="hidden" required>
<option value="">Choose</option>
<option value="any">Match Any Word</option>
<option value="all">Match All Words</option>
<option value="exact">Match Exact Expression</option>
<option value="regex">Match Regex</option>
<option value="fuzzy">Match Roughly</option>
</select>
<p class="text-input-help block peer-has-invalid:group-[.submitted]:hidden">
The algorithm used to filter out content.
</p>
<p class="mt-2 text-sm text-red-500 hidden peer-has-invalid:group-[.submitted]:block">
Please select an option.
</p>
</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">Whitelist</span>
<span class="block text-sm text-gray-500 dark:text-neutral-500">Inactive entries will not be processed.</span>
</span>
</label>
<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">Insensitive</span>
<span class="block text-sm text-gray-500 dark:text-neutral-500">Inactive entries will not be processed.</span>
</span>
</label>
</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="#filterModal">
Close
</button>
<button type="submit" form="filterForm" 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>
<!-- End Popup -->
<script>
var guildId = "<%- guild.id %>"
</script>
<% block("scripts").append('<script defer src="/static/js/guild/filters.js"></script>'); %>

View File

@ -0,0 +1,65 @@
<div>
<div class="relative pt-5 before:w-full before:h-[200px] before:-z-10 before:top-0 before:start-0 before:absolute">
<div class="flex sm:items-center gap-5 p-4 sm:p-6 pb-1!">
<div class="shrink-0">
<div class="relative">
<% if (guild.icon) { %>
<img src="<%= guild.iconURL() %>" class="rounded-md sm:rounded-lg size-16 sm:size-20" alt="">
<% } else { %>
<div class="size-16 sm:size-20 rounded-md sm:rounded-lg flex shrink-0 justify-center items-center bg-white dark:bg-neutral-800">
<span class="text-2xl"><%= guild.nameAcronym %></span>
</div>
<% } %>
</div>
</div>
<div class="grow">
<h1 class="text-2xl md:text-3xl font-semibold text-gray-800 dark:text-neutral-200">
<%= guild.name %>
</h1>
<ul class="flex flex-wrap items-center gap-3 mt-2">
<li class="relative">
<span class="text-sm">ID:</span>
<span class="text-sm inline-flex items-center gap-2 text-gray-800 dark:text-neutral-200"><%= guild.id %></span>
</li>
<li class="relative">
<span class="text-sm">Members:</span>
<span class="text-sm inline-flex items-center gap-2 text-gray-800 dark:text-neutral-200"><%= guild.memberCount %></span>
</li>
<li class="relative">
<span class="text-sm">Channels:</span>
<span class="text-sm inline-flex items-center gap-2 text-gray-800 dark:text-neutral-200"><%= guild.channels.channelCountWithoutThreads %></span>
</li>
</ul>
</div>
<div class="ms-auto flex flex-row flex-wrap gap-2">
<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">
<svg class="shrink-0 size-4" viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
Validate channels
</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">
<svg class="shrink-0 size-4" viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
Copy data
</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-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">
<svg class="shrink-0 size-4" viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
Remove bot
</button>
</div>
</div>
</div>
<div class="flex flex-col justify-center items-center mx-auto p-4 sm:p-6">
<div class="flex flex-row w-full pb-1 whitespace-nowrap overflow-x-auto overflow-y-hidden">
<!-- <a href="/guild/<%= guild.id %>" class="inline-flex items-center gap-2 whitespace-nowrap rounded-lg px-3 py-2 text-sm text-gray-800 dark:text-neutral-200 dark:hover:text-neutral-400 dark:focus:text-neutral-400 <%= !isNaN(+guildPage) ? 'bg-white dark:bg-neutral-800 dark:border-neutral-700! border shadow-xs' : 'mx-[1px]' %>">Overview</a> -->
<a href="/guild/<%= guild.id %>/subscriptions" class="inline-flex items-center gap-2 whitespace-nowrap rounded-lg px-3 py-2 text-sm text-blue-500 dark:text-blue-400 dark:hover:text-blue-500 dark:focus:text-blue-500 <%= guildPage === 'subscriptions' ? 'text-gray-800 dark:text-neutral-200 dark:hover:text-neutral-400 dark:focus:text-neutral-400 bg-white dark:bg-neutral-800 dark:border-neutral-700! border shadow-xs' : 'mx-[1px]' %>">Subscriptions</a>
<a href="/guild/<%= guild.id %>/filters" class="inline-flex items-center gap-2 whitespace-nowrap rounded-lg px-3 py-2 text-sm text-blue-500 dark:text-blue-400 dark:hover:text-blue-500 dark:focus:text-blue-500 <%= guildPage === 'filters' ? 'text-gray-800 dark:text-neutral-200 dark:hover:text-neutral-400 dark:focus:text-neutral-400 bg-white dark:bg-neutral-800 dark:border-neutral-700! border shadow-xs' : 'mx-[1px]' %>">Filters</a>
<a href="/guild/<%= guild.id %>/styles" class="inline-flex items-center gap-2 whitespace-nowrap rounded-lg px-3 py-2 text-sm text-blue-500 dark:text-blue-400 dark:hover:text-blue-500 dark:focus:text-blue-500 <%= guildPage === 'styles' ? 'text-gray-800 dark:text-neutral-200 dark:hover:text-neutral-400 dark:focus:text-neutral-400 bg-white dark:bg-neutral-800 dark:border-neutral-700! border shadow-xs' : 'mx-[1px]' %>">Styles</a>
<a href="/guild/<%= guild.id %>/content" class="inline-flex items-center gap-2 whitespace-nowrap rounded-lg px-3 py-2 text-sm text-blue-500 dark:text-blue-400 dark:hover:text-blue-500 dark:focus:text-blue-500 <%= guildPage === 'content' ? 'text-gray-800 dark:text-neutral-200 dark:hover:text-neutral-400 dark:focus:text-neutral-400 bg-white dark:bg-neutral-800 dark:border-neutral-700! border shadow-xs' : 'mx-[1px]' %>">Content</a>
<a href="#" class="inline-flex items-center gap-2 whitespace-nowrap rounded-lg px-3 py-2 text-sm text-blue-500 dark:text-blue-400 dark:hover:text-blue-500 dark:focus:text-blue-500">Settings</a>
</div>
</div>
</div>

View File

@ -0,0 +1,122 @@
<% layout("layout") -%>
<%- include("guildHeader") -%>
<!-- Card Section -->
<div class="max-w-full p-4 md:p-6">
<!-- Grid -->
<div class="grid sm:grid-cols-2 xl:grid-cols-4 gap-4 sm:gap-6">
<!-- Card -->
<div class="flex flex-col bg-white border shadow-xs rounded-md dark:bg-neutral-800 dark:border-neutral-700">
<div class="p-4 md:p-5 flex gap-x-4">
<div class="shrink-0 flex justify-center items-center size-[46px] bg-gray-100 rounded-lg dark:bg-neutral-800">
<svg class="shrink-0 size-5 text-gray-600 dark:text-neutral-400" 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="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<div class="grow">
<div class="flex items-center gap-x-2">
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-neutral-500">
Total Members
</p>
<div class="hs-tooltip">
<div class="hs-tooltip-toggle">
<svg class="shrink-0 size-4 text-gray-500 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="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
<span class="hs-tooltip-content hs-tooltip-shown:opacity-100 hs-tooltip-shown:visible opacity-0 transition-opacity inline-block absolute invisible z-10 py-1 px-2 bg-gray-900 text-xs font-medium text-white rounded-sm shadow-xs dark:bg-neutral-700" role="tooltip">
The number of server members
</span>
</div>
</div>
</div>
<div class="mt-1 flex items-center gap-x-2">
<h3 class="text-xl sm:text-2xl font-medium text-gray-800 dark:text-neutral-200">
<%= guild.memberCount %>
</h3>
<!-- <span class="flex items-center gap-x-1 text-green-600">
<svg class="inline-block size-4 self-center" 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"><polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/></svg>
<span class="inline-block text-sm">
1.7%
</span>
</span> -->
</div>
</div>
</div>
</div>
<!-- End Card -->
<!-- Card -->
<div class="flex flex-col bg-white border shadow-xs rounded-md dark:bg-neutral-800 dark:border-neutral-700">
<div class="p-4 md:p-5 flex gap-x-4">
<div class="shrink-0 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"><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>
</div>
<div class="grow">
<div class="flex items-center gap-x-2">
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-neutral-500">
Channels
</p>
</div>
<div class="mt-1 flex items-center gap-x-2">
<h3 class="text-xl sm:text-2xl font-medium text-gray-800 dark:text-neutral-200">
<%= guild.channels.channelCountWithoutThreads %>
</h3>
</div>
</div>
</div>
</div>
<!-- End Card -->
<!-- Card -->
<div class="flex flex-col bg-white border shadow-xs rounded-md dark:bg-neutral-800 dark:border-neutral-700">
<div class="p-4 md:p-5 flex gap-x-4">
<div class="shrink-0 flex justify-center items-center size-[46px] bg-gray-100 rounded-lg dark:bg-neutral-800">
<svg class="shrink-0 size-5 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"><ellipse cx="12" cy="5" rx="9" ry="3"></ellipse><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path></svg>
</div>
<div class="grow">
<div class="flex items-center gap-x-2">
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-neutral-500">
Processed Content
</p>
</div>
<div class="mt-1 flex items-center gap-x-2">
<h3 class="text-xl sm:text-2xl font-medium text-gray-800 dark:text-neutral-200">
0
</h3>
<!-- <span class="flex items-center gap-x-1 text-red-600">
<svg class="inline-block size-4 self-center" 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"><polyline points="22 17 13.5 8.5 8.5 13.5 2 7"/><polyline points="16 17 22 17 22 11"/></svg>
<span class="inline-block text-sm">
1.7%
</span>
</span> -->
</div>
</div>
</div>
</div>
<!-- End Card -->
<!-- Card -->
<div class="flex flex-col bg-white border shadow-xs rounded-md dark:bg-neutral-800 dark:border-neutral-700">
<div class="p-4 md:p-5 flex gap-x-4">
<div class="shrink-0 flex justify-center items-center size-[46px] bg-gray-100 rounded-lg dark:bg-neutral-800">
<svg class="shrink-0 size-5 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"><circle cx="12" cy="12" r="10"></circle><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"></line></svg>
</div>
<div class="grow">
<div class="flex items-center gap-x-2">
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-neutral-500">
Blocked Content
</p>
</div>
<div class="mt-1 flex items-center gap-x-2">
<h3 class="text-xl sm:text-2xl font-medium text-gray-800 dark:text-neutral-200">
0
</h3>
</div>
</div>
</div>
</div>
<!-- End Card -->
</div>
<!-- End Grid -->
</div>
<!-- End Card Section -->

View File

@ -0,0 +1,7 @@
<% layout("layout") -%>
<%- include("guildHeader") -%>
<div class="p-4">
Styles
</div>

View File

@ -0,0 +1,377 @@
<% layout("layout") -%>
<%- include("guildHeader") -%>
<!-- Table Section -->
<div id="table" class="--prevent-on-load-init max-w-full overflow-hidden px-4 sm:px-6">
<!-- Card -->
<div class="flex flex-col">
<div class="-m-1.5">
<div class="max-w-full p-1.5 min-w-full inline-block align-middle">
<div class="bg-white border border-gray-200 rounded-lg shadow-xs overflow-hidden dark:bg-neutral-900 dark:border-neutral-800">
<!-- Header -->
<div class="px-6 py-4 gap-3 flex flex-nowrap justify-between items-center border-b border-gray-200 dark:border-neutral-700">
<!-- Input -->
<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" name="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>
<!-- End Input -->
<div class="sm:col-span-2 md:grow">
<div class="flex justify-end gap-x-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>
<div class="hs-dropdown [--placement:bottom-right] relative inline-block h-full" data-hs-dropdown-auto-close="inside">
<button id="hs-as-table-table-filter-dropdown" 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" aria-haspopup="menu" aria-expanded="false" aria-label="Dropdown">
<svg class="shrink-0 size-3.5" 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="M7 12h10"/><path d="M10 18h4"/></svg>
Filter
</button>
<div class="hs-dropdown-menu transition-[opacity,margin] duration hs-dropdown-open:opacity-100 opacity-0 hidden divide-y divide-gray-200 min-w-48 z-10 bg-white shadow-md rounded-lg mt-2 dark:divide-neutral-700 dark:bg-neutral-800 dark:border dark:border-neutral-700" role="menu" aria-orientation="vertical" aria-labelledby="hs-as-table-table-filter-dropdown">
<div class="divide-y divide-gray-200 dark:divide-neutral-700">
<label for="hs-as-filters-dropdown-all" class="flex py-2.5 px-3">
<input type="radio" name="filterActive" class="form-radio shrink-0 mt-0.5 border-gray-200 rounded-full text-blue-600 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-800 dark:border-neutral-700 dark:checked:bg-blue-500 dark:checked:border-blue-500 dark:focus:ring-offset-gray-800" id="hs-as-filters-dropdown-all" checked value="">
<span class="ms-3 text-sm text-gray-800 dark:text-neutral-200">All</span>
</label>
<label for="hs-as-filters-dropdown-published" class="flex py-2.5 px-3">
<input type="radio" name="filterActive" class="form-radio shrink-0 mt-0.5 border-gray-200 rounded-full text-blue-600 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-800 dark:border-neutral-700 dark:checked:bg-blue-500 dark:checked:border-blue-500 dark:focus:ring-offset-gray-800" id="hs-as-filters-dropdown-published" value="1">
<span class="ms-3 text-sm text-gray-800 dark:text-neutral-200">Active</span>
</label>
<label for="hs-as-filters-dropdown-pending" class="flex py-2.5 px-3">
<input type="radio" name="filterActive" class="form-radio shrink-0 mt-0.5 border-gray-200 rounded-full text-blue-600 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-800 dark:border-neutral-700 dark:checked:bg-blue-500 dark:checked:border-blue-500 dark:focus:ring-offset-gray-800" id="hs-as-filters-dropdown-pending" value="0">
<span class="ms-3 text-sm text-gray-800 dark:text-neutral-200">Inactive</span>
</label>
</div>
</div>
</div>
<button type="button" class="openSubModal-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>
Add
<span class="hidden sm:inline">subscription</span>
</span>
</button>
</div>
</div>
</div>
<!-- End Header -->
<div class="min-w-full overflow-x-auto">
<!-- Table -->
<table class="min-w-full divide-y divide-gray-200 dark:divide-neutral-700">
<thead class="bg-gray-50 dark:bg-neutral-800 border-none">
<tr>
<th scope="col" class="ps-6 py-3 text-start --exclude-from-ordering">
<label for="hs-at-with-checkboxes-main" class="flex ml-[1px]">
<input type="checkbox" id="selectAllBox" class="form-checkbox shrink-0 border-gray-300 rounded-sm text-blue-600 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-800 dark:border-neutral-600 dark:checked:bg-blue-500 dark:checked:border-blue-500 dark:focus:ring-offset-gray-800">
<span class="sr-only">Checkbox</span>
</label>
</th>
<th scope="col" data-dt-column="name" class="px-6 py-3 text-start">
<div class="flex justify-between items-center gap-x-2 cursor-pointer">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-800 dark:text-neutral-200">
Name
</span>
<svg class="size-3.5 ms-1 -me-0.5 text-gray-400 dark:text-neutral-500" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path class="hs-datatable-ordering-desc:text-blue-600 dark:hs-datatable-ordering-desc:text-blue-500" d="m7 15 5 5 5-5"></path>
<path class="hs-datatable-ordering-asc:text-blue-600 dark:hs-datatable-ordering-asc:text-blue-500" d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
<th scope="col" class="px-6 py-3 text-start">
<div class="flex justify-between items-center gap-x-2 cursor-pointer">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-800 dark:text-neutral-200">
URL
</span>
<svg class="size-3.5 ms-1 -me-0.5 text-gray-400 dark:text-neutral-500" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path class="hs-datatable-ordering-desc:text-blue-600 dark:hs-datatable-ordering-desc:text-blue-500" d="m7 15 5 5 5-5"></path>
<path class="hs-datatable-ordering-asc:text-blue-600 dark:hs-datatable-ordering-asc:text-blue-500" d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
<th scope="col" class="px-6 py-3 text-start --exclude-from-ordering">
<div class="flex justify-between items-center gap-x-2">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-800 dark:text-neutral-200">
Channels
</span>
</div>
</th>
<th scope="col" class="px-6 py-3 text-start --exclude-from-ordering">
<div class="flex justify-between items-center gap-x-2">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-800 dark:text-neutral-200">
Filters
</span>
</div>
</th>
<th scope="col" class="px-6 py-3 text-start">
<div class="flex justify-between items-center gap-x-2 cursor-pointer">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-800 dark:text-neutral-200">
Style
</span>
<svg class="size-3.5 ms-1 -me-0.5 text-gray-400 dark:text-neutral-500" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path class="hs-datatable-ordering-desc:text-blue-600 dark:hs-datatable-ordering-desc:text-blue-500" d="m7 15 5 5 5-5"></path>
<path class="hs-datatable-ordering-asc:text-blue-600 dark:hs-datatable-ordering-asc:text-blue-500" d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
<th scope="col" class="px-6 py-3 text-start">
<div class="flex justify-between items-center gap-x-2 cursor-pointer">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-800 dark:text-neutral-200 text-nowrap">
Created at
</span>
<svg class="size-3.5 ms-1 -me-0.5 text-gray-400 dark:text-neutral-500" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path class="hs-datatable-ordering-desc:text-blue-600 dark:hs-datatable-ordering-desc:text-blue-500" d="m7 15 5 5 5-5"></path>
<path class="hs-datatable-ordering-asc:text-blue-600 dark:hs-datatable-ordering-asc:text-blue-500" d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
<th scope="col" class="px-6 py-3 text-start">
<div class="flex justify-between items-center gap-x-2 cursor-pointer">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-800 dark:text-neutral-200">
Status
</span>
<svg class="size-3.5 ms-1 -me-0.5 text-gray-400 dark:text-neutral-500" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path class="hs-datatable-ordering-desc:text-blue-600 dark:hs-datatable-ordering-desc:text-blue-500" d="m7 15 5 5 5-5"></path>
<path class="hs-datatable-ordering-asc:text-blue-600 dark:hs-datatable-ordering-asc:text-blue-500" d="m7 9 5-5 5 5"></path>
</svg>
</div>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-neutral-700">
</tbody>
</table>
<!-- End Table -->
</div>
<!-- Footer -->
<div class="px-6 py-4 gap-3 flex justify-between items-center border-t border-gray-200 dark:border-neutral-700">
<div class="max-w-sm space-y-3">
<select data-hs-select='{
"toggleTag": "<button type=\"button\" aria-expanded=\"false\"></button>",
"toggleClasses": "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",
"dropdownClasses": "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",
"dropdownScope": "window",
"optionClasses": "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",
"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>"
}' class="hidden" data-hs-datatable-page-entities="">
<option value="1">1</option>
<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>
</select>
</div>
<div class="hidden sm:inline-flex items-center gap-x-2" data-hs-datatable-info="">
<p class="text-sm text-gray-600 dark:text-neutral-400">
Showing
<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="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-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="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-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>
<!-- End Footer -->
</div>
</div>
</div>
</div>
<!-- End Card -->
</div>
<!-- End Table Section -->
<!-- Popup -->
<div id="subModal" class="hs-overlay hidden size-full fixed top-0 start-0 z-80 overflow-x-hidden overflow-y-auto pointer-events-none" role="dialog" tabindex="-1" aria-labelledby="hs-scale-animation-modal-label">
<div class="hs-overlay-animation-target hs-overlay-open:scale-100 hs-overlay-open:opacity-100 scale-95 opacity-0 ease-in-out transition-all duration-200 lg:max-w-4xl lg:w-full m-3 lg:mx-auto min-h-[calc(100%-3.5rem)] flex items-center">
<div 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">Subscription</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="subForm" 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="formStyle" class="text-input-label">Message Style</label>
<select id="formStyle" name="message_style" class="peer" data-hs-select='{
"placeholder": "Select option...",
"toggleTag": "<button type=\"button\" aria-expanded=\"false\"></button>",
"toggleClasses": "form-select hs-select-disabled:pointer-events-none hs-select-disabled:opacity-50 select-input peer-invalid:group-[.submitted]:border-red-500 peer-invalid:group-[.submitted]:ring-red-500",
"dropdownScope": "window",
"wrapperClasses": "peer",
"dropdownClasses": "z-80 w-full max-h-72 p-1 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]: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",
"optionClasses": "py-2 px-4 w-full text-sm text-gray-800 cursor-pointer hover:bg-gray-100 rounded-lg focus:outline-hidden focus:bg-gray-100 hs-select-disabled:pointer-events-none hs-select-disabled:opacity-50 dark:bg-neutral-900 dark:hover:bg-neutral-800 dark:text-neutral-200 dark:focus:bg-neutral-800",
"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>"
}' class="hidden" required>
<option value="">Choose</option>
<option>Default style</option>
</select>
<p class="text-input-help block peer-has-invalid:group-[.submitted]:hidden">
Appearance of delivered content.
</p>
<p class="mt-2 text-sm text-red-500 hidden peer-has-invalid:group-[.submitted]:block">
Please select an option.
</p>
</div>
<div>
<label for="formPublishedThreshold" class="text-input-label">Published Threshold</label>
<input type="datetime-local" id="formPublishedThreshold" name="published_threshold" class="form-input text-input invalid:group-[.submitted]:border-red-500 invalid:group-[.submitted]:ring-red-500 peer" required>
<p class="text-input-help block peer-invalid:group-[.submitted]:hidden">
Ignore content older than this date.
</p>
<p class="mt-2 text-sm text-red-500 hidden peer-invalid:group-[.submitted]:block">
Please enter a date &amp; time.
</p>
</div>
<div class="relative">
<label for="formChannelsInput" class="block text-sm font-medium mb-2 dark:text-white">Channels</label>
<select id="formChannels" name="channels" multiple="multiple" data-hs-select='{
"placeholder": "Select option...",
"dropdownClasses": "z-80 w-full max-h-72 p-1 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]: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",
"dropdownScope": "window",
"optionClasses": "py-2 px-4 w-full text-sm text-gray-800 cursor-pointer hover:bg-gray-100 rounded-lg focus:outline-hidden focus:bg-gray-100 hs-select-disabled:pointer-events-none hs-select-disabled:opacity-50 dark:bg-neutral-900 dark:hover:bg-neutral-800 dark:text-neutral-200 dark:focus:bg-neutral-800",
"mode": "tags",
"tagsInputId": "formChannelsInput",
"wrapperClasses": "relative form-select has-invalid:group-[.submitted]:border-red-500 has-invalid:group-[.submitted]:ring-red-500 py-0 ps-0.5 pe-9 min-h-[46px] flex items-center flex-wrap text-nowrap w-full border border-gray-200 rounded-lg text-start text-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400",
"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>",
"tagsInputClasses": "px-2 rounded-xs order-1 text-sm outline-hidden dark:bg-neutral-900 dark:placeholder-neutral-500 dark:text-neutral-400",
"optionTemplate": "<div class=\"flex items-center\"><div class=\"size-8 me-2 flex shrink-0 items-center justify-center text-gray-500 dark:text-neutral-500\" data-icon></div><div><div class=\"text-sm font-semibold text-gray-800 dark:text-neutral-200 \" data-title></div><div class=\"text-xs text-gray-500 dark:text-neutral-500 \" 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>"
}' class="hidden">
<option value="">Choose</option>
<% guild.channels.cache
.filter(channel => channel.type == 0)
.sort((a, b) => a.rawPosition - b.rawPosition)
.forEach(channel => { %>
<option value="<%= channel.id %>" data-hs-select-option='{
"description": "<%= channel.id %>",
"icon": "<svg class=\"shrink-0 size-[16px]\" viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" stroke=\"currentColor\" stroke-width=\"2\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><line x1=\"4\" y1=\"9\" x2=\"20\" y2=\"9\"></line><line x1=\"4\" y1=\"15\" x2=\"20\" y2=\"15\"></line><line x1=\"10\" y1=\"3\" x2=\"8\" y2=\"21\"></line><line x1=\"16\" y1=\"3\" x2=\"14\" y2=\"21\"></line></svg>"
}'>
<%= channel.name %>
</option>
<% }); %>
</select>
<p class="mt-2 text-sm text-gray-500 dark:text-neutral-500">
Send content to these channels.
</p>
</div>
<div class="relative">
<label for="formFilters" class="block text-sm font-medium mb-2 dark:text-white">Filters</label>
<select id="formFilters" name="filters" multiple="multiple" data-hs-select='{
"placeholder": "Select option...",
"dropdownClasses": "z-80 w-full max-h-72 p-1 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]: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",
"dropdownScope": "window",
"optionClasses": "py-2 px-4 w-full text-sm text-gray-800 cursor-pointer hover:bg-gray-100 rounded-lg focus:outline-hidden focus:bg-gray-100 hs-select-disabled:pointer-events-none hs-select-disabled:opacity-50 dark:bg-neutral-900 dark:hover:bg-neutral-800 dark:text-neutral-200 dark:focus:bg-neutral-800",
"mode": "tags",
"tagsInputId": "formFiltersInput",
"wrapperClasses": "relative ps-0.5 pe-9 min-h-[46px] flex items-center flex-wrap text-nowrap w-full border border-gray-200 rounded-lg text-start text-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400",
"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=\"ms-1 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>",
"tagsInputClasses": "py-3 px-2 rounded-lg order-1 text-sm outline-hidden dark:bg-neutral-900 dark:placeholder-neutral-500 dark:text-neutral-400",
"optionTemplate": "<div class=\"flex items-center\"><div class=\"size-8 me-2 flex shrink-0 items-center justify-center text-gray-500 dark:text-neutral-500\" data-icon></div><div><div class=\"text-sm font-semibold text-gray-800 dark:text-neutral-200 \" data-title></div><div class=\"text-xs text-gray-500 dark:text-neutral-500 \" 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>",
"extraMarkup": "<div class=\"absolute top-1/2 end-3 -translate-y-1/2\"><svg class=\"shrink-0 size-3.5 text-gray-500 dark:text-neutral-500 \" xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m7 15 5 5 5-5\"/><path d=\"m7 9 5-5 5 5\"/></svg></div>"
}' class="hidden">
<option value="">Choose</option>
<option>Filter 1</option>
<option>Filter 2</option>
<option>Filter 3</option>
</select>
<p class="mt-2 text-sm text-gray-500 dark:text-neutral-500">
Filter out unwanted content.
</p>
</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>
</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="#subModal">
Close
</button>
<button type="submit" form="subForm" 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>
<!-- End Popup -->
<script>
var guildId = "<%- guild.id %>"
var channels = <%- 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 defer src="/static/js/guild/subscriptions.js"></script>'); %>

46
src/client/views/home.ejs Normal file
View File

@ -0,0 +1,46 @@
<% layout("layout") -%>
<div class="p-4 sm:p-6 space-y-4 sm:space-y-6">
<h1 class="text-4xl">Home Page</h1>
<p>
This is some placeholder text.
</p>
<p>
Lorem ipsum dolor, sit amet consectetur adipisicing elit. Nulla aliquid temporibus sed odit blanditiis, repellendus id quibusdam voluptas harum vel culpa eligendi nihil corporis exercitationem ipsam a, deserunt voluptatibus soluta.
Lorem, ipsum dolor sit amet consectetur adipisicing elit. Animi fugit necessitatibus assumenda eum. Sint est officiis ad laborum, dolores, illum explicabo error, doloribus repudiandae animi quis quam mollitia expedita fugiat!
Lorem ipsum dolor sit amet consectetur adipisicing elit. Rerum asperiores provident et expedita ea unde vitae aut blanditiis, beatae sed rem a ad, quis quia dolorem ullam omnis magni sit?
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Optio ipsum voluptatibus enim laboriosam dolorem eveniet. Vitae cumque recusandae molestias molestiae necessitatibus nam quidem incidunt officia dignissimos, facilis, mollitia quaerat ad.
</p>
<p>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Cupiditate hic odio non ad expedita itaque accusantium nostrum. Veritatis provident quaerat, quibusdam ullam possimus beatae corrupti repudiandae! Accusantium dolorem enim cupiditate?
Lorem ipsum dolor sit amet consectetur adipisicing elit. Consequatur ullam exercitationem voluptas ducimus, veritatis repellat mollitia inventore recusandae tempore numquam voluptatibus dolores perferendis minima ut omnis, corrupti deserunt. Debitis, fuga?
Lorem ipsum dolor sit amet consectetur adipisicing elit. Libero natus quod iure id cum a eligendi et laudantium in incidunt, consequatur perspiciatis aliquid aliquam eaque debitis deserunt. Alias, accusamus veniam.
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quia, laudantium. Reprehenderit beatae autem illum et similique nesciunt suscipit? Quo dignissimos delectus minus, distinctio vero nesciunt iusto architecto accusamus eius iste.
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Dolor harum vero, ut laboriosam error dolores itaque animi quo accusamus consectetur. Distinctio pariatur quo iste dolorem a nobis eum fuga delectus.
</p>
<p>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Sit cumque rerum commodi aut modi maiores, incidunt suscipit odit? Natus aspernatur amet, est sapiente cupiditate consequuntur nam rerum autem accusamus culpa!
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Facere quaerat rem amet, laboriosam eius facilis minus pariatur magnam ea molestiae totam vitae suscipit veniam nihil explicabo voluptatibus sequi deleniti. Earum!
Lorem ipsum dolor sit amet consectetur adipisicing elit. Nam dolores exercitationem voluptas qui dolor consequuntur nostrum quidem, similique, incidunt voluptatum cum nulla. Quis odio ducimus cum eos saepe officiis at.
</p>
<p>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Magni ratione quaerat, sequi nostrum eius odio corrupti deserunt corporis, voluptates ex quas hic laborum non consequatur neque commodi, fuga inventore assumenda.
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Quibusdam aperiam distinctio numquam esse perferendis, placeat, pariatur consequatur nulla atque amet voluptatem molestiae quasi veniam quis sunt similique, modi ipsam! Consectetur!
</p>
<p>
Lorem ipsum dolor sit amet consectetur, adipisicing elit. Illo consequuntur neque numquam quam cumque dolores debitis dolorum exercitationem ratione optio fuga libero itaque, et ut. Tempore repellendus sit impedit illo.
Lorem ipsum dolor sit amet consectetur adipisicing elit. Eveniet velit natus inventore unde rerum nemo, aliquam cum nihil mollitia eligendi. Iusto aliquam ea molestias, nobis nostrum expedita delectus neque saepe.
Lorem ipsum dolor, sit amet consectetur adipisicing elit. Quos odit sint cumque et perspiciatis debitis magnam inventore ratione magni, architecto sequi vel ipsum sunt id minus vitae necessitatibus, neque voluptatem!
Lorem ipsum dolor, sit amet consectetur adipisicing elit. Iste, delectus facere enim veritatis reiciendis esse non ex perspiciatis explicabo incidunt pariatur fuga impedit officiis doloribus commodi quam deleniti fugiat modi.
Lorem ipsum dolor, sit amet consectetur adipisicing elit. Iste odit consequuntur neque, voluptate aut similique cupiditate ex asperiores excepturi unde sed totam sapiente, placeat praesentium sit corrupti officia minima veritatis.
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Dolorum rem quidem omnis fuga aspernatur, error qui maiores blanditiis rerum suscipit enim, totam vero, eligendi beatae laudantium inventore cumque mollitia impedit.
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Tenetur, maxime soluta consequuntur est ullam quia dolor, consequatur quasi ut porro ipsam! Maxime labore obcaecati dolorum, reprehenderit sunt repudiandae consequuntur assumenda.
</p>
<p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Vitae atque explicabo consequuntur quo totam similique magni laboriosam, reprehenderit quos deleniti pariatur ab esse sed fugiat accusantium veritatis, voluptas possimus ut?
Lorem ipsum dolor sit amet consectetur adipisicing elit. Odio ducimus esse quae ad sapiente possimus quisquam dolorum ab error, voluptatum libero eveniet tenetur dignissimos id sunt, ullam, facere corrupti eaque.
Lorem ipsum dolor sit amet consectetur adipisicing elit. Ducimus laborum id temporibus quis eveniet. Accusantium consequatur, beatae et facilis natus pariatur vitae aliquam possimus. Enim ratione odio harum eos hic?
</p>
</div>

View File

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<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">
</head>
<body class="bg-neutral-100 dark:bg-neutral-900 text-gray-600 dark:text-gray-400 font-[Inter]">
<%- include("sidebar") -%>
<!-- Content -->
<div class="w-full lg:ps-64">
<%- body -%>
</div>
<!-- End Content -->
<!-- <%- include("footer") -%> -->
<script type="text/javascript" src="/static/foreign/jquery.js"></script>
<script type="text/javascript" src="/static/foreign/dataTables.js"></script>
<script type="text/javascript" src="/static/foreign/dataTablesSelect.js"></script>
<script type="text/javascript" src="/static/foreign/floatingUiCore.js"></script>
<script type="text/javascript" src="/static/foreign/floatingUiDOM.js"></script>
<script type="text/javascript" src="/static/foreign/preline.js"></script>
<script type="text/javascript" src="/static/js/main.js"></script>
<%- block("scripts").toString() %>
</body>
</html>

View File

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<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">
</head>
<body class="h-screen flex justify-center items-center bg-neutral-100 dark:bg-neutral-900 text-gray-600 dark:text-gray-400 font-[Inter]">
<div class="mt-7 w-[26rem] bg-white border border-gray-200 rounded-xl shadow-xs dark:bg-neutral-900 dark:border-neutral-700">
<div class="p-4 sm:p-7">
<div class="text-center">
<h1 class="block text-2xl font-bold text-gray-800 dark:text-white">Sign in</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-neutral-400">
Login is required to use Relay.
</p>
</div>
<div class="mt-5">
<a href="/auth/api" class="w-full py-3 px-4 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-900 dark:border-neutral-700 dark:text-white dark:hover:bg-neutral-800 dark:focus:bg-neutral-800">
<svg class="w-4 h-auto" xmlns="http://www.w3.org/2000/svg" width="46" height="46" fill="currentColor" class="bi bi-discord" viewBox="0 0 16 16">
<path d="M13.545 2.907a13.2 13.2 0 0 0-3.257-1.011.05.05 0 0 0-.052.025c-.141.25-.297.577-.406.833a12.2 12.2 0 0 0-3.658 0 8 8 0 0 0-.412-.833.05.05 0 0 0-.052-.025c-1.125.194-2.22.534-3.257 1.011a.04.04 0 0 0-.021.018C.356 6.024-.213 9.047.066 12.032q.003.022.021.037a13.3 13.3 0 0 0 3.995 2.02.05.05 0 0 0 .056-.019q.463-.63.818-1.329a.05.05 0 0 0-.01-.059l-.018-.011a9 9 0 0 1-1.248-.595.05.05 0 0 1-.02-.066l.015-.019q.127-.095.248-.195a.05.05 0 0 1 .051-.007c2.619 1.196 5.454 1.196 8.041 0a.05.05 0 0 1 .053.007q.121.1.248.195a.05.05 0 0 1-.004.085 8 8 0 0 1-1.249.594.05.05 0 0 0-.03.03.05.05 0 0 0 .003.041c.24.465.515.909.817 1.329a.05.05 0 0 0 .056.019 13.2 13.2 0 0 0 4.001-2.02.05.05 0 0 0 .021-.037c.334-3.451-.559-6.449-2.366-9.106a.03.03 0 0 0-.02-.019m-8.198 7.307c-.789 0-1.438-.724-1.438-1.612s.637-1.613 1.438-1.613c.807 0 1.45.73 1.438 1.613 0 .888-.637 1.612-1.438 1.612m5.316 0c-.788 0-1.438-.724-1.438-1.612s.637-1.613 1.438-1.613c.807 0 1.451.73 1.438 1.613 0 .888-.631 1.612-1.438 1.612"/>
</svg>
Sign in with Discord
</a>
</div>
<% if (error != "") { %>
<%= error %>
<% } if (success != "") { %>
<%= success %>
<% } %>
</div>
</div>
<script src="/static/preline.js"></script>
</body>
</html>

View File

@ -0,0 +1,144 @@
<!-- Mobile Sidebar Controls -->
<header class="mobile-sidebar-header">
<nav class="mobile-sidebar-nav">
<a href="/" class="mobile-sidebar-brand">Relay</a>
<button type="button" class="ml-auto" data-hs-overlay="#layoutSidebarMain">
<svg class="shrink-0 size-6" 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"><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg>
</button>
</nav>
</header>
<!-- End Mobile Sidebar Controls -->
<!-- Sidebar -->
<aside id="layoutSidebarMain" class="sidebar-aside main-sidebar hs-overlay hs-overlay-open:!translate-x-0" role="dialog" tabindex="-1">
<div class="sidebar-container">
<!-- Sidebar Header -->
<div class="sidebar-header">
<a href="/" class="sidebar-header-brand">
Relay
</a>
<button type="button" class="lg:hidden" data-hs-overlay="#layoutSidebarMain">
<svg class="shrink-0 size-5" 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"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
<!-- End Sidebar Header -->
<!-- Sidebar Content -->
<div class="sidebar-content">
<nav class="sidebar-nav">
<ul class="flex flex-col space-y-1">
<li>
<a href="/" class="sidebar-btn">
<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 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
Dashboard
</a>
</li>
<li>
<button type="button" class="sidebar-btn" data-hs-overlay="#layoutSidebarGuilds">
<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"><polygon points="12 2 2 7 12 12 22 7 12 2"></polygon><polyline points="2 17 12 22 22 17"></polyline><polyline points="2 12 12 17 22 12"></polyline></svg>
Servers
<svg class="block ms-auto 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"><polyline points="9 18 15 12 9 6"></polyline></svg>
<svg class="hidden ms-auto 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"><polyline points="15 18 9 12 15 6"></polyline></svg>
</button>
</li>
<li>
<a href="https://gitea.cor.bz/corbz/pyrss-ng" class="sidebar-btn" target="_blank">
<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"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>
Source Code
</a>
</li>
<li>
<a href="https://gitea.cor.bz/corbz/pyrss-ng" class="sidebar-btn" target="_blank">
<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"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>
Help
</a>
</li>
<li>
<a href="#" class="sidebar-btn">
<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"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
Settings
</a>
</li>
</ul>
</nav>
</div>
<!-- End Sidebar Content -->
<!-- Sidebar Footer -->
<div class="mt-auto p-2 border-t border-gray-200 dark:border-neutral-700">
<div class="hs-dropdown [--strategy:absolute] [--auto-close:inside] relative w-full inline-flex">
<button type="button" id="userMenu" class="w-full inline-flex shrink-0 items-center gap-x-2 p-2 text-start text-sm text-gray-800 rounded-lg hover:bg-gray-100 focus:outline-hidden focus:bg-gray-100 dark:text-neutral-200 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700">
<img class="shrink-0 size-5 rounded-full" src="https://cdn.discordapp.com/avatars/<%= user.id %>/<%= user.avatar %>" alt="Avatar">
<%= user.username %>
<svg class="block hs-dropdown-open:hidden ms-auto 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="m18 15-6-6-6 6"/></svg>
<svg class="hidden hs-dropdown-open:block ms-auto 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="m6 9 6 6 6-6"/></svg>
</button>
<div class="hs-dropdown-menu hs-dropdown-open:opacity-100 mb-2! w-60 transition-[opacity,margin] duration opacity-0 hidden z-20 bg-white border border-gray-200 rounded-lg shadow-lg dark:bg-neutral-900 dark:border-neutral-700" role="menu">
<!-- <div class="p-1">
<a href="#" class="flex items-center gap-x-3 py-2 px-3 rounded-lg text-sm text-gray-800 hover:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none focus:outline-hidden focus:bg-gray-100 dark:text-neutral-300 dark:hover:bg-neutral-800 dark:focus:bg-neutral-800">
Settings
</a>
</div>
<hr class="border-gray-200 dark:border-neutral-700"> -->
<div class="p-1">
<div class="flex justify-between items-center py-1 px-3">
<label for="themeSwitch" class="text-sm text-gray-800 dark:text-neutral-300">Dark mode</label>
<div class="relative inline-block">
<input type="checkbox" name="themeSwitch" id="themeSwitch" class="form-radio relative w-11 h-6 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-5 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" data-hs-theme-switch="">
</div>
</div>
</div>
<hr class="border-gray-200 dark:border-neutral-700">
<div class="p-1">
<a href="/auth/logout" class="flex items-center gap-x-3 py-2 px-3 rounded-lg text-sm text-gray-800 hover:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none focus:outline-hidden focus:bg-gray-100 dark:text-neutral-300 dark:hover:bg-neutral-800 dark:focus:bg-neutral-800">
Sign out
</a>
</div>
</div>
</div>
</div>
<!-- End Sidebar Footer -->
</div>
</aside>
<!-- End Sidebar -->
<!-- Guild Sidebar -->
<aside id="layoutSidebarGuilds" class="sidebar-aside guild-sidebar hs-overlay hs-overlay-open:!translate-x-0 [--is-layout-affect:true]" role="dialog" tabindex="-1">
<div class="sidebar-container">
<!-- Guild Sidebar Header -->
<div class="sidebar-header">
<span class="flex-none text-gray-600 dark:text-gray-400 text-sm font-semibold">
Discord Servers
</span>
<button type="button" data-hs-overlay="#layoutSidebarGuilds">
<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"><polyline points="15 18 9 12 15 6"></polyline></svg>
</button>
</div>
<!-- End Guild Sidebar Header -->
<!-- Guild Sidebar Content -->
<div class="sidebar-content">
<nav class="sidebar-nav">
<ul class="flex flex-col space-y-1">
<% guilds.forEach(guild => { %>
<li>
<a href="/guild/<%= guild.id %>" class="sidebar-btn">
<% if (guild.icon) { %>
<img class="size-[28px] rounded-sm" src="<%= guild.iconURL() %>" alt="<%= guild.name %>">
<% } else { %>
<div class="size-[28px] flex shrink-0 justify-center items-center rounded-sm bg-neutral-100 dark:bg-neutral-900">
<span class="text-xs"><%= guild.name.split(" ").slice(0, 2).map(word => word[0].toUpperCase()).join(""); %></span>
</div>
<% } %>
<div>
<span class="text-base"><%= guild.name %></span>
<!-- <span class="text-sm font-mono text-gray-600 dark:text-gray-400"><%= guild.id %></span> -->
</div>
</a>
</li>
<% }); %>
</ul>
</nav>
</div>
<!-- End Guild Sidebar Content -->
</div>
</aside>
<!-- End Guild Sidebar -->

4
src/db/db.ts Normal file
View File

@ -0,0 +1,4 @@
import knex from "knex";
import knexConfig from "@server/../knexfile";
export const db = knex(knexConfig);

View File

@ -0,0 +1,19 @@
import type { Knex } from "knex";
const TABLE = "subscriptions";
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable(TABLE, table => {
table.increments("id").primary();
table.string("name").notNullable();
table.string("url").notNullable();
table.string("guild_id").notNullable();
table.boolean("active").notNullable().defaultTo(true);
table.timestamps(true, true);
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TABLE);
}

View File

@ -0,0 +1,21 @@
import type { Knex } from "knex";
const TABLE = "channels";
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable(TABLE, table => {
table.increments("id").primary();
table.string("channel_id").notNullable();
table.integer("subscription_id")
.unsigned()
.notNullable()
.references("id").inTable("subscriptions")
.onDelete("CASCADE");
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TABLE);
}

View File

@ -0,0 +1,25 @@
import type { Knex } from "knex";
const TABLE = "filters";
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable(TABLE, table => {
table.increments("id").primary();
table.string("guild_id").notNullable();
table.string("name").notNullable();
table.string("match").notNullable();
table.enum("algorithm", [
"any", "all", "exact",
"regex", "fuzzy"
]).notNullable();
table.boolean("is_insensitive").notNullable();
table.boolean("is_whitelist").notNullable();
table.timestamps(true, true);
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TABLE);
}

View File

@ -0,0 +1,12 @@
export interface Filter {
id: number;
guild_id: string;
name: string;
match: string;
algorithm: string;
is_insensitive: boolean;
is_whitelist: boolean;
created_at: Date;
updated_at: Date;
}

View File

@ -0,0 +1,18 @@
import { Url } from "url";
export interface Subscription {
id: number;
name: string;
url: Url;
guild_id: string;
active: boolean;
created_at: Date;
updated_at: Date;
channels?: string[];
}
export interface Channel {
id: number;
channel_id: string;
subscription_id: number;
}

View File

@ -0,0 +1,42 @@
import { subscribe } from "diagnostics_channel";
import { Knex, SchemaBuilder } from "knex";
const TABLE = "channels";
export async function seed(knex: Knex): Promise<void> {
// Deletes ALL existing entries
await knex(TABLE).del();
// Inserts seed entries
await knex(TABLE).insert([
{
channel_id: "1204426363440861207",
subscription_id: 1,
},
{
channel_id: "1204540472031322192",
subscription_id: 1,
},
{
channel_id: "819332348214247471",
subscription_id: 10
},
{
channel_id: "819325370087112747",
subscription_id: 10
},
{
channel_id: "819334448268181515",
subscription_id: 14
},
{
channel_id: "819325370087112747",
subscription_id: 14
},
{
channel_id: "918648963492618261",
subscription_id: 14
}
]);
};

123
src/db/seeds/test_sub.ts Normal file
View File

@ -0,0 +1,123 @@
import { Knex } from "knex";
const TABLE = "subscriptions";
export async function seed(knex: Knex): Promise<void> {
// Deletes ALL existing entries
await knex(TABLE).del();
// Inserts seed entries
await knex(TABLE).insert([
// pyrss guild
{
name: "BBC News · Home",
url: "http://feeds.bbci.co.uk/news/rss.xml",
guild_id: "1204426362794811453",
active: true
},
{
name: "BBC News · World",
url: "http://feeds.bbci.co.uk/news/world/rss.xml",
guild_id: "1204426362794811453",
active: false
},
{
name: "BBC News · Entertainment",
url: "http://feeds.bbci.co.uk/news/entertainment_and_arts/rss.xml",
guild_id: "1204426362794811453",
active: true
},
{
name: "BBC News · Technology",
url: "https://feeds.bbci.co.uk/news/technology/rss.xml",
guild_id: "1204426362794811453",
active: true
},
{
name: "BBC News · Science & Environment",
url: "https://feeds.bbci.co.uk/news/science_and_environment/rss.xml",
guild_id: "1204426362794811453",
active: false
},
{
name: "BBC News · Health",
url: "https://feeds.bbci.co.uk/news/health/rss.xml",
guild_id: "1204426362794811453",
active: true
},
{
name: "BBC News · Politics",
url: "https://feeds.bbci.co.uk/news/politics/rss.xml",
guild_id: "1204426362794811453",
active: false
},
{
name: "BBC News · US & Canada",
url: "https://feeds.bbci.co.uk/news/world/us_and_canada/rss.xml",
guild_id: "1204426362794811453",
active: false
},
{
name: "TechRadar · Phones",
url: "https://www.techradar.com/uk/feeds/tag/phones",
guild_id: "1204426362794811453",
active: true
},
// bot testing guild
{
name: "BBC News · Home",
url: "http://feeds.bbci.co.uk/news/rss.xml",
guild_id: "819325370087112744",
active: true
},
{
name: "BBC News · World",
url: "http://feeds.bbci.co.uk/news/world/rss.xml",
guild_id: "819325370087112744",
active: false
},
{
name: "BBC News · Entertainment",
url: "http://feeds.bbci.co.uk/news/entertainment_and_arts/rss.xml",
guild_id: "819325370087112744",
active: true
},
{
name: "BBC News · Technology",
url: "https://feeds.bbci.co.uk/news/technology/rss.xml",
guild_id: "819325370087112744",
active: true
},
{
name: "BBC News · Science & Environment",
url: "https://feeds.bbci.co.uk/news/science_and_environment/rss.xml",
guild_id: "819325370087112744",
active: false
},
{
name: "BBC News · Health",
url: "https://feeds.bbci.co.uk/news/health/rss.xml",
guild_id: "819325370087112744",
active: true
},
{
name: "BBC News · Politics",
url: "https://feeds.bbci.co.uk/news/politics/rss.xml",
guild_id: "819325370087112744",
active: false
},
{
name: "BBC News · US & Canada",
url: "https://feeds.bbci.co.uk/news/world/us_and_canada/rss.xml",
guild_id: "819325370087112744",
active: false
},
{
name: "TechRadar · Phones",
url: "https://www.techradar.com/uk/feeds/tag/phones",
guild_id: "819325370087112744",
active: true
},
]);
};

47
src/knexfile.ts Normal file
View File

@ -0,0 +1,47 @@
import path from "path";
import dotenv from "dotenv";
dotenv.config(); // I env vars are already loaded, but this file is invoked directly sometimes.
const {
DB_CLIENT = "sqlite", // Default:
SQLITE_FILE = path.resolve(__dirname, "../db.sqlite"), // use sqlite if not specified
PG_HOST = "",
PG_PORT = "",
PG_USER = "",
PG_PASSWORD = "",
PG_DATABASE = ""
} = process.env;
const dbConfig = {
sqlite: {
client: "sqlite3",
connection: {
filename: SQLITE_FILE
},
useNullAsDefault: true
},
postgresql: {
client: "pg",
connection: {
host: PG_HOST,
port: PG_PORT,
user: PG_USER,
password: PG_PASSWORD,
database: PG_DATABASE
}
}
}
const knexConfig = {
...dbConfig[DB_CLIENT as keyof typeof dbConfig],
migrations: {
tableName: "knex_migrations",
directory: path.resolve(__dirname, "./db/migrations")
},
seeds: {
directory: path.resolve(__dirname, "./db/seeds")
}
}
export default knexConfig;

View File

@ -0,0 +1,54 @@
import { Request, Response, NextFunction } from "express";
import passport, { PassportStatic } from "passport"
import { Strategy, Profile } from "passport-discord";
export const get = async (_request: Request, response: Response) => {
response.render("login", {
title: "Login - Relay"
});
};
export const authenticate = async (request: Request, response: Response, next: NextFunction) => {
passport.authenticate("discord", {
successRedirect: "/",
failureRedirect: "/auth/login",
failureFlash: true
})(request, response, next);
};
export const logout = async (request: Request, response: Response, next: NextFunction) => {
request.logout(error => {
if (error) { return next(); }
response.redirect("/");
});
};
export const setupPassport = (passport: PassportStatic) => {
const scopes: Array<string> = ["identify"];
const authorisedUserIds: Array<string> = process.env.DISCORD_USER_IDS?.split("/") || [];
passport.use(new Strategy({
clientID: process.env.CLIENT_ID || "",
clientSecret: process.env.CLIENT_SECRET || "",
callbackURL: process.env.CALLBACK_URL || "",
scope: scopes
},
(_accessToken: string, _refreshToken: string, profile: Profile, callback: CallableFunction) => {
if (authorisedUserIds.includes(profile.id)) {
return callback(null, profile);
}
return callback(null, false, {
"message": "User ID is not authorised!"
})
}));
passport.serializeUser((user, done) => {
done(null, user);
});
passport.deserializeUser((user: any, done) => {
done(null, user);
});
}
export default { get, authenticate, logout };

View File

@ -0,0 +1,19 @@
import { Request, Response } from "express";
import { client as bot } from "@bot/bot";
export const get = async (request: Request, response: Response) => {
const guildId = request.params.guildId;
const guild = bot.guilds.cache.get(guildId);
if (!guild) {
response.status(404).send("404: guild not found");
return;
}
response.render("guild/content", {
title: `${guild.name} - Relay`,
guild: guild,
});
};
export default { get };

View File

@ -0,0 +1,88 @@
import { Request, Response } from "express";
import { buildDatatableQuery } from "@utils/datatable";
import { db } from "@db/db";
import { Filter } from "@db/models/filters.model";
const isPostgres = db.client.config.client === "pg";
const TABLE = "filters";
export const datatable = async (request: Request, response: Response) => {
try {
let query = db(TABLE).where({ guild_id: request.params.guildId });
const datatableQuery = await buildDatatableQuery(request as any, query, TABLE);
const { recordsTotal, recordsFiltered } = datatableQuery;
const data = await datatableQuery.query;
console.debug(`total: ${recordsTotal} filtered: ${recordsFiltered} filtered+paged: ${data.length}`);
response.json({
data,
recordsFiltered,
recordsTotal,
});
}
catch (error) {
console.error(error);
response.status(500).json({ error: "Failed to fetch datatable for filters" });
}
}
export const get = async (request: Request, response: Response) => {
try {
if (!request.query.id) {
response.status(400).json({ error: "missing 'id' query" });
return;
}
const data = await db("filters")
.select("filters.*")
.where({ "filters.id": request.query.id })
.first();
if (!data) {
response.status(404).json({ message: "no result found" });
return;
}
response.json(data);
}
catch (error) {
console.error(error);
response.status(500).json({ error: "Failed to fetch filter" });
}
}
export const post = async (request: Request, response: Response) => {
try {
console.debug(JSON.stringify(request.body, null, 4));
const guild_id = request.params.guildId;
const { name, match, algorithm, is_insensitive, is_whitelist } = request.body;
if (!name || !match || !algorithm) {
response.status(400).json({ error: "Missing required fields" });
return;
}
const [filter] = await db<Filter>(TABLE)
.insert({
guild_id,
name,
match,
algorithm,
is_insensitive: is_insensitive == "on",
is_whitelist: is_whitelist == "on"
})
.returning("*");
response.status(201).json(filter);
}
catch (error) {
console.error(error);
response.status(500).json({ error: "Failed to create Filter" });
}
}
export default { datatable, get, post }

View File

@ -0,0 +1,19 @@
import { Request, Response } from "express";
import { client as bot } from "@bot/bot";
export const get = async (request: Request, response: Response) => {
const guildId = request.params.guildId;
const guild = bot.guilds.cache.get(guildId);
if (!guild) {
response.status(404).send("404: guild not found");
return;
}
response.render("guild/filters", {
title: `${guild.name} - Relay`,
guild: guild,
});
};
export default { get };

View File

@ -0,0 +1,23 @@
import { Request, Response } from "express";
import { client as bot } from "@bot/bot";
export const get = async (request: Request, response: Response) => {
const guildId = request.params.guildId;
response.redirect(`/guild/${guildId}/subscriptions`);
return
// const guild = bot.guilds.cache.get(guildId);
// if (!guild) {
// response.status(404).send("404: guild not found");
// return;
// }
// response.render("guild/overview", {
// title: `${guild.name} - Relay`,
// guild: guild,
// });
};
export default { get };

View File

@ -0,0 +1,19 @@
import { Request, Response } from "express";
import { client as bot } from "@bot/bot";
export const get = async (request: Request, response: Response) => {
const guildId = request.params.guildId;
const guild = bot.guilds.cache.get(guildId);
if (!guild) {
response.status(404).send("404: guild not found");
return;
}
response.render("guild/styles", {
title: `${guild.name} - Relay`,
guild: guild,
});
};
export default { get };

View File

@ -0,0 +1,156 @@
import { Request, Response } from "express";
import { buildDatatableQuery } from "@utils/datatable";
import { db } from "@db/db";
import { Subscription, Channel } from "@db/models/subs.model";
const isPostgres = db.client.config.client === "pg";
const TABLE = "subscriptions";
export const datatable = async (request: Request, response: Response) => {
try {
let query = db(TABLE).where({ guild_id: request.params.guildId });
const datatableQuery = await buildDatatableQuery(request as any, query, TABLE);
const { recordsTotal, recordsFiltered } = datatableQuery;
query = datatableQuery.query;
query.select("subscriptions.*")
.leftJoin("channels", "subscriptions.id", "=", "channels.subscription_id")
.select(db.raw(isPostgres
? "json_agg(channels.channel_id) as channels"
:"JSON_GROUP_ARRAY(channels.channel_id) as channels"
))
.groupBy("subscriptions.id")
const data = await query;
console.debug(`total: ${recordsTotal} filtered: ${recordsFiltered} filtered+paged: ${data.length}`);
data.forEach((item: any) => {
item.channels = item.channels === "[null]"
? []
: JSON.parse(item.channels);
});
response.json({
data,
recordsFiltered,
recordsTotal,
});
}
catch (error) {
console.error(error);
response.status(500).json({ error: "Failed to fetch datatable for subscriptions" });
}
}
export const get = async (request: Request, response: Response) => {
try {
if (!request.query.id) {
response.status(400).json({ error: "missing 'id' query" });
return;
}
const data = await db("subscriptions")
.leftJoin("channels", "subscriptions.id", "=", "channels.subscription_id")
.select("subscriptions.*")
.select(db.raw(isPostgres
? "json_agg(channels.channel_id) as channels"
:"JSON_GROUP_ARRAY(channels.channel_id) as channels"
))
.groupBy("subscriptions.id")
.where({ "subscriptions.id": request.query.id })
.first();
if (!data) {
response.status(404).json({ message: "no result found" });
return;
}
// Parse empty channel arrays
data.channels = data.channels === "[null]"
? []
: JSON.parse(data.channels);
response.json(data);
}
catch (error) {
console.error(error);
response.status(500).json({ error: "Failed to fetch subscription" });
}
}
export const post = async (request: Request, response: Response) => {
try {
console.debug(JSON.stringify(request.body, null, 4));
const guild_id = request.params.guildId;
const { name, url, message_style, published_threshold, channels, filters, active } = request.body;
if (!name || !url || !message_style || !published_threshold) {
response.status(400).json({ error: "Missing required fields" });
return;
}
const [subscription] = await db<Subscription>(TABLE)
.insert({
name,
url,
guild_id,
active: active === "on",
created_at: new Date(),
updated_at: new Date()
})
.returning("*");
if (channels && channels.length) {
const channelData = channels.map((channel_id: string) => ({
channel_id,
subscription_id: subscription.id
}));
await db<Channel>("channels").insert(channelData);
}
response.status(201).json({
...subscription,
channels: channels ?? []
});
}
catch (error) {
console.error(error);
response.status(500).json({ error: "Failed to create subscription" });
}
}
export const del = async (request: Request, response: Response) => {
try {
const { ids } = request.body;
const guild_id = request.params.guildId;
console.log(JSON.stringify(ids));
console.log(`deleting subs: ${ids}`);
if (!ids || !Array.isArray(ids)) {
response.status(400).json({ error: "invalid request body" });
return;
}
const subscriptionsToDelete = await db<Subscription>(TABLE).whereIn("id", ids).select("guild_id");
if (subscriptionsToDelete.some(sub => sub.guild_id !== guild_id)) {
response.status(400).json({ error: `Some subscriptions do not belong to guild: ${guild_id}` });
return;
}
await db(TABLE).whereIn("id", ids).andWhere("guild_id", guild_id).delete();
response.status(204).json(null);
}
catch (error) {
console.error(error);
response.status(500).json({ error: "Failed to delete subscription" })
}
}
export default { datatable, get, post, del }

View File

@ -0,0 +1,21 @@
import { Request, Response } from "express";
import { client as bot } from "@bot/bot";
export const get = async (request: Request, response: Response) => {
const guildId = request.params.guildId;
const guild = bot.guilds.cache.get(guildId);
if (!guild) {
response.status(404).send("404: guild not found");
return;
}
console.debug(guild.toJSON());
response.render("guild/subscriptions", {
title: `${guild.name} - Relay`,
guild: guild,
});
}
export default { get }

View File

@ -0,0 +1,9 @@
import { Request, Response } from "express";
export const get = async (_request: Request, response: Response) => {
response.render("home", {
title: "Dashboard - Relay"
});
};
export default { get };

View File

@ -0,0 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { client as bot } from "@bot/bot";
export const attachGuilds = (_request: Request, response: Response, next: NextFunction) => {
response.locals.guilds = bot.guilds.cache.map(guild => guild);
next();
};

View File

@ -0,0 +1,6 @@
import { Request, Response, NextFunction } from "express";
export const attachUser = (request: Request, response: Response, next: NextFunction) => {
response.locals.user = request.user;
next();
};

View File

@ -0,0 +1,17 @@
import { Request, Response, NextFunction } from "express";
export const ensureAuthenticated = (request: Request, response: Response, next: NextFunction) => {
if (request.isAuthenticated()) {
return next();
}
response.redirect("/auth/login");
};
export const forwardAuthenticated = (request: Request, response: Response, next: NextFunction) => {
if (request.isAuthenticated()) {
return response.redirect("/");
}
next();
};

View File

@ -0,0 +1,7 @@
import { Request, Response, NextFunction } from "express";
export const flashMiddleware = (request: Request, response: Response, next: NextFunction) => {
response.locals.success = request.flash("success");
response.locals.error = request.flash("error");
next();
};

View File

@ -0,0 +1,7 @@
import { Request, Response, NextFunction } from "express";
export const getGuildPage = (request: Request, response: Response, next: NextFunction) => {
const currentPath = request.path.split("/");
response.locals.guildPage = currentPath[currentPath.length - 1];
next();
};

View File

@ -0,0 +1,11 @@
import { Router } from "express";
import { ensureAuthenticated, forwardAuthenticated } from "@server/middleware/authenticated";
import controller from "@server/controllers/auth.web.controller";
const router = Router();
router.get("/login", forwardAuthenticated, controller.get);
router.get("/logout", ensureAuthenticated, controller.logout);
router.get("/api", forwardAuthenticated, controller.authenticate);
export default router;

View File

@ -0,0 +1,18 @@
import { Router } from "express";
import subApiController from "@server/controllers/guild/sub.api.controller";
import filterApiController from "@server/controllers/guild/filter.api.controller";
const router = Router();
router.get("/:guildId/subscriptions/api/datatable", subApiController.datatable);
router.get("/:guildId/subscriptions/api", subApiController.get);
router.post("/:guildId/subscriptions/api", subApiController.post);
router.delete("/:guildId/subscriptions/api", subApiController.del);
router.get("/:guildId/filters/api/datatable", filterApiController.datatable);
router.get("/:guildId/filters/api", filterApiController.get);
router.post("/:guildId/filters/api", filterApiController.post);
// router.delete("/:guildId/filters/api", filterApiController.del);
export default router;

View File

@ -0,0 +1,17 @@
import { Router } from "express";
import { getGuildPage } from "@server/middleware/guildPage";
import indexWebController from "@server/controllers/guild/index.web.controller";
import subWebController from "@server/controllers/guild/sub.web.controller";
import filterWebController from "@server/controllers/guild/filter.web.controller";
import styleWebController from "@server/controllers/guild/style.web.controller";
import contentWebController from "@server/controllers/guild/content.web.controller";
const router = Router();
router.get("/:guildId", getGuildPage, indexWebController.get);
router.get("/:guildId/subscriptions", getGuildPage, subWebController.get);
router.get("/:guildId/filters", getGuildPage, filterWebController.get);
router.get("/:guildId/styles", getGuildPage, styleWebController.get);
router.get("/:guildId/content", getGuildPage, contentWebController.get);
export default router;

View File

@ -0,0 +1,8 @@
import { Router } from "express";
import controller from "@server/controllers/home.web.controller";
const router = Router();
router.get("/", controller.get);
export default router;

1
src/types/ejs-mate.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module "ejs-mate";

29
src/utils/avatarGen.js Normal file
View File

@ -0,0 +1,29 @@
// Generate an avatar from a given string
const { createCanvas } = require("canvas");
const generateDefaultAvatar = (inputString, colour=null, bgColour=null, width=200, height=200, fontSize=null) => {
if (!inputString) { return; }
const words = inputString.split(" ").slice(0, 2);
const initials = words.map(word => word[0].toUpperCase()).join("");
const canvas = createCanvas(width, height);
const context = canvas.getContext("2d");
// // Background
// context.fillStyle = bgColour ? bgColour : "#" + Math.floor(Math.random()*16777215).toString(16);
// context.fillRect(0, 0, canvas.width, canvas.height);
// Text
const fontSizePixels = fontSize || width * 0.3;
context.fillStyle = colour || "#FFFFFF";
context.font = `bold ${fontSizePixels}px sans-serif`;
context.textAlign = "center";
context.textBaseline = "middle";
context.fillText(initials, canvas.width / 2, canvas.height / 2);
return canvas.toDataURL();
};
module.exports = generateDefaultAvatar;

73
src/utils/datatable.ts Normal file
View File

@ -0,0 +1,73 @@
import { Knex } from "knex";
import { db } from "@db/db";
export interface RequestQuery {
length: string;
start: string;
order: { column: string; dir: string }[];
columns: { [key: string]: { data: string; searchable: string } };
search: { value: string };
filters: { [key: string]: any };
}
export interface ResponseQuery {
query: Knex.QueryBuilder;
recordsTotal: number | string;
recordsFiltered: number | string;
}
type FilterConfig = {
[key: string]: (value: any) => Knex.QueryBuilder;
};
export const buildDatatableQuery = async (request: { query: RequestQuery }, query: Knex.QueryBuilder, tableName: string): Promise<ResponseQuery> => {
const size: number = parseInt(request.query.length) || 10;
const start: number = parseInt(request.query.start);
const order: string = (
request.query.order && request.query.columns
[request.query.order[0].column].data
) || 'id'
const direction: string = (request.query.order && request.query.order[0].dir) || "asc";
const search: string = request.query.search.value;
const recordsTotalResult = await query.clone().count("* as count").first();
const recordsTotal = recordsTotalResult ? recordsTotalResult.count : 0;
const filterConfig: FilterConfig = {
active: (value: string) => query.where('active', '=', value),
};
if (request.query.filters) {
Object.keys(request.query.filters).forEach((filterName: any) => {
const filterValue = request.query.filters[filterName];
if (filterConfig[filterName] && filterValue) {
query = filterConfig[filterName](filterValue);
}
});
}
if (search) {
console.debug("applying search: " + search)
console.debug("columns: " + JSON.stringify(request.query.columns, null, 4));
query = query.where(builder => {
Object.values(request.query.columns)
.filter(column => column.searchable === "true")
.forEach((col, index) =>
index === 0
? builder.where(`${tableName}.${col.data}`, "like", `%${search}%`)
: builder.orWhere(`${tableName}.${col.data}`, "like", `%${search}%`)
);
});
}
const recordsFilteredResult = await query.clone().count("* as count").first();
const recordsFiltered = recordsFilteredResult ? recordsFilteredResult.count : 0;
query = query.orderBy(`${tableName}.${order}`, direction).limit(size).offset(start);
return {
query,
recordsTotal,
recordsFiltered
};
}

16
tailwind.config.js Normal file
View File

@ -0,0 +1,16 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: "selector",
content: [
"./src/client/**/*.{html,js,ejs}",
"./node_modules/preline/dist/*.js"
],
theme: {
extend: {},
},
plugins: [
require('@tailwindcss/forms')({ strategy: "class" }),
],
}

120
tsconfig.client.json Normal file
View File

@ -0,0 +1,120 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "ES6", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "ES6", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
"baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
"paths": { /* Specify a set of entries that re-map imports to additional lookup locations. */
"@node_modules/*": ["node_modules/*"]
},
"plugins": [],
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
"typeRoots": [ /* Specify multiple folders that act like './node_modules/@types'. */
"./node_modules/@types",
"./src/client/public/types/globals.d.ts",
"./src/client/public/types/preline.d.ts"
],
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
// "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./src/client/public/js", /* Specify an output folder for all emitted files. */
"removeComments": true, /* Disable emitting comments. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
},
"include": ["src/client/**/*"]
}

5
tsconfig.json Normal file
View File

@ -0,0 +1,5 @@
// The purpose of this file is for vscode to correctly identify which tsconfig to use.
// This file serves no other purpose.
{
"extends": "./tsconfig.server.json"
}

124
tsconfig.server.json Normal file
View File

@ -0,0 +1,124 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "commonjs", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
"baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
"paths": { /* Specify a set of entries that re-map imports to additional lookup locations. */
"@db/*": ["src/db/*"],
"@server/*": ["src/server/*"],
"@client/*": ["src/client/*"],
"@bot/*": ["src/bot/*"],
"@utils/*": ["src/utils/*"],
"@node_modules/*": ["node_modules/*"],
},
"plugins": [
{
"transform": "@zerollup/ts-transform-paths"
}
],
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
"typeRoots": ["./node_modules/@types", "./src/types/"], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
// "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./dist", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
},
"include": ["src/**/*"]
}

View File

View File

@ -1,12 +0,0 @@
import logging
from quart import Quart
from .routes.dashboard import dashboard
log = logging.getLogger(__name__)
def create_app():
log.info("Creating web app and registering blueprints")
app = Quart(__name__)
app.register_blueprint(dashboard, url_prefix="/dashboard")
return app

View File

@ -1,7 +0,0 @@
from quart import Blueprint
dashboard = Blueprint("dashboard", __name__)
@dashboard.route("/")
async def home():
return "Dashboard page"