Compare commits
47 commits
Author | SHA1 | Date | |
---|---|---|---|
d575910662 | |||
6fb563502a | |||
ede764b6f3 | |||
04fb5709a1 | |||
136468b4bd | |||
3c404ab5fa | |||
faa42f0899 | |||
76fa4872f1 | |||
1b2673fc93 | |||
6550f703a5 | |||
89507f8412 | |||
ed6195e1e2 | |||
d022d9fcf6 | |||
b28d381948 | |||
68e8218b1b | |||
95fee833a1 | |||
ffaf8d989e | |||
6b388729d9 | |||
18c6535d1c | |||
c4251d9f51 | |||
1974152b48 | |||
590e7d8265 | |||
2b9b5198fa | |||
b28ceb8659 | |||
4e02e51fca | |||
4e6bd72a21 | |||
2ced092aa4 | |||
6b07599a68 | |||
757d790e54 | |||
c3bf31b3d4 | |||
55b81fac91 | |||
a657906f4f | |||
2a1cf8114e | |||
712affc83d | |||
1d4f7e6fc6 | |||
3379a685e1 | |||
4db1154ecd | |||
2e529cede8 | |||
eb3acd206c | |||
4ea622e251 | |||
035a08efec | |||
102c829c5a | |||
918b28c919 | |||
5fa8ce75eb | |||
b0188276e9 | |||
1738c564de | |||
51143344d1 |
|
@ -1,7 +1,8 @@
|
|||
.fleet/
|
||||
.idea/
|
||||
dist/
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store/
|
||||
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
.output
|
||||
.vinxi
|
||||
.git
|
||||
|
|
11
.eslintrc.json
Normal file
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"env": {
|
||||
"node": true
|
||||
},
|
||||
"plugins": ["solid"],
|
||||
"extends": [
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:solid/typescript"
|
||||
]
|
||||
}
|
27
.github/workflows/playwright.yml
vendored
Normal file
|
@ -0,0 +1,27 @@
|
|||
name: Playwright Tests
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
- name: Install dependencies
|
||||
run: npm install -g pnpm && pnpm install
|
||||
- name: Install Playwright Browsers
|
||||
run: pnpm exec playwright install --with-deps
|
||||
- name: Run Playwright tests
|
||||
run: pnpm exec playwright test
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
48
.gitignore
vendored
|
@ -1,28 +1,36 @@
|
|||
# build output
|
||||
dist/
|
||||
src/drizzle/migrations
|
||||
log
|
||||
|
||||
# generated types
|
||||
.astro/
|
||||
dist
|
||||
.vinxi
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
netlify
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
/node_modules
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
*.launch
|
||||
.settings/
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
# Temp
|
||||
gitignore
|
||||
|
||||
# macOS-specific files
|
||||
# System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
/.idea/
|
||||
/.fleet/
|
||||
|
||||
/.next/
|
||||
|
||||
next-env.d.ts
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
|
3
.prettierrc.json
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"plugins": ["prettier-plugin-organize-imports"]
|
||||
}
|
54
Dockerfile
|
@ -1,11 +1,51 @@
|
|||
FROM node:lts AS build
|
||||
# Use the desired base image
|
||||
FROM node:21-alpine AS base
|
||||
|
||||
# Set the NODE_ENV to production
|
||||
ENV NODE_ENV production
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
|
||||
# Pass the Font Awesome token as a build argument
|
||||
ARG FONT_AWESOME_TOKEN
|
||||
RUN echo "@fortawesome:registry=https://npm.fontawesome.com/" > ~/.npmrc \
|
||||
&& echo "//npm.fontawesome.com/:_authToken=${FONT_AWESOME_TOKEN}" >> ~/.npmrc \
|
||||
&& if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||
elif [ -f package-lock.json ]; then npm ci; \
|
||||
elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine AS runtime
|
||||
COPY ./nginx/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 8080
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 solidjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=solidjs:nodejs /app/.output ./.output
|
||||
COPY --from=builder --chown=solidjs:nodejs /app/.vinxi ./.vinxi
|
||||
|
||||
USER solidjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Set the default values for environment variables
|
||||
ENV PORT 3000
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
|
|
156
README.md
|
@ -1,54 +1,132 @@
|
|||
# Astro Starter Kit: Basics
|
||||
# li'l Judd - Competitive Splatoon Bot
|
||||
|
||||
```sh
|
||||
npm create astro@latest -- --template basics
|
||||
```
|
||||
Welcome to li'l Judd, your go-to bot for enhancing your competitive Splatoon experience! Whether you're looking for match statistics, team coordination, or general Splatoon information, li'l Judd has got you covered.
|
||||
|
||||
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/basics)
|
||||
[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/basics)
|
||||
[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/basics/devcontainer.json)
|
||||
## Features
|
||||
|
||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||
- **Match Statistics:** Track and analyze your Splatoon matches to improve your gameplay.
|
||||
- **Team Coordination:** Plan strategies and coordinate with your team more effectively.
|
||||
- **General Splatoon Information:** Get information about weapons, maps, and game updates.
|
||||
|
||||
![just-the-basics](https://github.com/withastro/astro/assets/2244813/a0a5533c-a856-4198-8470-2d67b1d7c554)
|
||||
## Getting Started
|
||||
|
||||
## 🚀 Project Structure
|
||||
To get started with li'l Judd, follow the instructions below.
|
||||
|
||||
Inside of your Astro project, you'll see the following folders and files:
|
||||
### Prerequisites
|
||||
|
||||
```text
|
||||
/
|
||||
├── public/
|
||||
│ └── favicon.svg
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ └── Card.astro
|
||||
│ ├── layouts/
|
||||
│ │ └── Layout.astro
|
||||
│ └── pages/
|
||||
│ └── index.astro
|
||||
└── package.json
|
||||
```
|
||||
- Node.js and pnpm installed (pnpm is strongly recommended for local development)
|
||||
- Docker (optional, for containerized deployment)
|
||||
- A Font Awesome subscription (required for the paid version used)
|
||||
|
||||
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
||||
### Installation
|
||||
|
||||
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
|
||||
1. Clone the repository:
|
||||
|
||||
Any static assets, like images, can be placed in the `public/` directory.
|
||||
```bash
|
||||
git clone https://git.moonleay.net/Websites/liljudd-website.git
|
||||
```
|
||||
|
||||
## 🧞 Commands
|
||||
2. Install dependencies:
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
```bash
|
||||
cd liljudd-website
|
||||
|
||||
| Command | Action |
|
||||
| :------------------------ | :----------------------------------------------- |
|
||||
| `npm install` | Installs dependencies |
|
||||
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||
| `npm run build` | Build your production site to `./dist/` |
|
||||
| `npm run preview` | Preview your build locally, before deploying |
|
||||
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||
# Install pnpm if not already installed
|
||||
npm i -g pnpm
|
||||
|
||||
## 👀 Want to learn more?
|
||||
# Add token to authenticate when using installing Font Awesome packages
|
||||
echo "@fortawesome:registry=https://npm.fontawesome.com/" > ~/.npmrc
|
||||
echo "//npm.fontawesome.com/:_authToken=<YOUR_TOKEN>" >> ~/.npmrc
|
||||
|
||||
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. Set up environment variables:
|
||||
|
||||
Create a `.env` file in the root directory and add the following variables:
|
||||
|
||||
```env
|
||||
VITE_DISCORD_CLIENT_ID=your_discord_client_id
|
||||
VITE_DISCORD_CLIENT_SECRET=your_discord_client_secret
|
||||
VITE_DISCORD_BOT_TOKEN=your_discord_bot_token
|
||||
VITE_DISCORD_OAUTH2_PERMISSIONS=18977581952080
|
||||
VITE_AUTH_REDIRECT_URL=your_redirect_url
|
||||
VITE_DATABASE_URL=your_database_url
|
||||
```
|
||||
|
||||
Recieve your discord applications `CLIENT_ID`, `CLIENT_SECRET` & `BOT_TOKEN` from the [Applications dashboard](https://discord.com/developers/applications/).
|
||||
|
||||
Your `VITE_AUTH_REDIRECT_URL` should look like this: `https://<hostname>/api/auth/callback/discord`.
|
||||
|
||||
Composite your `VITE_DATABASE_URL` like [`postgres://username:password@hostname:5432/databasename`](https://orm.drizzle.team/docs/get-started-postgresql#postgresjs).
|
||||
|
||||
#### Development
|
||||
|
||||
The duplicate `DATABASE_URL` is only needed for Drizzle Studio.
|
||||
|
||||
```
|
||||
# Drizzle Studio & Tests
|
||||
DATABASE_URL=your_database_url
|
||||
|
||||
#Tests
|
||||
VITE_DISCORD_CLIENT_ID=your_discord_client_id
|
||||
VITE_DISCORD_CLIENT_SECRET=your_discord_client_secret
|
||||
VITE_DISCORD_BOT_TOKEN=your_discord_bot_token
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
- **Run development server:**
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
# Use --host if you have set up a reverse proxy to test auth with callback to your dev box
|
||||
pnpm dev --host
|
||||
```
|
||||
|
||||
- **Build for production:**
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
- **Start production server:**
|
||||
|
||||
```bash
|
||||
pnpm start
|
||||
```
|
||||
|
||||
- **Generate openapi types from specs:**
|
||||
|
||||
[openapi.json](https://raw.githubusercontent.com/discord/discord-api-spec/main/specs/openapi.json) (from discord-api-spec repo) -> [discord.d.ts](/src/types/discord.d.ts)
|
||||
|
||||
[liljudd.json](/public/api/specs/liljudd.json) -> [liljudd.d.ts](/src/types/liljudd.d.ts)
|
||||
|
||||
```bash
|
||||
pnpm discord-openapi-gen
|
||||
pnpm liljudd-openapi-gen
|
||||
```
|
||||
|
||||
**Drizzle Studio:**
|
||||
|
||||
Explore the database with ease through the intuitive web-based interface of Drizzle Studio, offering a visual and user-friendly way to interact with the data behind the scenes.
|
||||
|
||||
```bash
|
||||
pnpm drizzle-studio
|
||||
```
|
||||
|
||||
- **Docker Container:**
|
||||
|
||||
```bash
|
||||
docker build --build-arg FONT_AWESOME_TOKEN=<YOUR_TOKEN> -t $image_name .
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
If you'd like to contribute to li'l Judd, feel free to open an issue or submit a pull request. Your contributions are highly appreciated!
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the [GPL v3 License](LICENSE).
|
||||
|
||||
Happy splatting! 🦑🎮
|
||||
|
|
11
add-test-server.http
Normal file
|
@ -0,0 +1,11 @@
|
|||
POST http://localhost:3000/api/598539452343648256/config
|
||||
# Content-Type: application/json
|
||||
Authorization: Basic {{$dotenv DISCORD_CLIENT_ID}}:{{$dotenv DISCORD_CLIENT_SECRET}}
|
||||
Origin: http://localhost:3000
|
||||
|
||||
###
|
||||
|
||||
DELETE http://localhost:3000/api/598539452343648256/config
|
||||
# Content-Type: application/json
|
||||
Authorization: Basic {{$dotenv DISCORD_CLIENT_ID}}:{{$dotenv DISCORD_CLIENT_SECRET}}
|
||||
Origin: http://localhost:3000
|
5
app.config.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { defineConfig } from "@solidjs/start/config";
|
||||
|
||||
export default defineConfig({
|
||||
middleware: "./src/middleware.ts",
|
||||
});
|
|
@ -1,4 +0,0 @@
|
|||
import { defineConfig } from "astro/config";
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({});
|
11
drizzle.config.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import "dotenv/config";
|
||||
import type { Config } from "drizzle-kit";
|
||||
|
||||
export default {
|
||||
schema: "./src/drizzle/schema.ts",
|
||||
out: "./src/drizzle/migrations",
|
||||
driver: "pg",
|
||||
dbCredentials: {
|
||||
connectionString: process.env.DATABASE_URL!,
|
||||
},
|
||||
} satisfies Config;
|
274
e2e/auth.spec.ts
Normal file
|
@ -0,0 +1,274 @@
|
|||
import { DrizzlePostgreSQLAdapter } from "@lucia-auth/adapter-drizzle";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { expect, test, type BrowserContext, type Page } from "@playwright/test";
|
||||
import "dotenv/config";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { Lucia, type Cookie } from "lucia";
|
||||
import createClient from "openapi-fetch";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "~/drizzle/schema";
|
||||
import type * as discord from "~/types/discord";
|
||||
import type * as liljudd from "~/types/liljudd";
|
||||
|
||||
const unencoded = `${process.env.DISCORD_CLIENT_ID}:${process.env.DISCORD_CLIENT_SECRET}`;
|
||||
const encoded = btoa(unencoded);
|
||||
|
||||
const queryClient = postgres(process.env.DATABASE_URL!);
|
||||
const db = drizzle(queryClient, {
|
||||
schema,
|
||||
});
|
||||
|
||||
const adapter = new DrizzlePostgreSQLAdapter(db, schema.sessions, schema.users);
|
||||
export const lucia = new Lucia(adapter, {
|
||||
getUserAttributes: (attributes) => attributes,
|
||||
});
|
||||
|
||||
let context: BrowserContext;
|
||||
let page: Page;
|
||||
|
||||
let sessionCookie: Cookie | undefined;
|
||||
|
||||
let userId = createId();
|
||||
let guildId: bigint;
|
||||
|
||||
test.describe.serial("User auth process", () => {
|
||||
test.beforeAll(() => {
|
||||
expect(
|
||||
[
|
||||
"DISCORD_CLIENT_ID",
|
||||
"DISCORD_CLIENT_SECRET",
|
||||
"DATABASE_URL",
|
||||
"DISCORD_BOT_TOKEN",
|
||||
].filter((e) => typeof process.env[e] === "undefined").length,
|
||||
{ message: "Please specify all env vars." },
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
if (!sessionCookie) return;
|
||||
|
||||
const sameSiteProps = {
|
||||
lax: "Lax",
|
||||
strict: "Strict",
|
||||
none: "None",
|
||||
} as const;
|
||||
const expires = sessionCookie.attributes.expires
|
||||
? sessionCookie.attributes.expires.getTime() / 1000
|
||||
: undefined;
|
||||
const sameSite = sessionCookie.attributes.sameSite
|
||||
? sameSiteProps[sessionCookie.attributes.sameSite]
|
||||
: undefined;
|
||||
await context.addCookies([
|
||||
{
|
||||
name: sessionCookie.name,
|
||||
value: sessionCookie.value,
|
||||
...sessionCookie.attributes,
|
||||
sameSite,
|
||||
expires,
|
||||
secure: false,
|
||||
domain: "localhost",
|
||||
path: "/",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test.afterAll("Delete DB entries", async () => {
|
||||
await db.delete(schema.users).where(eq(schema.users.id, userId)).execute();
|
||||
await db
|
||||
.delete(schema.guilds)
|
||||
.where(eq(schema.guilds.id, guildId))
|
||||
.execute();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test("Landing page", async () => {
|
||||
await page.goto("/");
|
||||
await page.waitForLoadState("load");
|
||||
|
||||
expect(await page.screenshot()).toMatchSnapshot("landing_page.png");
|
||||
});
|
||||
|
||||
test("Unauthorized Access Redirect Test", async () => {
|
||||
await page.goto("/config");
|
||||
await page.waitForURL("/");
|
||||
});
|
||||
|
||||
test("Generate auth session for further tests", async ({ browser }) => {
|
||||
const { GET } = createClient<discord.paths>({
|
||||
baseUrl: "https://discord.com/api/v10",
|
||||
});
|
||||
const discordUserResponse = await GET("/users/@me", {
|
||||
headers: {
|
||||
Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN}`,
|
||||
},
|
||||
});
|
||||
if (discordUserResponse.error) throw discordUserResponse.error;
|
||||
const discordUser = discordUserResponse.data;
|
||||
|
||||
const browserName = browser.browserType().name() as
|
||||
| "chromium"
|
||||
| "webkit"
|
||||
| "firefox";
|
||||
|
||||
userId = discordUser.id + userId.slice(discordUser.id.length);
|
||||
userId = userId.slice(0, -browserName.length) + browserName;
|
||||
|
||||
enum BrowserIds {
|
||||
chromium,
|
||||
webkit,
|
||||
firefox,
|
||||
}
|
||||
guildId = BigInt(discordUser.id) ^ BigInt(BrowserIds[browserName]);
|
||||
|
||||
await db.insert(schema.users).values({
|
||||
id: userId,
|
||||
discord_id: discordUser.id,
|
||||
name: discordUser.global_name,
|
||||
image: discordUser.avatar,
|
||||
});
|
||||
const session = await lucia.createSession(
|
||||
userId,
|
||||
{},
|
||||
{ sessionId: createId() },
|
||||
);
|
||||
sessionCookie = lucia.createSessionCookie(session.id);
|
||||
await db
|
||||
.insert(schema.discordTokens)
|
||||
.values({
|
||||
userId,
|
||||
accessToken: "tokens.accessToken",
|
||||
expiresAt: sessionCookie.attributes.expires ?? new Date(),
|
||||
refreshToken: "tokens.refreshToken",
|
||||
})
|
||||
.returning()
|
||||
.execute();
|
||||
});
|
||||
|
||||
test("Landing page when logged in", async () => {
|
||||
await page.goto("/");
|
||||
await page.waitForLoadState("load");
|
||||
|
||||
expect(await page.screenshot()).toMatchSnapshot(
|
||||
"landing_page_logged_in.png",
|
||||
);
|
||||
});
|
||||
|
||||
test("Test Api", async () => {
|
||||
const { GET, POST, PUT } = createClient<liljudd.paths>({
|
||||
baseUrl: "http://localhost:3000/",
|
||||
});
|
||||
|
||||
const createConfigResponse = await POST("/api/{guildId}/config", {
|
||||
params: {
|
||||
path: {
|
||||
guildId: guildId.toString(),
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Basic ${encoded}`,
|
||||
Origin: "http://localhost:3000",
|
||||
},
|
||||
});
|
||||
|
||||
if (createConfigResponse.error)
|
||||
throw new Error(createConfigResponse.error.error);
|
||||
|
||||
let getConfigResponse = await GET("/api/{guildId}/config", {
|
||||
params: {
|
||||
path: {
|
||||
guildId: guildId.toString(),
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Basic ${encoded}`,
|
||||
Origin: "http://localhost:3000",
|
||||
},
|
||||
});
|
||||
|
||||
if (getConfigResponse.error) throw new Error(getConfigResponse.error.error);
|
||||
|
||||
switch (getConfigResponse.data?.checksum) {
|
||||
case "9d9ba8fa6405653cb98a961c533ac7e92cbc3af6": // webkit
|
||||
case "cf6316140d481bd5c1728828b065efbe8f7bb537": // firefox
|
||||
case "9e608cb56e0818c83334389ab3913eade9c011f7": // chromium
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(
|
||||
"Before guild GET checksum didn't matched known ones: " +
|
||||
getConfigResponse.data?.checksum,
|
||||
);
|
||||
}
|
||||
|
||||
const putTimePlanningResponse = await PUT("/api/{guildId}/timePlanning", {
|
||||
body: {
|
||||
channelId: "1234567890123456789",
|
||||
targetMinute: 1,
|
||||
targetHour: 2,
|
||||
targetDay: 3,
|
||||
roles: {
|
||||
enabled: true,
|
||||
isAvailableRoleId: "1234567890123456789",
|
||||
wantsToBeNotifieRoledId: "1234567890123456789",
|
||||
},
|
||||
messageIds: {
|
||||
"0": "1234567890123456789",
|
||||
"1": "1234567890123456789",
|
||||
"2": "1234567890123456789",
|
||||
"3": "1234567890123456789",
|
||||
"4": "1234567890123456789",
|
||||
"5": "1234567890123456789",
|
||||
"6": "1234567890123456789",
|
||||
},
|
||||
},
|
||||
params: {
|
||||
path: {
|
||||
guildId: guildId.toString(),
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Basic ${encoded}`,
|
||||
Origin: "http://localhost:3000",
|
||||
},
|
||||
});
|
||||
|
||||
if (putTimePlanningResponse.error)
|
||||
throw new Error(putTimePlanningResponse.error.error);
|
||||
|
||||
getConfigResponse = await GET("/api/{guildId}/config", {
|
||||
params: {
|
||||
path: {
|
||||
guildId: guildId.toString(),
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Basic ${encoded}`,
|
||||
Origin: "http://localhost:3000",
|
||||
},
|
||||
});
|
||||
|
||||
if (getConfigResponse.error) throw new Error(getConfigResponse.error.error);
|
||||
|
||||
switch (getConfigResponse.data?.checksum) {
|
||||
case "843ea341487f777b614f4c1a07b19730a7fd12e3": // webkit
|
||||
case "8c4909abb19f7ca520840c54697f78ca4d0b5089": // firefox
|
||||
case "17701fc9adcffc4df764f35774a752d3a9b43017": // chromium
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(
|
||||
"After guild GET checksum didn't matched known ones: " +
|
||||
getConfigResponse.data?.checksum,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
BIN
e2e/auth.spec.ts-snapshots/landing-page-chromium-linux.png
Normal file
After Width: | Height: | Size: 225 KiB |
BIN
e2e/auth.spec.ts-snapshots/landing-page-firefox-linux.png
Normal file
After Width: | Height: | Size: 328 KiB |
After Width: | Height: | Size: 227 KiB |
After Width: | Height: | Size: 331 KiB |
After Width: | Height: | Size: 716 KiB |
BIN
e2e/auth.spec.ts-snapshots/landing-page-webkit-linux.png
Normal file
After Width: | Height: | Size: 710 KiB |
5
kill-running-server.sh
Executable file
|
@ -0,0 +1,5 @@
|
|||
#!/bin/bash
|
||||
|
||||
# This script checks if a process is running on port 3000 and kills it if it's found
|
||||
|
||||
lsof -i TCP:3000 | grep LISTEN | awk '{print $2}' | xargs kill -9
|
|
@ -1,31 +0,0 @@
|
|||
worker_processes 1;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
include /etc/nginx/mime.types;
|
||||
|
||||
gzip on;
|
||||
gzip_min_length 1000;
|
||||
gzip_proxied expired no-cache no-store private auth;
|
||||
gzip_types text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
error_page 404 /404.html;
|
||||
location = /404.html {
|
||||
root /usr/share/nginx/html;
|
||||
internal;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/index.html =404;
|
||||
}
|
||||
}
|
||||
}
|
70
package.json
|
@ -1,23 +1,67 @@
|
|||
{
|
||||
"name": "liljudd-website",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro check && astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
"dev": "vinxi dev",
|
||||
"build": "vinxi build",
|
||||
"start": "vinxi start",
|
||||
"lint": "eslint --fix \"**/*.{ts,tsx,js,jsx}\"",
|
||||
"push": "drizzle-kit push:pg",
|
||||
"discord-openapi-gen": "openapi-typescript https://raw.githubusercontent.com/discord/discord-api-spec/main/specs/openapi.json -o ./src/types/discord.d.ts",
|
||||
"liljudd-openapi-gen": "openapi-typescript ./public/api/specs/liljudd.json -o ./src/types/liljudd.d.ts",
|
||||
"typecheck": "tsc --noEmit --checkJs false --skipLibCheck --preserveSymLinks",
|
||||
"drizzle-studio": "drizzle-kit studio",
|
||||
"test": "pnpm exec playwright test",
|
||||
"test-ui": "pnpm exec playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.3.1",
|
||||
"astro": "^3.4.4",
|
||||
"sass": "^1.69.5",
|
||||
"typescript": "^5.2.2"
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
||||
"@fortawesome/pro-duotone-svg-icons": "^6.5.1",
|
||||
"@fortawesome/pro-light-svg-icons": "^6.5.1",
|
||||
"@fortawesome/pro-regular-svg-icons": "^6.5.1",
|
||||
"@fortawesome/pro-solid-svg-icons": "^6.5.1",
|
||||
"@fortawesome/pro-thin-svg-icons": "^6.5.1",
|
||||
"@fortawesome/sharp-solid-svg-icons": "^6.5.1",
|
||||
"@lucia-auth/adapter-drizzle": "^1.0.3",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@solidjs/meta": "^0.29.3",
|
||||
"@solidjs/router": "^0.13.0",
|
||||
"@solidjs/start": "^0.7.3",
|
||||
"arctic": "^1.2.1",
|
||||
"colors": "^1.4.0",
|
||||
"drizzle-orm": "^0.30.1",
|
||||
"http-status": "^1.7.4",
|
||||
"json-stable-stringify": "^1.1.1",
|
||||
"lucia": "^3.1.1",
|
||||
"moment-timezone": "^0.5.45",
|
||||
"object-hash": "^3.0.0",
|
||||
"openapi-fetch": "^0.9.3",
|
||||
"postgres": "^3.4.3",
|
||||
"solid-js": "^1.8.15",
|
||||
"vinxi": "^0.3.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^3.0.3",
|
||||
"prettier-plugin-astro": "^0.12.1",
|
||||
"prettier-plugin-organize-imports": "^3.2.3"
|
||||
"@playwright/test": "^1.42.1",
|
||||
"@types/json-stable-stringify": "^1.0.36",
|
||||
"@types/node": "^20.11.25",
|
||||
"@types/object-hash": "^3.0.6",
|
||||
"@typescript-eslint/eslint-plugin": "^7.1.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"drizzle-kit": "^0.20.14",
|
||||
"drizzle-zod": "^0.5.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-solid": "^0.13.1",
|
||||
"h3": "^1.11.1",
|
||||
"openapi-typescript": "^6.7.4",
|
||||
"pg": "^8.11.3",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-organize-imports": "^3.2.4",
|
||||
"sass": "^1.71.1",
|
||||
"typescript": "^5.4.2",
|
||||
"zod": "3.22.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
|
|
77
playwright.config.ts
Normal file
|
@ -0,0 +1,77 @@
|
|||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "html",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://localhost:3000",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
|
||||
{
|
||||
name: "webkit",
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
},
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: { ...devices['Pixel 5'] },
|
||||
// },
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: { ...devices['iPhone 12'] },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||
// },
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: "pnpm start",
|
||||
url: "http://localhost:3000",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
9449
pnpm-lock.yaml
|
@ -1,3 +0,0 @@
|
|||
module.exports = {
|
||||
plugins: ["prettier-plugin-organize-imports", "prettier-plugin-astro"],
|
||||
};
|
34
public/api/index.html
Normal file
|
@ -0,0 +1,34 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content="SwaggerUI" />
|
||||
<title>SwaggerUI</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://unpkg.com/swagger-ui-dist@4.5.0/swagger-ui.css"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script
|
||||
src="https://unpkg.com/swagger-ui-dist@4.5.0/swagger-ui-bundle.js"
|
||||
crossorigin
|
||||
></script>
|
||||
<script
|
||||
src="https://unpkg.com/swagger-ui-dist@4.5.0/swagger-ui-standalone-preset.js"
|
||||
crossorigin
|
||||
></script>
|
||||
<script>
|
||||
window.onload = () => {
|
||||
window.ui = SwaggerUIBundle({
|
||||
url: "/api/specs/liljudd.json",
|
||||
dom_id: "#swagger-ui",
|
||||
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
|
||||
layout: "StandaloneLayout",
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
729
public/api/specs/liljudd.json
Normal file
|
@ -0,0 +1,729 @@
|
|||
{
|
||||
"openapi": "3.0.0",
|
||||
"info": {
|
||||
"title": "li'l Judd - OpenAPI 3.0",
|
||||
"description": "None yet",
|
||||
"termsOfService": "https://liljudd.ink/terms-of-service/",
|
||||
"contact": {
|
||||
"url": "https://liljudd.ink/contact/"
|
||||
},
|
||||
"version": "0.0.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/boot": {
|
||||
"get": {
|
||||
"tags": ["Bot bootup"],
|
||||
"summary": "Retrieve all guild's configs",
|
||||
"description": "Returns all guild's configs.",
|
||||
"operationId": "getGuildsForBoot",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "successful operation",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/guildConfig"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid ID supplied",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Guild not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"basicAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/{guildId}/config": {
|
||||
"get": {
|
||||
"tags": ["Guild config"],
|
||||
"summary": "Find a guild's config by ID",
|
||||
"description": "Returns a single guild's config.",
|
||||
"operationId": "getGuildById",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "guildId",
|
||||
"in": "path",
|
||||
"description": "ID of guild config to return",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "varchar(20)"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "successful operation",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/guildConfig"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid ID supplied",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Guild not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"basicAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"tags": ["Guild config"],
|
||||
"summary": "Creates a guild's config by ID",
|
||||
"description": "Create a guild's config when the bot is has joined a new guild.",
|
||||
"operationId": "postGuildById",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "guildId",
|
||||
"in": "path",
|
||||
"description": "ID of guild's config to create",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "varchar(20)"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "successful operation"
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid ID supplied",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Guild not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"basicAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"tags": ["Guild config"],
|
||||
"summary": "Deletes a guild's config by ID",
|
||||
"description": "Delete a guild's config when the bot is removed from the guild.",
|
||||
"operationId": "deleteGuildById",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "guildId",
|
||||
"in": "path",
|
||||
"description": "ID of guild's config to delete",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "varchar(20)"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "successful operation"
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid ID supplied",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Guild not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"basicAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/{guildId}/timePlanning": {
|
||||
"get": {
|
||||
"tags": ["Time planning messages"],
|
||||
"summary": "Find the timePlanning of guild by ID",
|
||||
"description": "Returns timePlanning for a guild",
|
||||
"operationId": "gettimePlanningOfGuildById",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "guildId",
|
||||
"in": "path",
|
||||
"description": "ID of guild's timePlanning to return",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "varchar(20)"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "successful operation",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/timePlanning"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid ID supplied",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Guild not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"basicAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"tags": ["Time planning messages"],
|
||||
"summary": "Put new message IDs for timePlanning of guild by ID",
|
||||
"description": "Returns timePlanning for a guild",
|
||||
"operationId": "puttimePlanningOfGuildById",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "guildId",
|
||||
"in": "path",
|
||||
"description": "ID of guild's timePlanning to return",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "varchar(20)"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"description": "Put new message IDs for timePlanning in channel",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/timePlanning"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "successful operation"
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid ID supplied",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Guild not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"basicAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/{guildId}/matches": {
|
||||
"get": {
|
||||
"tags": ["Matches"],
|
||||
"summary": "Find all matches of guild by ID",
|
||||
"description": "Returns timePlanning for a guild",
|
||||
"operationId": "getMatchesOfGuildById",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "guildId",
|
||||
"in": "path",
|
||||
"description": "ID of guild's timePlanning to return",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "varchar(20)"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "successful operation",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["matches", "timezone"],
|
||||
"properties": {
|
||||
"matches": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/match"
|
||||
}
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"format": "text",
|
||||
"example": "Europe/Berlin"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid ID supplied",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Guild not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"basicAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"tags": ["Matches"],
|
||||
"summary": "Save a new created match of guild by ID",
|
||||
"description": "Returns timePlanning for a guild",
|
||||
"operationId": "postMatchOfGuildById",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "guildId",
|
||||
"in": "path",
|
||||
"description": "ID of match's guild to set",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "varchar(20)"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"description": "Save a new created match in channel",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["match", "timezone"],
|
||||
"properties": {
|
||||
"match": {
|
||||
"$ref": "#/components/schemas/match"
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"format": "text",
|
||||
"example": "Europe/Berlin",
|
||||
"description": "Has to match guild tz"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "successful operation"
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid ID supplied",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Guild not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"basicAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"guildConfig": {
|
||||
"type": "object",
|
||||
"required": ["guildId", "timezone", "features", "matches", "checksum"],
|
||||
"properties": {
|
||||
"guildId": {
|
||||
"$ref": "#/components/schemas/id"
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"format": "text",
|
||||
"example": "Europe/Berlin"
|
||||
},
|
||||
"features": {
|
||||
"type": "object",
|
||||
"required": ["timePlanning"],
|
||||
"properties": {
|
||||
"timePlanning": {
|
||||
"$ref": "#/components/schemas/timePlanning"
|
||||
}
|
||||
}
|
||||
},
|
||||
"matches": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/match"
|
||||
}
|
||||
},
|
||||
"checksum": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"match": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"channelId",
|
||||
"matchType",
|
||||
"createrId",
|
||||
"roleId",
|
||||
"opponentName",
|
||||
"messageId",
|
||||
"utc_ts"
|
||||
],
|
||||
"properties": {
|
||||
"channelId": {
|
||||
"$ref": "#/components/schemas/id"
|
||||
},
|
||||
"createrId": {
|
||||
"$ref": "#/components/schemas/id"
|
||||
},
|
||||
"roleId": {
|
||||
"$ref": "#/components/schemas/id"
|
||||
},
|
||||
"messageId": {
|
||||
"$ref": "#/components/schemas/id"
|
||||
},
|
||||
"matchType": {
|
||||
"type": "string",
|
||||
"format": "varchar(50)",
|
||||
"example": "Scrim"
|
||||
},
|
||||
"opponentName": {
|
||||
"type": "string",
|
||||
"format": "varchar(100)",
|
||||
"example": "?"
|
||||
},
|
||||
"utc_ts": {
|
||||
"type": "string",
|
||||
"example": "2020-01-01T00:00:00Z"
|
||||
}
|
||||
}
|
||||
},
|
||||
"timePlanning": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"channelId",
|
||||
"targetMinute",
|
||||
"targetHour",
|
||||
"targetDay",
|
||||
"roles",
|
||||
"messageIds"
|
||||
],
|
||||
"properties": {
|
||||
"channelId": {
|
||||
"$ref": "#/components/schemas/idOrNull"
|
||||
},
|
||||
"targetMinute": {
|
||||
"type": "number",
|
||||
"example": 0
|
||||
},
|
||||
"targetHour": {
|
||||
"type": "number",
|
||||
"example": 1
|
||||
},
|
||||
"targetDay": {
|
||||
"type": "number",
|
||||
"example": 1
|
||||
},
|
||||
"roles": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"enabled",
|
||||
"isAvailableRoleId",
|
||||
"wantsToBeNotifieRoledId"
|
||||
],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"isAvailableRoleId": {
|
||||
"$ref": "#/components/schemas/idOrNull"
|
||||
},
|
||||
"wantsToBeNotifieRoledId": {
|
||||
"$ref": "#/components/schemas/idOrNull"
|
||||
}
|
||||
}
|
||||
},
|
||||
"messageIds": {
|
||||
"type": "object",
|
||||
"required": ["0", "1", "2", "3", "4", "5", "6"],
|
||||
"properties": {
|
||||
"0": {
|
||||
"$ref": "#/components/schemas/idOrNull"
|
||||
},
|
||||
"1": {
|
||||
"$ref": "#/components/schemas/idOrNull"
|
||||
},
|
||||
"2": {
|
||||
"$ref": "#/components/schemas/idOrNull"
|
||||
},
|
||||
"3": {
|
||||
"$ref": "#/components/schemas/idOrNull"
|
||||
},
|
||||
"4": {
|
||||
"$ref": "#/components/schemas/idOrNull"
|
||||
},
|
||||
"5": {
|
||||
"$ref": "#/components/schemas/idOrNull"
|
||||
},
|
||||
"6": {
|
||||
"$ref": "#/components/schemas/idOrNull"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{7,20}$",
|
||||
"example": "1234567890123456789"
|
||||
},
|
||||
"idOrNull": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{7,20}$",
|
||||
"example": "1234567890123456789",
|
||||
"nullable": true
|
||||
},
|
||||
"error": {
|
||||
"type": "object",
|
||||
"required": "error",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"securitySchemes": {
|
||||
"basicAuth": {
|
||||
"type": "http",
|
||||
"scheme": "basic"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
BIN
public/assets/bg.jpg
Normal file
After Width: | Height: | Size: 31 KiB |
1
public/assets/icons/discord.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="20" viewBox="0 0 640 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2023 Fonticons, Inc.--><path fill="#ffffff" d="M524.5 69.8a1.5 1.5 0 0 0 -.8-.7A485.1 485.1 0 0 0 404.1 32a1.8 1.8 0 0 0 -1.9 .9 337.5 337.5 0 0 0 -14.9 30.6 447.8 447.8 0 0 0 -134.4 0 309.5 309.5 0 0 0 -15.1-30.6 1.9 1.9 0 0 0 -1.9-.9A483.7 483.7 0 0 0 116.1 69.1a1.7 1.7 0 0 0 -.8 .7C39.1 183.7 18.2 294.7 28.4 404.4a2 2 0 0 0 .8 1.4A487.7 487.7 0 0 0 176 479.9a1.9 1.9 0 0 0 2.1-.7A348.2 348.2 0 0 0 208.1 430.4a1.9 1.9 0 0 0 -1-2.6 321.2 321.2 0 0 1 -45.9-21.9 1.9 1.9 0 0 1 -.2-3.1c3.1-2.3 6.2-4.7 9.1-7.1a1.8 1.8 0 0 1 1.9-.3c96.2 43.9 200.4 43.9 295.5 0a1.8 1.8 0 0 1 1.9 .2c2.9 2.4 6 4.9 9.1 7.2a1.9 1.9 0 0 1 -.2 3.1 301.4 301.4 0 0 1 -45.9 21.8 1.9 1.9 0 0 0 -1 2.6 391.1 391.1 0 0 0 30 48.8 1.9 1.9 0 0 0 2.1 .7A486 486 0 0 0 610.7 405.7a1.9 1.9 0 0 0 .8-1.4C623.7 277.6 590.9 167.5 524.5 69.8zM222.5 337.6c-29 0-52.8-26.6-52.8-59.2S193.1 219.1 222.5 219.1c29.7 0 53.3 26.8 52.8 59.2C275.3 311 251.9 337.6 222.5 337.6zm195.4 0c-29 0-52.8-26.6-52.8-59.2S388.4 219.1 417.9 219.1c29.7 0 53.3 26.8 52.8 59.2C470.7 311 447.5 337.6 417.9 337.6z"/></svg>
|
After Width: | Height: | Size: 1.2 KiB |
1
public/assets/icons/email.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16" viewBox="0 0 512 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2023 Fonticons, Inc.--><path fill="#ffffff" d="M48 64C21.5 64 0 85.5 0 112c0 15.1 7.1 29.3 19.2 38.4L236.8 313.6c11.4 8.5 27 8.5 38.4 0L492.8 150.4c12.1-9.1 19.2-23.3 19.2-38.4c0-26.5-21.5-48-48-48H48zM0 176V384c0 35.3 28.7 64 64 64H448c35.3 0 64-28.7 64-64V176L294.4 339.2c-22.8 17.1-54 17.1-76.8 0L0 176z"/></svg>
|
After Width: | Height: | Size: 529 B |
1
public/assets/icons/external.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16" viewBox="0 0 512 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2023 Fonticons, Inc.--><path fill="#ffffff" d="M320 0c-17.7 0-32 14.3-32 32s14.3 32 32 32h82.7L201.4 265.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L448 109.3V192c0 17.7 14.3 32 32 32s32-14.3 32-32V32c0-17.7-14.3-32-32-32H320zM80 32C35.8 32 0 67.8 0 112V432c0 44.2 35.8 80 80 80H400c44.2 0 80-35.8 80-80V320c0-17.7-14.3-32-32-32s-32 14.3-32 32V432c0 8.8-7.2 16-16 16H80c-8.8 0-16-7.2-16-16V112c0-8.8 7.2-16 16-16H192c17.7 0 32-14.3 32-32s-14.3-32-32-32H80z"/></svg>
|
After Width: | Height: | Size: 682 B |
BIN
public/assets/lilJudd.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
public/assets/logos/kord.png
Normal file
After Width: | Height: | Size: 26 KiB |
BIN
public/assets/logos/kordextensions.png
Normal file
After Width: | Height: | Size: 7.6 KiB |
18
public/assets/logos/kotlin.svg
Normal file
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: Adobe Illustrator 24.1.2, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 500 500" style="enable-background:new 0 0 500 500;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:url(#SVGID_1_);}
|
||||
</style>
|
||||
<g id="Logotypes">
|
||||
<g>
|
||||
|
||||
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="500.0035" y1="579.1058" x2="-9.653803e-02" y2="1079.2058" gradientTransform="matrix(0.9998 0 0 0.9998 9.651873e-02 -578.99)">
|
||||
<stop offset="3.435144e-03" style="stop-color:#E44857"/>
|
||||
<stop offset="0.4689" style="stop-color:#C711E1"/>
|
||||
<stop offset="1" style="stop-color:#7F52FF"/>
|
||||
</linearGradient>
|
||||
<polygon class="st0" points="500,500 0,500 0,0 500,0 250,250 "/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 898 B |
BIN
public/assets/logos/pgelephant.png
Normal file
After Width: | Height: | Size: 49 KiB |
BIN
public/assets/logox256.png
Normal file
After Width: | Height: | Size: 39 KiB |
BIN
public/assets/screenshots/featureexample.png
Normal file
After Width: | Height: | Size: 35 KiB |
BIN
public/assets/screenshots/featureexamplemobile.png
Normal file
After Width: | Height: | Size: 26 KiB |
BIN
public/assets/screenshots/matchexample.png
Normal file
After Width: | Height: | Size: 38 KiB |
BIN
public/assets/screenshots/matchexamplemobile.png
Normal file
After Width: | Height: | Size: 30 KiB |
BIN
public/assets/screenshots/matchplanner.png
Normal file
After Width: | Height: | Size: 58 KiB |
BIN
public/assets/screenshots/notifs.png
Normal file
After Width: | Height: | Size: 17 KiB |
BIN
public/assets/screenshots/oldplanningmsg.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
public/assets/screenshots/rotationstatus.png
Normal file
After Width: | Height: | Size: 36 KiB |
BIN
public/assets/screenshots/timeplanner.png
Normal file
After Width: | Height: | Size: 55 KiB |
BIN
public/assets/screenshots/unknown.png
Normal file
After Width: | Height: | Size: 2.8 KiB |
27
src/app.tsx
Normal file
|
@ -0,0 +1,27 @@
|
|||
// @refresh reload
|
||||
import "@fortawesome/fontawesome-svg-core/styles.css";
|
||||
import { Meta, MetaProvider, Title } from "@solidjs/meta";
|
||||
import { Router } from "@solidjs/router";
|
||||
import { FileRoutes } from "@solidjs/start/router";
|
||||
import { Suspense } from "solid-js";
|
||||
import "./styles/global.scss";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Router
|
||||
root={(props) => (
|
||||
<MetaProvider>
|
||||
<Meta
|
||||
name="description"
|
||||
content="The Splatoon Discord bot with unique features."
|
||||
/>
|
||||
<Title>li'l Judd - Your competitive Splatoon assistant</Title>
|
||||
|
||||
<Suspense>{props.children}</Suspense>
|
||||
</MetaProvider>
|
||||
)}
|
||||
>
|
||||
<FileRoutes />
|
||||
</Router>
|
||||
);
|
||||
}
|
151
src/components/FontAwesomeIcon.tsx
Normal file
|
@ -0,0 +1,151 @@
|
|||
import {
|
||||
FaSymbol,
|
||||
FlipProp,
|
||||
IconDefinition,
|
||||
IconProp,
|
||||
PullProp,
|
||||
RotateProp,
|
||||
SizeProp,
|
||||
Transform,
|
||||
} from "@fortawesome/fontawesome-svg-core";
|
||||
import { type JSX } from "solid-js";
|
||||
|
||||
export interface FontAwesomeIconProps
|
||||
extends Omit<
|
||||
JSX.SvgSVGAttributes<SVGSVGElement>,
|
||||
"children" | "mask" | "transform"
|
||||
> {
|
||||
icon: IconDefinition;
|
||||
mask?: IconProp;
|
||||
maskId?: string;
|
||||
color?: string;
|
||||
spin?: boolean;
|
||||
spinPulse?: boolean;
|
||||
spinReverse?: boolean;
|
||||
pulse?: boolean;
|
||||
beat?: boolean;
|
||||
fade?: boolean;
|
||||
beatFade?: boolean;
|
||||
bounce?: boolean;
|
||||
shake?: boolean;
|
||||
flash?: boolean;
|
||||
border?: boolean;
|
||||
fixedWidth?: boolean;
|
||||
inverse?: boolean;
|
||||
listItem?: boolean;
|
||||
flip?: FlipProp;
|
||||
size?: SizeProp;
|
||||
pull?: PullProp;
|
||||
rotation?: RotateProp;
|
||||
transform?: string | Transform;
|
||||
symbol?: FaSymbol;
|
||||
style?: JSX.CSSProperties;
|
||||
tabIndex?: number;
|
||||
title?: string;
|
||||
titleId?: string;
|
||||
swapOpacity?: boolean;
|
||||
}
|
||||
|
||||
const idPool = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
function nextUniqueId() {
|
||||
let size = 12;
|
||||
let id = "";
|
||||
|
||||
while (size-- > 0) {
|
||||
id += idPool[(Math.random() * 62) | 0];
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
function Path(props: { d: string | string[] }) {
|
||||
return (
|
||||
<>
|
||||
{typeof props.d === "string" ? (
|
||||
<path fill="currentColor" d={props.d} />
|
||||
) : (
|
||||
<>
|
||||
<path class="fa-secondary" fill="currentColor" d={props.d[0]} />
|
||||
<path class="fa-primary" fill="currentColor" d={props.d[1]} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function FontAwesomeIcon(props: FontAwesomeIconProps) {
|
||||
const titleId = () =>
|
||||
props.title
|
||||
? "svg-inline--fa-title-".concat(props.titleId || nextUniqueId())
|
||||
: undefined;
|
||||
// Get CSS class list from the props object
|
||||
function attributes() {
|
||||
const defaultClasses = {
|
||||
"svg-inline--fa": true,
|
||||
[`fa-${props.icon.iconName}`]: true,
|
||||
[props.class ?? ""]:
|
||||
typeof props.class !== "undefined" && props.class !== null,
|
||||
...props.classList,
|
||||
};
|
||||
|
||||
// map of CSS class names to properties
|
||||
const faClasses = {
|
||||
"fa-beat": props.beat,
|
||||
"fa-fade": props.fade,
|
||||
"fa-beat-fade": props.beatFade,
|
||||
"fa-bounce": props.bounce,
|
||||
"fa-shake": props.shake,
|
||||
"fa-flash": props.flash,
|
||||
"fa-spin": props.spin,
|
||||
"fa-spin-reverse": props.spinReverse,
|
||||
"fa-spin-pulse": props.spinPulse,
|
||||
"fa-pulse": props.pulse,
|
||||
"fa-fw": props.fixedWidth,
|
||||
"fa-inverse": props.inverse,
|
||||
"fa-border": props.border,
|
||||
"fa-li": props.listItem,
|
||||
"fa-flip": typeof props.flip !== "undefined" && props.flip !== null,
|
||||
"fa-flip-horizontal":
|
||||
props.flip === "horizontal" || props.flip === "both",
|
||||
"fa-flip-vertical": props.flip === "vertical" || props.flip === "both",
|
||||
[`fa-${props.size}`]:
|
||||
typeof props.size !== "undefined" && props.size !== null,
|
||||
[`fa-rotate-${props.rotation}`]:
|
||||
typeof props.rotation !== "undefined" && props.size !== null,
|
||||
[`fa-pull-${props.pull}`]:
|
||||
typeof props.pull !== "undefined" && props.pull !== null,
|
||||
"fa-swap-opacity": props.swapOpacity,
|
||||
};
|
||||
|
||||
const attributes = {
|
||||
focusable: !!props.title,
|
||||
"aria-hidden": !props.title,
|
||||
role: "img",
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
"aria-labelledby": titleId(),
|
||||
"data-prefix": props.icon.prefix,
|
||||
"data-icon": props.icon.iconName,
|
||||
"data-fa-transform": props.transform,
|
||||
"data-fa-mask": props.mask,
|
||||
"data-fa-mask-id": props.maskId,
|
||||
"data-fa-symbol": props.symbol,
|
||||
tabIndex: props.tabIndex,
|
||||
classList: { ...defaultClasses, ...faClasses },
|
||||
color: props.color,
|
||||
style: props.style,
|
||||
viewBox: `0 0 ${props.icon.icon[0]} ${props.icon.icon[1]}`,
|
||||
} as const;
|
||||
|
||||
// return the complete class list
|
||||
return attributes;
|
||||
}
|
||||
|
||||
return (
|
||||
<svg {...attributes()}>
|
||||
{/* <Show when={props.title}>
|
||||
<title id={titleId()}>{props.title}</title>
|
||||
</Show> */}
|
||||
<Path d={props.icon.icon[4]} />
|
||||
</svg>
|
||||
);
|
||||
}
|
|
@ -1,73 +0,0 @@
|
|||
---
|
||||
import '../styles/components/Footer.scss'
|
||||
---
|
||||
|
||||
<footer>
|
||||
<div class="footerIcon">
|
||||
<img
|
||||
class="footerImage"
|
||||
src="https://static.moonleay.net/img/lilJuddWeb/lilJudd.png"
|
||||
alt="A cute Image of lil Judd <3"
|
||||
/>
|
||||
<p>li'l Judd</p>
|
||||
<p class="footerImageNote">Your competitive Splatoon assistant.</p>
|
||||
</div>
|
||||
<div class="footerTable">
|
||||
<div>
|
||||
<h3>Navigation</h3>
|
||||
<div class="footerLinks">
|
||||
<a href="/" target="_self">Home</a>
|
||||
<a href="/features" target="_self">Features</a>
|
||||
<a href="/how-do-i" target="_self">How do I...?</a>
|
||||
<a href="/stack" target="_self">The Stack</a>
|
||||
<a href="/about" target="_self">About</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3>The Software</h3>
|
||||
<div class="footerLinks">
|
||||
<a href="https://git.moonleay.net/DiscordBots/lilJudd" target="_blank"
|
||||
>The code of the bot <img src="https://static.moonleay.net/img/lilJuddWeb/logos/external.svg" alt="external link"/></a
|
||||
>
|
||||
<a
|
||||
href="https://git.moonleay.net/Websites/liljudd-website"
|
||||
target="_blank">The code of the website <img src="https://static.moonleay.net/img/lilJuddWeb/logos/external.svg" alt="external link"/></a
|
||||
>
|
||||
<a href="https://todo.moonleay.net/share/OmisuzgPDdsrCAXKjGrTfYzWwqNDNclOMGJWeMsi/auth?view=kanban" target="_blank">The todo list <img src="https://static.moonleay.net/img/lilJuddWeb/logos/external.svg" alt="external link"/></a>
|
||||
<a href="/acknowledgements" target="_self">Acknowledgements</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3>More</h3>
|
||||
<div class="footerLinks">
|
||||
<a href="https://moonleay.net/" target="_self">My homepage <img src="https://static.moonleay.net/img/lilJuddWeb/logos/external.svg" alt="external link"/></a>
|
||||
<a href="https://moonleay.net/blog/" target="_blank">My blog <img src="https://static.moonleay.net/img/lilJuddWeb/logos/external.svg" alt="external link"/></a>
|
||||
<a href="/contact" target="_self">Contact me</a>
|
||||
<a href="https://status.moonleay.net/" target="_blank">Server Status <img src="https://static.moonleay.net/img/lilJuddWeb/logos/external.svg" alt="external link"/></a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Legal</h3>
|
||||
<div class="footerLinks">
|
||||
<a href="/imprint" target="_self">Imprint</a>
|
||||
<a href="/privacy-policy" target="_self">Privacy Policy</a>
|
||||
<a href="/terms-of-service" target="_self">Terms of Service</a>
|
||||
<a
|
||||
href="https://git.moonleay.net/DiscordBots/lilJudd/src/branch/master/LICENSE"
|
||||
target="_blank">The license <img src="https://static.moonleay.net/img/lilJuddWeb/logos/external.svg" alt="external link"/></a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footerNotice">
|
||||
<p>
|
||||
This website is NOT affiliated with Nintendo or any other party. All
|
||||
product names, logos, and brands are property of their respective owners.
|
||||
</p>
|
||||
<p>
|
||||
li'l Judd © 2023 <a href="https://moonleay.net/" target="_blank"
|
||||
>moonleay</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
108
src/components/Footer.tsx
Normal file
|
@ -0,0 +1,108 @@
|
|||
import "../styles/components/Footer.scss";
|
||||
|
||||
function Footer() {
|
||||
return (
|
||||
<footer>
|
||||
<div class="footerIcon">
|
||||
<img
|
||||
class="footerImage"
|
||||
src="/assets/lilJudd.png"
|
||||
alt="A cute Image of lil Judd <3"
|
||||
/>
|
||||
<p>li'l Judd</p>
|
||||
<p class="footerImageNote">Your competitive Splatoon assistant.</p>
|
||||
</div>
|
||||
<div class="footerTable">
|
||||
<div>
|
||||
<h3>Navigation</h3>
|
||||
<div class="footerLinks">
|
||||
<a href="/">Home</a>
|
||||
<a href="/features">Features</a>
|
||||
<a href="/how-do-i">How do I...?</a>
|
||||
<a href="/stack">The Stack</a>
|
||||
<a href="/about">About</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3>The Software</h3>
|
||||
<div class="footerLinks">
|
||||
<a
|
||||
href="https://git.moonleay.net/DiscordBots/lilJudd"
|
||||
target="_blank"
|
||||
>
|
||||
The code of the bot{" "}
|
||||
<img src="/assets/icons/external.svg" alt="external link" />
|
||||
</a>
|
||||
<a
|
||||
href="https://git.moonleay.net/Websites/liljudd-website"
|
||||
target="_blank"
|
||||
>
|
||||
The code of the website{" "}
|
||||
<img src="/assets/icons/external.svg" alt="external link" />
|
||||
</a>
|
||||
<a
|
||||
href="https://todo.moonleay.net/share/OmisuzgPDdsrCAXKjGrTfYzWwqNDNclOMGJWeMsi/auth?view=kanban"
|
||||
target="_blank"
|
||||
>
|
||||
The todo list{" "}
|
||||
<img src="/assets/icons/external.svg" alt="external link" />
|
||||
</a>
|
||||
<a href="/acknowledgements">Acknowledgements</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3>More</h3>
|
||||
<div class="footerLinks">
|
||||
<a href="https://moonleay.net/">
|
||||
My homepage{" "}
|
||||
<img src="/assets/icons/external.svg" alt="external link" />
|
||||
</a>
|
||||
<a href="https://moonleay.net/blog/" target="_blank">
|
||||
My blog{" "}
|
||||
<img src="/assets/icons/external.svg" alt="external link" />
|
||||
</a>
|
||||
<a href="/contact">Contact me</a>
|
||||
<a href="https://status.moonleay.net/" target="_blank">
|
||||
Server Status{" "}
|
||||
<img src="/assets/icons/external.svg" alt="external link" />
|
||||
</a>
|
||||
<a href="https://discord.gg/HTZRktfH4A" target="_blank">
|
||||
Support Discord{" "}
|
||||
<img src="/assets/icons/external.svg" alt="external link" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Legal</h3>
|
||||
<div class="footerLinks">
|
||||
<a href="/imprint">Imprint</a>
|
||||
<a href="/privacy-policy">Privacy Policy</a>
|
||||
<a href="/terms-of-service">Terms of Service</a>
|
||||
<a
|
||||
href="https://git.moonleay.net/DiscordBots/lilJudd/src/branch/master/LICENSE"
|
||||
target="_blank"
|
||||
>
|
||||
The license{" "}
|
||||
<img src="/assets/icons/external.svg" alt="external link" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footerNotice">
|
||||
<p>
|
||||
This website is NOT affiliated with Nintendo or any other party. All
|
||||
product names, logos, and brands are property of their respective
|
||||
owners.
|
||||
</p>
|
||||
<p>
|
||||
li'l Judd © 2022-2024{" "}
|
||||
<a href="https://moonleay.net/" target="_blank">
|
||||
moonleay
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
export default Footer;
|
|
@ -1,21 +0,0 @@
|
|||
---
|
||||
interface Props {
|
||||
imgUrl: string;
|
||||
imgAlt: string;
|
||||
title: string;
|
||||
description: string;
|
||||
note: string;
|
||||
span?: boolean;
|
||||
}
|
||||
const { imgUrl, imgAlt, title, description, note, span = false } = Astro.props;
|
||||
import "../styles/components/ImageSection.scss";
|
||||
---
|
||||
|
||||
<section class:list={["ImageSection", { span }]}>
|
||||
<h1>{title}</h1>
|
||||
<div class="imgDiv">
|
||||
<img src={imgUrl} alt={imgAlt} />
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<p class="noteP">{note}</p>
|
||||
</section>
|
25
src/components/ImageSection.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import "../styles/components/ImageSection.scss";
|
||||
|
||||
interface Props {
|
||||
imgUrl: string;
|
||||
imgAlt: string;
|
||||
title: string;
|
||||
description: string;
|
||||
note: string;
|
||||
span?: boolean;
|
||||
}
|
||||
|
||||
function ImageSection(props: Props) {
|
||||
return (
|
||||
<section classList={{ ImageSection: true, span: props.span ?? true }}>
|
||||
<h1>{props.title}</h1>
|
||||
<div class="imgDiv">
|
||||
<img src={props.imgUrl} alt={props.imgAlt} />
|
||||
<p>{props.description}</p>
|
||||
</div>
|
||||
<p class="noteP">{props.note}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImageSection;
|
18
src/components/Layout.tsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { JSX, Suspense } from "solid-js";
|
||||
import "../styles/Layout.scss";
|
||||
import Footer from "./Footer";
|
||||
import NavBar from "./NavBar";
|
||||
|
||||
function Layout(props: { children: JSX.Element; site: string }) {
|
||||
return (
|
||||
<>
|
||||
<NavBar />
|
||||
<div class={props.site}>
|
||||
<Suspense>{props.children}</Suspense>
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Layout;
|
|
@ -1,35 +0,0 @@
|
|||
---
|
||||
import '../styles/components/NavBar.scss';
|
||||
---
|
||||
<nav>
|
||||
<ul>
|
||||
<li class="navElem">
|
||||
<a class="textBx" href="/" target="_self">
|
||||
<img
|
||||
id="logo"
|
||||
src="https://static.moonleay.net/img/lilJuddWeb/logos/logox256.png"
|
||||
alt="The Bots Logo"
|
||||
/>
|
||||
li'l Judd
|
||||
</a>
|
||||
</li>
|
||||
<li class="navElem">
|
||||
<a href="/features" target="_self">Features</a>
|
||||
</li>
|
||||
<li class="navElem">
|
||||
<a href="/how-do-i" target="_self">How do I...?</a>
|
||||
</li>
|
||||
<li class="navElem">
|
||||
<a href="/stack" target="_self">The Stack</a>
|
||||
</li>
|
||||
<li class="navElem">
|
||||
<a href="/about" target="_self">About</a>
|
||||
</li>
|
||||
<li class="navElem">
|
||||
<a
|
||||
href="https://discord.com/api/oauth2/authorize?client_id=1024410658973941862&permissions=18977581952080&scope=bot"
|
||||
target="_blank">Invite to your server</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
56
src/components/NavBar.tsx
Normal file
|
@ -0,0 +1,56 @@
|
|||
import { faCirclePlus } from "@fortawesome/pro-regular-svg-icons";
|
||||
import { JSX, Show } from "solid-js";
|
||||
import "../styles/components/NavBar.scss";
|
||||
import { FontAwesomeIcon } from "./FontAwesomeIcon";
|
||||
import NavUser from "./NavUser";
|
||||
|
||||
if (typeof import.meta.env.VITE_DISCORD_CLIENT_ID === "undefined")
|
||||
throw new Error("No env VITE_DISCORD_CLIENT_ID found!");
|
||||
|
||||
if (typeof import.meta.env.VITE_DISCORD_OAUTH2_PERMISSIONS === "undefined")
|
||||
throw new Error("No env VITE_DISCORD_OAUTH2_PERMISSIONS found!");
|
||||
|
||||
export function Li(props: {
|
||||
href: string;
|
||||
rel?: string;
|
||||
name?: string;
|
||||
children?: JSX.Element;
|
||||
}) {
|
||||
return (
|
||||
<li class="navElem flex-row thick">
|
||||
<a class="flex-row" href={props.href} rel={props.rel}>
|
||||
{props.children ?? <></>}
|
||||
<Show when={props.name}>
|
||||
<span>{props.name}</span>
|
||||
</Show>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function NavBar() {
|
||||
return (
|
||||
<nav class="flex-row responsive">
|
||||
<ul class="flex-row responsive thick">
|
||||
<Li href="/" name="li'l Judd">
|
||||
<img src="/assets/logox256.png" alt="The Bots Logo" />
|
||||
</Li>
|
||||
<Li href="/features" name="Features" />
|
||||
<Li href="/how-do-i" name="How do I...?" />
|
||||
<Li href="/stack" name="The Stack" />
|
||||
<Li href="/about" name="About" />
|
||||
</ul>
|
||||
<ul class="flex-row responsive thick">
|
||||
<Li
|
||||
href={`https://discord.com/api/oauth2/authorize?client_id=${import.meta.env.VITE_DISCORD_CLIENT_ID}&permissions=${import.meta.env.VITE_DISCORD_OAUTH2_PERMISSIONS}&scope=bot`}
|
||||
name="Invite to your server"
|
||||
>
|
||||
<FontAwesomeIcon class="lower" icon={faCirclePlus} size="xl" />
|
||||
</Li>
|
||||
<NavUser />
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export default NavBar;
|
59
src/components/NavUser.tsx
Normal file
|
@ -0,0 +1,59 @@
|
|||
import {
|
||||
faArrowRightFromBracket,
|
||||
faArrowRightToBracket,
|
||||
faGear,
|
||||
} from "@fortawesome/pro-regular-svg-icons";
|
||||
import { cache, createAsync } from "@solidjs/router";
|
||||
import { Show } from "solid-js";
|
||||
import { getRequestEvent } from "solid-js/web";
|
||||
import { FontAwesomeIcon } from "./FontAwesomeIcon";
|
||||
import { Li } from "./NavBar";
|
||||
|
||||
async function getUser() {
|
||||
"use server";
|
||||
|
||||
const event = getRequestEvent();
|
||||
|
||||
return event?.nativeEvent.context.user;
|
||||
}
|
||||
|
||||
const cachedUser = cache(() => getUser(), "userInfo");
|
||||
|
||||
function NavUser() {
|
||||
const user = createAsync(() => cachedUser());
|
||||
const pfp = () => {
|
||||
const thisUser = user();
|
||||
if (!thisUser?.id) return "";
|
||||
|
||||
return thisUser.image
|
||||
? `https://cdn.discordapp.com/avatars/${thisUser.discord_id}/${thisUser.image}.png`
|
||||
: `https://cdn.discordapp.com/embed/avatars/${(parseInt(thisUser.discord_id) >> 22) % 6}.png`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={user()?.id}
|
||||
fallback={
|
||||
<Li href="/api/auth/login" name="Login" rel="external">
|
||||
<FontAwesomeIcon
|
||||
class="secondary"
|
||||
icon={faArrowRightToBracket}
|
||||
size="xl"
|
||||
/>
|
||||
</Li>
|
||||
}
|
||||
>
|
||||
<Li href="/config">
|
||||
<div class="swap lower">
|
||||
<img class="primary" src={pfp()} alt="User pfp" />
|
||||
<FontAwesomeIcon class="secondary" icon={faGear} size="xl" />
|
||||
</div>
|
||||
</Li>
|
||||
<Li href="/api/auth/logout" rel="external">
|
||||
<FontAwesomeIcon icon={faArrowRightFromBracket} size="xl" />
|
||||
</Li>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
export default NavUser;
|
13
src/drizzle/index.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "./schema";
|
||||
|
||||
if (typeof import.meta.env.VITE_DATABASE_URL === "undefined")
|
||||
throw new Error("No env VITE_DATABASE_URL found!");
|
||||
|
||||
const queryClient = postgres(import.meta.env.VITE_DATABASE_URL);
|
||||
const db = drizzle(queryClient, {
|
||||
schema,
|
||||
});
|
||||
|
||||
export default db;
|
106
src/drizzle/schema.ts
Normal file
|
@ -0,0 +1,106 @@
|
|||
import { relations } from "drizzle-orm";
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
serial,
|
||||
smallint,
|
||||
text,
|
||||
timestamp,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
export const cache = pgTable("cache", {
|
||||
key: varchar("key").primaryKey(),
|
||||
value: varchar("value").notNull(),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
});
|
||||
|
||||
export const users = pgTable("user", {
|
||||
id: varchar("id", { length: 24 }).primaryKey(),
|
||||
discord_id: text("discord_id").notNull(),
|
||||
name: text("name"),
|
||||
image: text("image"),
|
||||
});
|
||||
|
||||
export const sessions = pgTable("session", {
|
||||
id: varchar("id", { length: 24 }).primaryKey(),
|
||||
userId: varchar("user_id", { length: 24 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
expiresAt: timestamp("expires_at", {
|
||||
withTimezone: true,
|
||||
mode: "date",
|
||||
}).notNull(),
|
||||
});
|
||||
|
||||
export const discordTokens = pgTable("tokens", {
|
||||
userId: varchar("user_id", { length: 24 })
|
||||
.primaryKey()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
refreshToken: text("refresh_token").notNull(),
|
||||
accessToken: text("access_token").notNull(),
|
||||
expiresAt: timestamp("expires_at", { mode: "date" }).notNull(),
|
||||
});
|
||||
|
||||
export const guilds = pgTable("guilds", {
|
||||
id: bigint("id", { mode: "bigint" }).primaryKey(),
|
||||
timezone: text("timezone").notNull().default("Etc/UTC"),
|
||||
tpChannelId: bigint("tp_channel_id", { mode: "bigint" }),
|
||||
tpInterval: smallint("target_interval").notNull().default(64),
|
||||
tpRolesEnabled: boolean("tp_roles_enabled").notNull().default(false),
|
||||
isAvailableRoleId: bigint("is_available_role_id", { mode: "bigint" }),
|
||||
wantsToBeNotifieRoledId: bigint("wants_to_be_notified_role_id", {
|
||||
mode: "bigint",
|
||||
}),
|
||||
});
|
||||
|
||||
export const guildsRelations = relations(guilds, ({ many }) => ({
|
||||
tpMessages: many(tpMessages),
|
||||
matches: many(matches),
|
||||
}));
|
||||
|
||||
export const tpMessages = pgTable(
|
||||
"tp_messages",
|
||||
{
|
||||
messageId: bigint("message_id", { mode: "bigint" }),
|
||||
day: smallint("day").notNull(),
|
||||
guildId: bigint("guild_id", { mode: "bigint" })
|
||||
.notNull()
|
||||
.references(() => guilds.id, { onDelete: "cascade" }),
|
||||
},
|
||||
(table) => {
|
||||
return {
|
||||
pk: primaryKey({ columns: [table.guildId, table.day] }),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export const tpMessagesRelations = relations(tpMessages, ({ one }) => ({
|
||||
guild: one(guilds, {
|
||||
fields: [tpMessages.guildId],
|
||||
references: [guilds.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const matches = pgTable("matches", {
|
||||
id: serial("id").primaryKey(),
|
||||
channelId: bigint("channel_id", { mode: "bigint" }).notNull(),
|
||||
matchType: varchar("match_type", { length: 50 }).notNull(),
|
||||
createrId: bigint("creater_id", { mode: "bigint" }).notNull(),
|
||||
roleId: bigint("role_id", { mode: "bigint" }).notNull(),
|
||||
opponentName: varchar("opponent_name", { length: 100 }).notNull(),
|
||||
messageId: bigint("message_id", { mode: "bigint" }).notNull(),
|
||||
utc_ts: timestamp("utc_ts").notNull(),
|
||||
guildId: bigint("guild_id", { mode: "bigint" })
|
||||
.notNull()
|
||||
.references(() => guilds.id, { onDelete: "cascade" }),
|
||||
});
|
||||
|
||||
export const matchPlanningsRelations = relations(matches, ({ one }) => ({
|
||||
guild: one(guilds, {
|
||||
fields: [matches.guildId],
|
||||
references: [guilds.id],
|
||||
}),
|
||||
}));
|
3
src/entry-client.tsx
Normal file
|
@ -0,0 +1,3 @@
|
|||
import { mount, StartClient } from "@solidjs/start/client";
|
||||
|
||||
mount(() => <StartClient />, document.getElementById("app")!);
|
21
src/entry-server.tsx
Normal file
|
@ -0,0 +1,21 @@
|
|||
// @refresh reload
|
||||
import { StartServer, createHandler } from "@solidjs/start/server";
|
||||
|
||||
export default createHandler(() => (
|
||||
<StartServer
|
||||
document={({ assets, children, scripts }) => (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
{assets}
|
||||
</head>
|
||||
<body id="app">
|
||||
{children}
|
||||
{scripts}
|
||||
</body>
|
||||
</html>
|
||||
)}
|
||||
/>
|
||||
));
|
1
src/env.d.ts
vendored
|
@ -1 +0,0 @@
|
|||
/// <reference types="astro/client" />
|
|
@ -1,26 +0,0 @@
|
|||
---
|
||||
import Footer from "@components/Footer.astro";
|
||||
import NavBar from "@components/NavBar.astro";
|
||||
import "../styles/Layout.scss";
|
||||
import "../styles/GlobalLayout.css";
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="description"
|
||||
content="The Splatoon Discord bot with unique features."
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>li'l Judd - Your competitive Splatoon assistant</title>
|
||||
</head>
|
||||
<body>
|
||||
<NavBar />
|
||||
<slot />
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
34
src/lib/accessToken.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import db from "~/drizzle";
|
||||
import { discordTokens } from "~/drizzle/schema";
|
||||
import { discord } from "./auth";
|
||||
|
||||
const getAccessToken = async (userId: string) => {
|
||||
let tokens = await db.query.discordTokens
|
||||
.findFirst({
|
||||
where: eq(discordTokens.userId, userId),
|
||||
})
|
||||
.execute();
|
||||
|
||||
if (tokens && new Date() >= tokens.expiresAt) {
|
||||
const newTokens = await discord.refreshAccessToken(tokens.refreshToken);
|
||||
|
||||
tokens = (
|
||||
await db
|
||||
.update(discordTokens)
|
||||
.set({
|
||||
accessToken: newTokens.accessToken,
|
||||
expiresAt: newTokens.accessTokenExpiresAt,
|
||||
refreshToken: newTokens.refreshToken,
|
||||
})
|
||||
.where(eq(discordTokens.userId, userId))
|
||||
.returning()
|
||||
.execute()
|
||||
)[0];
|
||||
}
|
||||
|
||||
if (!tokens) return tokens;
|
||||
return tokens;
|
||||
};
|
||||
|
||||
export default getAccessToken;
|
43
src/lib/auth.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { DrizzlePostgreSQLAdapter } from "@lucia-auth/adapter-drizzle";
|
||||
import { Discord } from "arctic";
|
||||
import { Lucia } from "lucia";
|
||||
import db from "~/drizzle";
|
||||
import { sessions, users } from "~/drizzle/schema";
|
||||
|
||||
if (typeof import.meta.env.PROD === "undefined")
|
||||
throw new Error("No env PROD found!");
|
||||
|
||||
if (typeof import.meta.env.VITE_DISCORD_CLIENT_ID === "undefined")
|
||||
throw new Error("No env VITE_DISCORD_CLIENT_ID found!");
|
||||
|
||||
if (typeof import.meta.env.VITE_DISCORD_CLIENT_SECRET === "undefined")
|
||||
throw new Error("No env PROD found!");
|
||||
|
||||
if (typeof import.meta.env.VITE_AUTH_REDIRECT_URL === "undefined")
|
||||
throw new Error("No env VITE_AUTH_REDIRECT_URL found!");
|
||||
|
||||
const adapter = new DrizzlePostgreSQLAdapter(db, sessions, users);
|
||||
|
||||
export const lucia = new Lucia(adapter, {
|
||||
sessionCookie: {
|
||||
attributes: {
|
||||
// set to `true` when using HTTPS
|
||||
secure: import.meta.env.PROD,
|
||||
},
|
||||
},
|
||||
getUserAttributes: (attributes) => attributes,
|
||||
});
|
||||
|
||||
export const discord = new Discord(
|
||||
import.meta.env.VITE_DISCORD_CLIENT_ID,
|
||||
import.meta.env.VITE_DISCORD_CLIENT_SECRET,
|
||||
import.meta.env.VITE_AUTH_REDIRECT_URL,
|
||||
);
|
||||
|
||||
const unencoded = `${import.meta.env.VITE_DISCORD_CLIENT_ID}:${import.meta.env.VITE_DISCORD_CLIENT_SECRET}`;
|
||||
const encoded = btoa(unencoded);
|
||||
|
||||
export const BasicAuth = {
|
||||
unencoded: `Basic ${unencoded}`,
|
||||
encoded: `Basic ${encoded}`,
|
||||
};
|
124
src/lib/cachedDiscord.ts
Normal file
|
@ -0,0 +1,124 @@
|
|||
import { and, eq, gt } from "drizzle-orm";
|
||||
import createClient from "openapi-fetch";
|
||||
import db from "~/drizzle";
|
||||
import { cache } from "~/drizzle/schema";
|
||||
import { paths } from "~/types/discord";
|
||||
import getAccessToken from "./accessToken";
|
||||
|
||||
type Guilds =
|
||||
paths["/users/@me/guilds"]["get"]["responses"]["200"]["content"]["application/json"];
|
||||
|
||||
type Channels =
|
||||
paths["/guilds/{guild_id}/channels"]["get"]["responses"]["200"]["content"]["application/json"];
|
||||
|
||||
export const { GET: discordApi } = createClient<paths>({
|
||||
baseUrl: "https://discord.com/api/v10",
|
||||
});
|
||||
|
||||
export async function userGuilds(userId: string) {
|
||||
const path = "/users/@me/guilds";
|
||||
const key = `${path}:${userId}`;
|
||||
const consoleKey = `${path.green}:${userId.yellow}`;
|
||||
|
||||
const tokens = await getAccessToken(userId);
|
||||
if (!tokens) {
|
||||
console.log("No discord access token!");
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentDate = new Date();
|
||||
const fifteenMinutesAgo = new Date(currentDate.getTime() - 15 * 60 * 1000);
|
||||
|
||||
let guilds: Guilds | undefined = JSON.parse(
|
||||
(
|
||||
await db.query.cache
|
||||
.findFirst({
|
||||
where: and(
|
||||
eq(cache.key, key),
|
||||
gt(cache.createdAt, fifteenMinutesAgo),
|
||||
),
|
||||
})
|
||||
.execute()
|
||||
)?.value ?? "null",
|
||||
);
|
||||
|
||||
if (!guilds) {
|
||||
const { data, error } = await discordApi(path, {
|
||||
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
||||
});
|
||||
|
||||
if (error) console.log("Discord api error:", { error });
|
||||
|
||||
guilds = data;
|
||||
|
||||
const set = {
|
||||
key,
|
||||
value: JSON.stringify(data),
|
||||
createdAt: currentDate,
|
||||
};
|
||||
await db.insert(cache).values(set).onConflictDoUpdate({
|
||||
set,
|
||||
target: cache.key,
|
||||
});
|
||||
console.log("To cache written.", consoleKey);
|
||||
} else {
|
||||
console.log("Cache value used!", consoleKey);
|
||||
}
|
||||
|
||||
return guilds;
|
||||
}
|
||||
|
||||
export async function guildChannels(guildId: bigint) {
|
||||
const path = "/guilds/{guild_id}/channels";
|
||||
const key = `${path}:${String(guildId)}`;
|
||||
const consoleKey = `${path.green}:${String(guildId).yellow}`;
|
||||
|
||||
const currentDate = new Date();
|
||||
const fifteenMinutesAgo = new Date(currentDate.getTime() - 15 * 60 * 1000);
|
||||
|
||||
let channels: Channels | undefined = JSON.parse(
|
||||
(
|
||||
await db.query.cache
|
||||
.findFirst({
|
||||
where: and(
|
||||
eq(cache.key, key),
|
||||
gt(cache.createdAt, fifteenMinutesAgo),
|
||||
),
|
||||
})
|
||||
.execute()
|
||||
)?.value ?? "null",
|
||||
);
|
||||
|
||||
if (!channels) {
|
||||
const { data, error } = await discordApi(path, {
|
||||
params: {
|
||||
path: {
|
||||
guild_id: String(guildId),
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bot ${import.meta.env.VITE_DISCORD_BOT_TOKEN}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) console.log("Discord api error:", { error });
|
||||
|
||||
channels = data;
|
||||
|
||||
const set = {
|
||||
key,
|
||||
value: JSON.stringify(data),
|
||||
createdAt: currentDate,
|
||||
};
|
||||
await db.insert(cache).values(set).onConflictDoUpdate({
|
||||
set,
|
||||
target: cache.key,
|
||||
});
|
||||
|
||||
console.log("To cache written.", consoleKey);
|
||||
} else {
|
||||
console.log("Cache value used!", consoleKey);
|
||||
}
|
||||
|
||||
return channels;
|
||||
}
|
111
src/lib/responseBuilders.ts
Normal file
|
@ -0,0 +1,111 @@
|
|||
import stringify from "json-stable-stringify";
|
||||
import objectHash from "object-hash";
|
||||
import { guilds, matches, tpMessages } from "~/drizzle/schema";
|
||||
import { ExtractDataTypes, GetColumns } from "~/types/db";
|
||||
import { components } from "~/types/liljudd";
|
||||
|
||||
export const buildMatches = (
|
||||
queryMatches: ExtractDataTypes<GetColumns<typeof matches>>[],
|
||||
): components["schemas"]["match"][] =>
|
||||
queryMatches.map(
|
||||
({
|
||||
channelId,
|
||||
createrId,
|
||||
roleId,
|
||||
messageId,
|
||||
matchType,
|
||||
opponentName,
|
||||
utc_ts,
|
||||
}) => ({
|
||||
channelId: channelId.toString(),
|
||||
createrId: createrId.toString(),
|
||||
roleId: roleId.toString(),
|
||||
messageId: messageId.toString(),
|
||||
matchType,
|
||||
opponentName,
|
||||
utc_ts: utc_ts.toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
export const splitInterval = (tpInterval: number) => {
|
||||
const targetMinute = tpInterval & 63;
|
||||
const targetHour = (tpInterval >> 6) & 31;
|
||||
const targetDay = (tpInterval >> 11) & 7;
|
||||
|
||||
return { targetMinute, targetHour, targetDay };
|
||||
};
|
||||
|
||||
export const combineInterval = (
|
||||
targetMinute: number,
|
||||
targetHour: number,
|
||||
targetDay: number,
|
||||
) => {
|
||||
const tpInterval = targetMinute | (targetHour << 6) | (targetDay << 11);
|
||||
|
||||
return tpInterval;
|
||||
};
|
||||
|
||||
export const DayKeys = ["0", "1", "2", "3", "4", "5", "6"] as const;
|
||||
export type DayKeys = (typeof DayKeys)[number];
|
||||
export type Messages = Record<DayKeys, string | null>;
|
||||
|
||||
export const buildTpMessages = (
|
||||
messages: ExtractDataTypes<GetColumns<typeof tpMessages>>[],
|
||||
) =>
|
||||
messages.reduce(
|
||||
(acc, message) => {
|
||||
const day = message.day.toString() as DayKeys;
|
||||
if (!/^[0-6]$/.test(day)) return acc;
|
||||
acc[day] = message.messageId?.toString() ?? null;
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
"0": null,
|
||||
"1": null,
|
||||
"2": null,
|
||||
"3": null,
|
||||
"4": null,
|
||||
"5": null,
|
||||
"6": null,
|
||||
} as Messages,
|
||||
);
|
||||
|
||||
export function buildConfig(
|
||||
guildQuery: ExtractDataTypes<GetColumns<typeof guilds>> & {
|
||||
tpMessages: ExtractDataTypes<GetColumns<typeof tpMessages>>[];
|
||||
matches: ExtractDataTypes<GetColumns<typeof matches>>[];
|
||||
},
|
||||
): components["schemas"]["guildConfig"] {
|
||||
const {
|
||||
id,
|
||||
timezone,
|
||||
tpChannelId,
|
||||
tpInterval,
|
||||
tpRolesEnabled,
|
||||
isAvailableRoleId,
|
||||
wantsToBeNotifieRoledId,
|
||||
tpMessages,
|
||||
} = guildQuery;
|
||||
|
||||
const payload = {
|
||||
guildId: id.toString(),
|
||||
timezone,
|
||||
features: {
|
||||
timePlanning: {
|
||||
channelId: tpChannelId?.toString() ?? null,
|
||||
...splitInterval(tpInterval),
|
||||
roles: {
|
||||
enabled: tpRolesEnabled,
|
||||
isAvailableRoleId: isAvailableRoleId?.toString() ?? null,
|
||||
wantsToBeNotifieRoledId: wantsToBeNotifieRoledId?.toString() ?? null,
|
||||
},
|
||||
messageIds: buildTpMessages(tpMessages),
|
||||
},
|
||||
},
|
||||
matches: buildMatches(guildQuery.matches),
|
||||
};
|
||||
|
||||
// generate checksum from payload because
|
||||
// from guildQuery results in bigint serialization error
|
||||
return { ...payload, checksum: objectHash(stringify(payload)) };
|
||||
}
|
39
src/lib/responses.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import httpStatus from "http-status";
|
||||
import {
|
||||
APIResponse,
|
||||
Methods,
|
||||
MyPaths,
|
||||
ResponseSchemas,
|
||||
StatusCodes,
|
||||
} from "~/types/backend";
|
||||
|
||||
export function ErrorResponse<
|
||||
P extends MyPaths,
|
||||
M extends Methods<P>,
|
||||
C extends StatusCodes<P, M> = StatusCodes<P, M>,
|
||||
>(code: C, error?: string): APIResponse<P, M> {
|
||||
console.log(code, error);
|
||||
const responseData = {
|
||||
error: error ?? httpStatus[`${httpStatus[code]}_NAME`],
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(responseData), {
|
||||
status: httpStatus[code],
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function Res<
|
||||
P extends MyPaths,
|
||||
M extends Methods<P>,
|
||||
C extends StatusCodes<P, M> = StatusCodes<P, M>,
|
||||
>(code: C, payload: ResponseSchemas<P, M, C>): APIResponse<P, M> {
|
||||
return new Response(payload === null ? null : JSON.stringify(payload), {
|
||||
status: httpStatus[code],
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
46
src/lib/zod.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
import moment from "moment-timezone";
|
||||
import { z } from "zod";
|
||||
|
||||
const zodId = z
|
||||
.string()
|
||||
.refine((value) => /^\d{7,20}$/.test(value), "Invalid ID supplied");
|
||||
export const zodBigIntId = zodId.transform((value) => BigInt(value));
|
||||
|
||||
export const zodTpMessages = z.object({
|
||||
channelId: zodId.nullable(),
|
||||
targetMinute: z.number().nonnegative().max(59),
|
||||
targetHour: z.number().nonnegative().max(23),
|
||||
targetDay: z.number().nonnegative().max(6),
|
||||
roles: z.object({
|
||||
enabled: z.boolean(),
|
||||
isAvailableRoleId: zodId.nullable(),
|
||||
wantsToBeNotifieRoledId: zodId.nullable(),
|
||||
}),
|
||||
messageIds: z.object({
|
||||
"0": zodId.nullable(),
|
||||
"1": zodId.nullable(),
|
||||
"2": zodId.nullable(),
|
||||
"3": zodId.nullable(),
|
||||
"4": zodId.nullable(),
|
||||
"5": zodId.nullable(),
|
||||
"6": zodId.nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const zodMatch = z.object({
|
||||
match: z.object({
|
||||
channelId: zodId,
|
||||
createrId: zodId,
|
||||
messageId: zodId,
|
||||
roleId: zodId,
|
||||
matchType: z.string(),
|
||||
opponentName: z.string(),
|
||||
utc_ts: z.string().datetime(),
|
||||
}),
|
||||
timezone: z
|
||||
.string()
|
||||
.refine(
|
||||
(value) => moment.tz.names().includes(value),
|
||||
"Unknown timezone supplied",
|
||||
),
|
||||
});
|
103
src/middleware.ts
Normal file
|
@ -0,0 +1,103 @@
|
|||
import { createMiddleware } from "@solidjs/start/middleware";
|
||||
import colors from "colors";
|
||||
import fs from "fs";
|
||||
import { verifyRequestOrigin } from "lucia";
|
||||
import { getCookie, getHeader, setCookie } from "vinxi/http";
|
||||
import { lucia } from "./lib/auth";
|
||||
|
||||
colors.enable();
|
||||
|
||||
let started: boolean = false;
|
||||
|
||||
export default createMiddleware({
|
||||
onRequest: async (event) => {
|
||||
if (event.nativeEvent.node.req.method !== "GET") {
|
||||
const originHeader = getHeader("Origin") ?? null;
|
||||
const hostHeader = getHeader("Host") ?? null;
|
||||
if (
|
||||
!originHeader ||
|
||||
!hostHeader ||
|
||||
!verifyRequestOrigin(originHeader, [hostHeader])
|
||||
) {
|
||||
event.nativeEvent.node.res.writeHead(403).end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const sessionId = getCookie(lucia.sessionCookieName) ?? null;
|
||||
if (!sessionId) {
|
||||
event.nativeEvent.context.session = null;
|
||||
event.nativeEvent.context.user = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const { session, user } = await lucia.validateSession(sessionId);
|
||||
if (session && session.fresh) {
|
||||
const sessionCookie = lucia.createSessionCookie(session.id);
|
||||
setCookie(
|
||||
sessionCookie.name,
|
||||
sessionCookie.value,
|
||||
sessionCookie.attributes,
|
||||
);
|
||||
}
|
||||
if (!session) {
|
||||
const sessionCookie = lucia.createBlankSessionCookie();
|
||||
setCookie(
|
||||
sessionCookie.name,
|
||||
sessionCookie.value,
|
||||
sessionCookie.attributes,
|
||||
);
|
||||
}
|
||||
|
||||
event.nativeEvent.context.session = session;
|
||||
event.nativeEvent.context.user = user;
|
||||
},
|
||||
onBeforeResponse: async (event, response) => {
|
||||
let consoleLog = "",
|
||||
fileLog = "";
|
||||
|
||||
if (!started) {
|
||||
try {
|
||||
await fs.promises.mkdir("log");
|
||||
console.log("Created 'log' Folder.");
|
||||
} catch {}
|
||||
started = true;
|
||||
}
|
||||
|
||||
const currentDate = new Date();
|
||||
const year = currentDate.getFullYear();
|
||||
const month = String(currentDate.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(currentDate.getDate()).padStart(2, "0");
|
||||
const hours = String(currentDate.getHours()).padStart(2, "0");
|
||||
const minutes = String(currentDate.getMinutes()).padStart(2, "0");
|
||||
const seconds = String(currentDate.getSeconds()).padStart(2, "0");
|
||||
|
||||
// Create a short and numeric representation
|
||||
const date = `[${year}-${month}-${day}_${hours}:${minutes}:${seconds}]`;
|
||||
const xForwardedFor = event.request.headers.get("x-forwarded-for");
|
||||
const ip = (xForwardedFor || "127.0.0.1, 192.168.178.1").split(",");
|
||||
const route = event.request.url;
|
||||
const frontend = !new URL(event.request.url).pathname.startsWith("/api");
|
||||
const method = frontend ? "Frontend" : event.request.method;
|
||||
const code =
|
||||
(response.body as Response | undefined)?.status ?? event.response.status;
|
||||
consoleLog += [
|
||||
date,
|
||||
ip[0].yellow,
|
||||
method,
|
||||
code,
|
||||
route?.green,
|
||||
event.nativeEvent.context.user?.discord_id.rainbow,
|
||||
].join(" ");
|
||||
fileLog += [
|
||||
date,
|
||||
ip[0],
|
||||
method,
|
||||
code,
|
||||
route,
|
||||
event.nativeEvent.context.user?.discord_id,
|
||||
].join(" ");
|
||||
await fs.promises.appendFile("log/log.txt", fileLog + "\n");
|
||||
console.log(consoleLog);
|
||||
},
|
||||
});
|
|
@ -1,67 +0,0 @@
|
|||
---
|
||||
import Layout from "@layouts/Layout.astro";
|
||||
import "../styles/pages/about.scss";
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<h1>About</h1>
|
||||
<div class="aboutdiv">
|
||||
<section>
|
||||
<h2>Why does this bot exist?</h2>
|
||||
<p>
|
||||
We had a person in our team, who sent <a
|
||||
href="https://static.moonleay.net/img/lilJuddWeb/about/oldplanningmsg.png"
|
||||
target="_blank"
|
||||
>these planning messages</a
|
||||
> and I thought that this should be automated. Some time later the first
|
||||
version of li'l Judd was born. Today the bot has more features and keeps getting more of them! It is
|
||||
designed to actually improve the Splatoon experience and not be the
|
||||
10000th moderation and general utility bot with the same features as all
|
||||
bots.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Who is behind this?</h2>
|
||||
<p>
|
||||
The bot is currently being developed by <a
|
||||
href="/contact"
|
||||
target="_self">moonleay</a
|
||||
> (hey that's me!) with occasional help from his friends!
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>How can I trust you?</h2>
|
||||
<p>
|
||||
The bot only requests permissions, which are needed for it to work. Additionally,
|
||||
if you want to check how the bot works under the hood, you can
|
||||
<a href="https://git.moonleay.net/DiscordBots/lilJudd">read the code</a>
|
||||
and if you still don't trust me, you can always host the bot yourself!
|
||||
A guide on how to do that can be found in the README of the git project.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Where is my data stored?</h2>
|
||||
<p>
|
||||
Your data is stored on a VPS from Contabo in Germany. The bot used to be hosted on a server in my basement,
|
||||
but I moved it to a VPS, because my internet connection was not stable enough.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>So whats in the future?</h2>
|
||||
<p>
|
||||
I plan on adding features, which are aimed to improve your and your teams
|
||||
competitive experience! You can check out my public todo list <a href="https://todo.moonleay.net/share/OmisuzgPDdsrCAXKjGrTfYzWwqNDNclOMGJWeMsi/auth?view=kanban" target="_blank">here</a>.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Hey, there is this really cool idea I have! Can you add it?</h2>
|
||||
<p>
|
||||
Just message me! I can't promise anything, but I am always open to new
|
||||
ideas and improvements! You can find ways to contact me <a
|
||||
href="/contact"
|
||||
target="_self">here</a
|
||||
>.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</Layout>
|
|
@ -1,166 +0,0 @@
|
|||
---
|
||||
import Layout from "@layouts/Layout.astro";
|
||||
import "../styles/pages/acknowledgements.scss";
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<div class="wrapper">
|
||||
<h1>Acknowledgements</h1>
|
||||
<section>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<p>tool</p>
|
||||
</th>
|
||||
<th>
|
||||
<p>license</p>
|
||||
</th>
|
||||
<th>
|
||||
<p>page</p>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Kotlin</p>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="https://kotlinlang.org/docs/faq.html#is-kotlin-free"
|
||||
target="_blank">Apache license 2.0</a
|
||||
>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://kotlinlang.org/" target="_blank">website</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Kord</p>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="https://github.com/kordlib/kord/blob/main/LICENSE"
|
||||
target="_blank">MIT license</a
|
||||
>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://kord.dev/" target="_blank">website</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Kord Extensions</p>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="https://github.com/Kord-Extensions/kord-extensions/blob/root/LICENSE"
|
||||
target="_blank">Mozilla Public License 2.0</a
|
||||
>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://kordex.kotlindiscord.com/" target="_blank"
|
||||
>website</a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>PostgreSQL</p>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.postgresql.org/about/licence/" target="_blank"
|
||||
>PostgreSQL license</a
|
||||
>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.postgresql.org/" target="_blank">website</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>kotlinx-coroutines-core</p>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="https://github.com/Kotlin/kotlinx.coroutines/blob/master/LICENSE.txt"
|
||||
target="_blank">Apache license 2.0</a
|
||||
>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="https://github.com/Kotlin/kotlinx.coroutines"
|
||||
target="_blank">repo</a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>slf4j</p>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="https://github.com/qos-ch/slf4j/blob/master/LICENSE.txt"
|
||||
target="_blank">MIT license</a
|
||||
>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.slf4j.org/" target="_blank">website</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Exposed</p>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://github.com/JetBrains/Exposed/blob/main/LICENSE.txt"
|
||||
>Apache license 2.0</a
|
||||
>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://github.com/JetBrains/Exposed" target="_blank"
|
||||
>repo</a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Krontab</p>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="https://github.com/InsanusMokrassar/krontab/blob/master/LICENSE"
|
||||
target="_blank">Apache license 2.0</a
|
||||
>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="https://github.com/InsanusMokrassar/krontab"
|
||||
target="_blank">repo</a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Splatoon3.ink</p>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="https://github.com/misenhower/splatoon3.ink/blob/main/license.md"
|
||||
target="_blank"
|
||||
>MIT License</a>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="https://splatoon3.ink/"
|
||||
target="_blank"
|
||||
>website</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
</Layout>
|
|
@ -1,20 +0,0 @@
|
|||
---
|
||||
import Layout from "@layouts/Layout.astro";
|
||||
import "../styles/pages/contact.scss";
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<div class="wrapper">
|
||||
<h1>Contact</h1>
|
||||
<section class="contact">
|
||||
<a href="mailto:contact@moonleay.net" target="_blank">
|
||||
<img src="https://static.moonleay.net/img/lilJuddWeb/logos/email.svg" alt="Email"/>
|
||||
contact@moonleay.net
|
||||
</a>
|
||||
<a href="https://discord.com/users/372703841151614976" target="_blank">
|
||||
<img src="https://static.moonleay.net/img/lilJuddWeb/logos/discord.svg" alt="Discord"/>
|
||||
@moonleay
|
||||
</a>
|
||||
</section>
|
||||
</div>
|
||||
</Layout>
|
|
@ -1,49 +0,0 @@
|
|||
---
|
||||
import ImageSection from "@components/ImageSection.astro";
|
||||
import Layout from "@layouts/Layout.astro";
|
||||
import "../styles/pages/features.scss";
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<div class="container">
|
||||
<h1 class="title">Features</h1>
|
||||
<div class="gridlayout">
|
||||
<ImageSection
|
||||
imgUrl="https://static.moonleay.net/img/lilJuddWeb/features/timeplanner.png"
|
||||
imgAlt="Screenshot of the time planning feature of li'l Judd"
|
||||
title="Time Planning and Management"
|
||||
description="Helps you to see on which days your fellow team mates are available."
|
||||
note="The bot can send these planning messages every monday at 3AM. Members can click the buttons on each message to communicate, if they have time on that day."
|
||||
/>
|
||||
<ImageSection
|
||||
imgUrl="https://static.moonleay.net/img/lilJuddWeb/features/matchplanner.png"
|
||||
imgAlt="Screenshot of the match planning feature of li'l Judd"
|
||||
title="Match Planner"
|
||||
description="Make sure that you know when your next match is and who will participate."
|
||||
note="The bot can send these planning messages, when the command /match is used. Members can click the buttons on each message to communicate, if they will participate in the match. Participating members will get a role until the match has started."
|
||||
/>
|
||||
<ImageSection
|
||||
imgUrl="https://static.moonleay.net/img/lilJuddWeb/features/notifs.png"
|
||||
imgAlt="Screenshot of the notification feature of li'l Judd"
|
||||
title="Notifications"
|
||||
description="Make sure that you and your team members vote in the Time Planner."
|
||||
note="The bot can add roles. The first one gets pinged, when the time planner sends the messages, the other one gets assigned to the available members of the day, so that it is possible to ping all available people."
|
||||
/>
|
||||
<ImageSection
|
||||
imgUrl="https://static.moonleay.net/img/lilJuddWeb/features/rotationstatus.png"
|
||||
imgAlt="Screenshot of the current x map rotation in li'l Judd's status"
|
||||
title="Rotation Status"
|
||||
description="Li'l Judd can show you the current map rotation in his status."
|
||||
note="The bot cycles through the current map and mode rotation. It updates every few seconds."
|
||||
/>
|
||||
<ImageSection
|
||||
span
|
||||
imgUrl="https://static.moonleay.net/img/lilJuddWeb/features/unknown.png"
|
||||
imgAlt="A Question Mark"
|
||||
title="More to come.."
|
||||
description="The bot is still in development. More features will be added."
|
||||
note="If you have a specific feature request, you can contact me on Discord: @moonleay or email: contact at moonleay dot net"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
|
@ -1,38 +0,0 @@
|
|||
---
|
||||
import Layout from "@layouts/Layout.astro";
|
||||
import "../styles/pages/how-do-i.scss";
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<h1 class="title">How do I...?</h1>
|
||||
<section class="section">
|
||||
<h2>.. enable / disable certain features?</h2>
|
||||
<p>
|
||||
Features can be enabled and disables using the <code>/feature</code>
|
||||
command.<br />Example:
|
||||
</p>
|
||||
<div class="imgwrapper">
|
||||
<img
|
||||
src="https://static.moonleay.net/img/lilJuddWeb/howdoi/featureexample.png"
|
||||
alt="A screenshot of the example in Discord."/>
|
||||
<p><code>/feature feature:Time Planning Feature set:Enable channel:#ich-kann-heute</code></p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<h2>.. create a match?</h2>
|
||||
<p>
|
||||
You can create a match time using the <code>/match</code> command.<br
|
||||
/>Example:
|
||||
</p>
|
||||
<div class="imgwrapper">
|
||||
<img
|
||||
src="https://static.moonleay.net/img/lilJuddWeb/howdoi/matchexample.png"
|
||||
alt="A screenshot of the example in Discord."
|
||||
/>
|
||||
<p><code>/match match:Ladder Match timestamp:24.12.2069 04:20 opponent:Forbidden</code></p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="footernotesection">
|
||||
<p>Is something missing here?<br/>Please <a href="/contact" target="_self">contact me</a>!</p>
|
||||
</section>
|
||||
</Layout>
|
|
@ -1,29 +0,0 @@
|
|||
---
|
||||
import Layout from "@layouts/Layout.astro";
|
||||
import "../styles/pages/imprint.scss";
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<section class="wrapper">
|
||||
<h1>Imprint</h1>
|
||||
<p>
|
||||
In accordance with the applicable legal regulations, we would like to
|
||||
point out that this website is not subject to the imprint obligation. This
|
||||
notice is for clarification and transparent information for our visitors.
|
||||
<br />
|
||||
According to § 5 TMG (Telemedia Act), certain providers of telemedia in
|
||||
Germany are obliged to provide an imprint with certain information. This
|
||||
obligation applies in particular to commercial websites or those that are
|
||||
used for business purposes.
|
||||
<br />
|
||||
However, since this website has no business or commercial character and
|
||||
only provides non-commercial, informative or private content, it is not
|
||||
subject to the imprint obligation according to § 5 TMG.
|
||||
<br />
|
||||
Nevertheless, we strive to keep all information on this website correct
|
||||
and up-to-date to the best of our knowledge and belief. However, if you
|
||||
have any questions or concerns, please feel free to contact us using the
|
||||
<a href="/contact" target="_self">contact options</a> provided.
|
||||
</p>
|
||||
</section>
|
||||
</Layout>
|
|
@ -1,17 +0,0 @@
|
|||
---
|
||||
import Layout from "@layouts/Layout.astro";
|
||||
import '../styles/pages/index.scss';
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<section class="section">
|
||||
<h1>li'l Judd</h1>
|
||||
<h5>The competetive Splatoon Bot</h5>
|
||||
<div>
|
||||
<p class="p1">Improve your competitive Splatoon experience!</p>
|
||||
<p class="p2">
|
||||
See what li'l Judd can help you with: <a href="/features">here</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
|
@ -1,130 +0,0 @@
|
|||
---
|
||||
import Layout from "@layouts/Layout.astro";
|
||||
import "../styles/pages/privacy-policy.scss";
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<div class="wrapper">
|
||||
<div>
|
||||
<h1>GDPR Privacy Policy for li'l Judd</h1>
|
||||
<h4>Last updated: 2023-12-05</h4>
|
||||
</div>
|
||||
<section>
|
||||
<h2>1. Introduction</h2>
|
||||
<p>Welcome to li'l Judd! This Privacy Policy explains how we collect, use, disclose, and safeguard your personal information when you use our Discord bot service.</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>2. Data Controller</h2>
|
||||
<p>The data controller for the processing of your personal data is: Eric L
|
||||
<br/>
|
||||
Reachable by email at contact at moonleay dot net (<a href="/contact" target="_self">See contact page.</a>)
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>3. Information We Collect</h2>
|
||||
<h3>3.1 Discord User Data</h3>
|
||||
<p>
|
||||
We may collect and process the following personal data related to your Discord account & guilds:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>- Discord User ID</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>- Discord username, discriminator and IDs of users</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>- Guild name and ID</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>- Channel names and IDs of channels with active features</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>- Message IDs of the bot messages</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>- Role IDs of created roles</p>
|
||||
</li>
|
||||
</ul>
|
||||
<h3>3.2 Usage Data</h3>
|
||||
<p>We may collect information on how you interact with our bot, including but not limited to:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>- Commands issued</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>- Server and channel information</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>- Timestamps of interactions</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h2>4. How we use your Information</h2>
|
||||
<p>We process your personal data for the following purposes:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>- To provide and maintain the bot service.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>- To improve, customize, and optimize our bot.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>- To respond to your requests, comments, or inquiries.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>- To comply with legal obligations.</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h2>5. Legal Basis for Processing</h2>
|
||||
<p>We process your personal data on the following legal bases:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>- Consent: You have given your consent for the processing of your personal data for one or more specific purposes.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>- Performance of a contract: The processing is necessary for the performance of the agreement between you and us.</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h2>6. Data Sharing</h2>
|
||||
<p>We do not sell, trade, or otherwise transfer your personal information to third parties. However, we may share your information with:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>- Third-party service providers involved in the operation and maintenance of the bot.</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h2>7. Data Security</h2>
|
||||
<p>We implement reasonable security measures to protect your personal information from unauthorized access, disclosure, alteration, and destruction.</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>8. Your Rights</h2>
|
||||
<p>You have the following rights regarding your personal data:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>- Right to withdraw consent: You have the right to withdraw your consent at any time. You can do this by contacting us at contact@moonleay.net.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>- Right to rectification: You can request corrections to inaccurate or incomplete information.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>- Right to erasure: You can request the deletion of your personal data.</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h2>9. Changes to this Privacy Policy</h2>
|
||||
<p>We may update this Privacy Policy to reflect changes in our practices. The updated version will be posted on https://liljudd.ink/privacy-policy.</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>10. Contact Information</h2>
|
||||
<p>If you have any questions or concerns about this Privacy Policy, please contact us at contact@moonleay.net.</p>
|
||||
</section>
|
||||
</div>
|
||||
</Layout>
|
|
@ -1,58 +0,0 @@
|
|||
---
|
||||
import Layout from "@layouts/Layout.astro";
|
||||
import "../styles/pages/stack.scss"
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<h1 class="title">The Stack</h1>
|
||||
<section class="stacksection">
|
||||
<img
|
||||
src="https://static.moonleay.net/img/lilJuddWeb/logos/kotlin.svg"
|
||||
alt="Kotlin 'K' logo"
|
||||
/>
|
||||
<div class="stackgrid_3 stackitm">
|
||||
<h1>The Kotlin programming language</h1>
|
||||
<p>
|
||||
A programming language, which runs in the JVM. Also my main language.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="stacksection">
|
||||
<img
|
||||
src="https://static.moonleay.net/img/lilJuddWeb/logos/kord.png"
|
||||
alt="The Kord logo"
|
||||
/>
|
||||
<div class="stackgrid_3 stackitm">
|
||||
<h1>The Kord library</h1>
|
||||
<p>A Kotlin library for creating Discord bots. Pretty bare bones.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="stacksection">
|
||||
<img
|
||||
src="https://static.moonleay.net/img/lilJuddWeb/logos/kordextensions.png"
|
||||
alt="The Kord-Extensions logo"
|
||||
/>
|
||||
<div class="stackgrid_3 stackitm">
|
||||
<h1>The Kord Extensions library</h1>
|
||||
<p>A Kotlin library to improve the Kord experience.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="stacksection">
|
||||
<img
|
||||
src="https://static.moonleay.net/img/lilJuddWeb/logos/pgelephant.png"
|
||||
alt="The PostgreSQL elephant"
|
||||
/>
|
||||
<div class="stackgrid_3 stackitm">
|
||||
<h1>The PostgreSQL database</h1>
|
||||
<p>
|
||||
A fast and reliable database. Also something, which I already used
|
||||
beforehand.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="stacknote">
|
||||
<p>
|
||||
To view all used libraries and their licenses, check the <a href="/acknowledgements">Acknowledgements</a>.
|
||||
</p>
|
||||
</section>
|
||||
</Layout>
|
|
@ -1,52 +0,0 @@
|
|||
---
|
||||
import Layout from "@layouts/Layout.astro";
|
||||
import "../styles/pages/terms-of-service.scss";
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<div class="wrapper">
|
||||
<h1>Terms of Service</h1>
|
||||
<div>
|
||||
<h2>Usage Agreement</h2>
|
||||
<p>
|
||||
By inviting the bot and using its features (commands, planning system) are you agreeing to the below mentioned Terms and Privacy Policy (Policy) of the bot.<br/>
|
||||
|
||||
You acknowledge that you have the privilege to use the bot freely on any Discord Server (Server) you share with it, that you can invite it to any Server that you have "Manage Server" rights for and that this privilege might get revoked for you, if you're subject of breaking the terms and/or policy of this bot, or the <a href="https://discord.com/terms" target="_blank">Terms of Service</a>, <a href="https://discord.com/privacy" target="_blank">Privacy Policy</a> and/or <a href="https://discord.com/guidelines" target="_blank">Community Guidelines</a> of <a href="https://discord.com/" target="_blank">Discord Inc</a>.<br/>
|
||||
|
||||
Through Inviting the bot may it collect specific data as described in its Policy.<br/>
|
||||
The intended usage of this data is for core functionalities of the bot such as command handling, guild-specific settings and the time-planning system.<br/>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Intended Age</h2>
|
||||
<p>
|
||||
The bot may not be used by individuals under the minimal age described in Discord's Terms of Service.<br/>
|
||||
Doing so will be seen as a violation of these terms and will result in a removal of the bot from any servers you own.<br/>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Affiliation</h2>
|
||||
<p>
|
||||
The Bot is not affiliated with, supported or made by Discord Inc.<br/>
|
||||
Any direct connection to Discord or any of its Trademark objects is purely coincidental. We do not claim to have the copyright ownership of any of Discord's assets, trademarks or other intellectual property.<br/>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Liability</h2>
|
||||
<p>
|
||||
The owner of the bot may not be made liable for individuals breaking these Terms at any given time.<br/>
|
||||
He has faith in the end users being truthfull about their information and not misusing this bot or the services of Discord Inc in a malicious way.<br/>
|
||||
|
||||
We reserve the right to update these terms at our own discretion, giving you a 1-Week (7 days) period to opt out of these terms if you're not agreeing with the new changes.
|
||||
You may opt out by Removing the bot from any Server you have the rights for.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Contact</h2>
|
||||
<p>
|
||||
People may get in contact through e-mail at contact@moonleay.net, or through the official Support Discord of the Bot.
|
||||
Other ways of support may be provided but aren't guaranteed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
23
src/routes/[...404].tsx
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { Title } from "@solidjs/meta";
|
||||
import { HttpStatusCode } from "@solidjs/start";
|
||||
import Layout from "~/components/Layout";
|
||||
import "../styles/pages/index.scss";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<Layout site="index">
|
||||
<Title>Not Found</Title>
|
||||
<HttpStatusCode code={404} />
|
||||
<section class="index">
|
||||
<h1>404 - Whoops, ink spill!</h1>
|
||||
<h5>li'l Judd couldn't find this turf.</h5>
|
||||
<div>
|
||||
<p class="p1">
|
||||
Head back to <a href="/">base</a> and splat into action from there!
|
||||
🦑🕹️
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
);
|
||||
}
|
78
src/routes/about.tsx
Normal file
|
@ -0,0 +1,78 @@
|
|||
import Layout from "~/components/Layout";
|
||||
import "../styles/pages/about.scss";
|
||||
|
||||
function about() {
|
||||
return (
|
||||
<Layout site="about">
|
||||
<h1>About</h1>
|
||||
<section>
|
||||
<h2>Why does this bot exist?</h2>
|
||||
<p>
|
||||
We had a person in our team, who sent{" "}
|
||||
<a href="/assets/screenshots/oldplanningmsg.png" target="_blank">
|
||||
these planning messages
|
||||
</a>{" "}
|
||||
and I thought that this should be automated. Some time later the first
|
||||
version of li'l Judd was born. Today the bot has more features and
|
||||
keeps getting more of them! It is designed to actually improve the
|
||||
Splatoon experience and not be the 10000th moderation and general
|
||||
utility bot with the same features as all bots.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Who is behind this?</h2>
|
||||
<p>
|
||||
The bot is currently being developed by{" "}
|
||||
<a href="/contact">moonleay</a> (hey that's me!) with occasional
|
||||
help from his friends!
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>How can I trust you?</h2>
|
||||
<p>
|
||||
The bot only requests permissions, which are needed for it to work.
|
||||
Additionally, if you want to check how the bot works under the hood,
|
||||
you can
|
||||
<a href="https://git.moonleay.net/DiscordBots/lilJudd">
|
||||
read the code
|
||||
</a>
|
||||
and if you still don't trust me, you can always host the bot yourself!
|
||||
A guide on how to do that can be found in the README of the git
|
||||
project.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Where is my data stored?</h2>
|
||||
<p>
|
||||
Your data is stored on a VPS from Contabo in Germany. The bot used to
|
||||
be hosted on a server in my basement, but I moved it to a VPS, because
|
||||
my internet connection was not stable enough.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>So whats in the future?</h2>
|
||||
<p>
|
||||
I plan on adding features, which are aimed to improve your and your
|
||||
teams competitive experience! You can check out my public todo list{" "}
|
||||
<a
|
||||
href="https://todo.moonleay.net/share/OmisuzgPDdsrCAXKjGrTfYzWwqNDNclOMGJWeMsi/auth?view=kanban"
|
||||
target="_blank"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Hey, there is this really cool idea I have! Can you add it?</h2>
|
||||
<p>
|
||||
Just message me! I can't promise anything, but I am always open to new
|
||||
ideas and improvements! You can find ways to contact me{" "}
|
||||
<a href="/contact">here</a>.
|
||||
</p>
|
||||
</section>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default about;
|
196
src/routes/acknowledgements.tsx
Normal file
|
@ -0,0 +1,196 @@
|
|||
import Layout from "~/components/Layout";
|
||||
import "../styles/pages/acknowledgements.scss";
|
||||
|
||||
function acknowledgements() {
|
||||
return (
|
||||
<Layout site="acknowledgements">
|
||||
<h1>Acknowledgements</h1>
|
||||
<section>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<p>tool</p>
|
||||
</th>
|
||||
<th>
|
||||
<p>license</p>
|
||||
</th>
|
||||
<th>
|
||||
<p>page</p>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Kotlin</p>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="https://kotlinlang.org/docs/faq.html#is-kotlin-free"
|
||||
target="_blank"
|
||||
>
|
||||
Apache license 2.0
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://kotlinlang.org/" target="_blank">
|
||||
website
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Kord</p>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="https://github.com/kordlib/kord/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
>
|
||||
MIT license
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://kord.dev/" target="_blank">
|
||||
website
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Kord Extensions</p>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="https://github.com/Kord-Extensions/kord-extensions/blob/root/LICENSE"
|
||||
target="_blank"
|
||||
>
|
||||
Mozilla Public License 2.0
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://kordex.kotlindiscord.com/" target="_blank">
|
||||
website
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>PostgreSQL</p>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="https://www.postgresql.org/about/licence/"
|
||||
target="_blank"
|
||||
>
|
||||
PostgreSQL license
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.postgresql.org/" target="_blank">
|
||||
website
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>kotlinx-coroutines-core</p>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="https://github.com/Kotlin/kotlinx.coroutines/blob/master/LICENSE.txt"
|
||||
target="_blank"
|
||||
>
|
||||
Apache license 2.0
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="https://github.com/Kotlin/kotlinx.coroutines"
|
||||
target="_blank"
|
||||
>
|
||||
repo
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>slf4j</p>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="https://github.com/qos-ch/slf4j/blob/master/LICENSE.txt"
|
||||
target="_blank"
|
||||
>
|
||||
MIT license
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://www.slf4j.org/" target="_blank">
|
||||
website
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Exposed</p>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://github.com/JetBrains/Exposed/blob/main/LICENSE.txt">
|
||||
Apache license 2.0
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://github.com/JetBrains/Exposed" target="_blank">
|
||||
repo
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Krontab</p>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="https://github.com/InsanusMokrassar/krontab/blob/master/LICENSE"
|
||||
target="_blank"
|
||||
>
|
||||
Apache license 2.0
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="https://github.com/InsanusMokrassar/krontab"
|
||||
target="_blank"
|
||||
>
|
||||
repo
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Splatoon3.ink</p>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="https://github.com/misenhower/splatoon3.ink/blob/main/license.md"
|
||||
target="_blank"
|
||||
>
|
||||
MIT License
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://splatoon3.ink/" target="_blank">
|
||||
website
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default acknowledgements;
|
101
src/routes/api/[guildId]/config.ts
Normal file
|
@ -0,0 +1,101 @@
|
|||
import { APIEvent } from "@solidjs/start/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import db from "~/drizzle";
|
||||
import { guilds } from "~/drizzle/schema";
|
||||
import { BasicAuth } from "~/lib/auth";
|
||||
import { buildConfig } from "~/lib/responseBuilders";
|
||||
import { ErrorResponse, Res } from "~/lib/responses";
|
||||
import { zodBigIntId } from "~/lib/zod";
|
||||
import { APIResponse } from "~/types/backend";
|
||||
|
||||
type Path = "/api/{guildId}/config";
|
||||
|
||||
export const GET = async (
|
||||
event: APIEvent,
|
||||
): Promise<APIResponse<Path, "get">> => {
|
||||
switch (event.request.headers.get("authorization")) {
|
||||
case BasicAuth.unencoded:
|
||||
case BasicAuth.encoded:
|
||||
break;
|
||||
|
||||
default:
|
||||
return ErrorResponse("UNAUTHORIZED");
|
||||
}
|
||||
|
||||
let guildId: bigint;
|
||||
try {
|
||||
guildId = zodBigIntId.parse(event.params.guildId);
|
||||
} catch (e) {
|
||||
return ErrorResponse("BAD_REQUEST", JSON.stringify(e));
|
||||
}
|
||||
|
||||
const guildQuery = await db.query.guilds
|
||||
.findFirst({
|
||||
where: eq(guilds.id, guildId),
|
||||
with: { tpMessages: true, matches: true },
|
||||
})
|
||||
.execute();
|
||||
|
||||
if (!guildQuery) return ErrorResponse("NOT_FOUND");
|
||||
|
||||
return Res("OK", buildConfig(guildQuery));
|
||||
};
|
||||
|
||||
export const POST = async (
|
||||
event: APIEvent,
|
||||
): Promise<APIResponse<Path, "post">> => {
|
||||
switch (event.request.headers.get("authorization")) {
|
||||
case BasicAuth.unencoded:
|
||||
case BasicAuth.encoded:
|
||||
break;
|
||||
|
||||
default:
|
||||
return ErrorResponse("UNAUTHORIZED");
|
||||
}
|
||||
|
||||
let guildId: bigint;
|
||||
try {
|
||||
guildId = zodBigIntId.parse(event.params.guildId);
|
||||
} catch (e) {
|
||||
return ErrorResponse("BAD_REQUEST", JSON.stringify(e));
|
||||
}
|
||||
|
||||
const guildQuery = await db.insert(guilds).values({ id: guildId }).execute();
|
||||
|
||||
if (!guildQuery) return ErrorResponse("NOT_FOUND");
|
||||
|
||||
return Res("NO_CONTENT", null);
|
||||
};
|
||||
|
||||
export const DELETE = async (
|
||||
event: APIEvent,
|
||||
): Promise<APIResponse<Path, "delete">> => {
|
||||
switch (event.request.headers.get("authorization")) {
|
||||
case BasicAuth.unencoded:
|
||||
case BasicAuth.encoded:
|
||||
break;
|
||||
|
||||
default:
|
||||
return ErrorResponse("UNAUTHORIZED");
|
||||
}
|
||||
|
||||
let guildId: bigint;
|
||||
try {
|
||||
guildId = zodBigIntId.parse(event.params.guildId);
|
||||
} catch (e) {
|
||||
return ErrorResponse("BAD_REQUEST", JSON.stringify(e));
|
||||
}
|
||||
|
||||
const guildQuery = await db.query.guilds
|
||||
.findFirst({
|
||||
where: eq(guilds.id, guildId),
|
||||
with: { tpMessages: true, matches: true },
|
||||
})
|
||||
.execute();
|
||||
|
||||
if (!guildQuery) return ErrorResponse("NOT_FOUND");
|
||||
|
||||
await db.delete(guilds).where(eq(guilds.id, guildId)).execute();
|
||||
|
||||
return Res("NO_CONTENT", null);
|
||||
};
|
106
src/routes/api/[guildId]/matches.ts
Normal file
|
@ -0,0 +1,106 @@
|
|||
import { APIEvent } from "@solidjs/start/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import db from "~/drizzle";
|
||||
import { guilds, matches } from "~/drizzle/schema";
|
||||
import { BasicAuth } from "~/lib/auth";
|
||||
import { buildMatches } from "~/lib/responseBuilders";
|
||||
import { ErrorResponse, Res } from "~/lib/responses";
|
||||
import { zodBigIntId, zodMatch } from "~/lib/zod";
|
||||
import { APIResponse, RequestBody } from "~/types/backend";
|
||||
|
||||
type Path = "/api/{guildId}/matches";
|
||||
|
||||
export const GET = async (
|
||||
event: APIEvent,
|
||||
): Promise<APIResponse<Path, "get">> => {
|
||||
switch (event.request.headers.get("authorization")) {
|
||||
case BasicAuth.unencoded:
|
||||
case BasicAuth.encoded:
|
||||
break;
|
||||
|
||||
default:
|
||||
return ErrorResponse("UNAUTHORIZED");
|
||||
}
|
||||
|
||||
let guildId: bigint;
|
||||
try {
|
||||
guildId = zodBigIntId.parse(event.params.guildId);
|
||||
} catch (e) {
|
||||
return ErrorResponse("BAD_REQUEST", JSON.stringify(e));
|
||||
}
|
||||
|
||||
const guild = await db.query.guilds
|
||||
.findFirst({
|
||||
where: eq(guilds.id, guildId),
|
||||
with: {
|
||||
matches: true,
|
||||
},
|
||||
})
|
||||
.execute();
|
||||
|
||||
if (!guild) return ErrorResponse("NOT_FOUND");
|
||||
|
||||
return Res("OK", {
|
||||
matches: buildMatches(guild.matches),
|
||||
timezone: guild.timezone,
|
||||
});
|
||||
};
|
||||
|
||||
export const POST = async (
|
||||
event: APIEvent,
|
||||
): Promise<APIResponse<Path, "post">> => {
|
||||
switch (event.request.headers.get("authorization")) {
|
||||
case BasicAuth.unencoded:
|
||||
case BasicAuth.encoded:
|
||||
break;
|
||||
|
||||
default:
|
||||
return ErrorResponse("UNAUTHORIZED");
|
||||
}
|
||||
|
||||
let guildId: bigint;
|
||||
try {
|
||||
guildId = zodBigIntId.parse(event.params.guildId);
|
||||
} catch (e) {
|
||||
return ErrorResponse("BAD_REQUEST", JSON.stringify(e));
|
||||
}
|
||||
|
||||
const guild = await db.query.guilds
|
||||
.findFirst({
|
||||
where: eq(guilds.id, guildId),
|
||||
with: {
|
||||
matches: true,
|
||||
},
|
||||
})
|
||||
.execute();
|
||||
|
||||
if (!guild) return ErrorResponse("NOT_FOUND");
|
||||
|
||||
const unparsedBody = await new Response(event.request.body).json();
|
||||
|
||||
let body: RequestBody<Path, "post">;
|
||||
try {
|
||||
body = zodMatch.parse(unparsedBody);
|
||||
} catch (e) {
|
||||
return ErrorResponse("BAD_REQUEST", JSON.stringify(e));
|
||||
}
|
||||
|
||||
if (body.timezone !== guild.timezone)
|
||||
return ErrorResponse(
|
||||
"BAD_REQUEST",
|
||||
"Match's timezone is different from guild's timezone",
|
||||
);
|
||||
|
||||
await db.insert(matches).values({
|
||||
guildId: guild.id,
|
||||
channelId: BigInt(body.match.channelId),
|
||||
roleId: BigInt(body.match.roleId),
|
||||
createrId: BigInt(body.match.createrId),
|
||||
messageId: BigInt(body.match.messageId),
|
||||
matchType: body.match.matchType,
|
||||
opponentName: body.match.opponentName,
|
||||
utc_ts: new Date(body.match.utc_ts),
|
||||
});
|
||||
|
||||
return Res("NO_CONTENT", null);
|
||||
};
|
125
src/routes/api/[guildId]/timePlanning.ts
Normal file
|
@ -0,0 +1,125 @@
|
|||
import { APIEvent } from "@solidjs/start/server";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import db from "~/drizzle";
|
||||
import { guilds, tpMessages } from "~/drizzle/schema";
|
||||
import { BasicAuth } from "~/lib/auth";
|
||||
import {
|
||||
DayKeys,
|
||||
buildTpMessages,
|
||||
splitInterval,
|
||||
} from "~/lib/responseBuilders";
|
||||
import { ErrorResponse, Res } from "~/lib/responses";
|
||||
import { zodBigIntId, zodTpMessages } from "~/lib/zod";
|
||||
import { APIResponse, RequestBody } from "~/types/backend";
|
||||
|
||||
type Path = "/api/{guildId}/timePlanning";
|
||||
|
||||
export const GET = async (
|
||||
event: APIEvent,
|
||||
): Promise<APIResponse<Path, "get">> => {
|
||||
switch (event.request.headers.get("authorization")) {
|
||||
case BasicAuth.unencoded:
|
||||
case BasicAuth.encoded:
|
||||
break;
|
||||
|
||||
default:
|
||||
return ErrorResponse("UNAUTHORIZED");
|
||||
}
|
||||
|
||||
let guildId: bigint;
|
||||
try {
|
||||
guildId = zodBigIntId.parse(event.params.guildId);
|
||||
} catch (e) {
|
||||
return ErrorResponse("BAD_REQUEST", JSON.stringify(e));
|
||||
}
|
||||
|
||||
const guild = await db.query.guilds.findFirst({
|
||||
where: eq(guilds.id, guildId),
|
||||
with: {
|
||||
tpMessages: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!guild) return ErrorResponse("NOT_FOUND");
|
||||
|
||||
return Res("OK", {
|
||||
channelId: guild.tpChannelId?.toString() ?? null,
|
||||
...splitInterval(guild.tpInterval),
|
||||
roles: {
|
||||
enabled: guild.tpRolesEnabled,
|
||||
isAvailableRoleId: guild.isAvailableRoleId?.toString() ?? null,
|
||||
wantsToBeNotifieRoledId:
|
||||
guild.wantsToBeNotifieRoledId?.toString() ?? null,
|
||||
},
|
||||
messageIds: buildTpMessages(guild.tpMessages),
|
||||
});
|
||||
};
|
||||
|
||||
export const PUT = async (
|
||||
event: APIEvent,
|
||||
): Promise<APIResponse<Path, "put">> => {
|
||||
switch (event.request.headers.get("authorization")) {
|
||||
case BasicAuth.unencoded:
|
||||
case BasicAuth.encoded:
|
||||
break;
|
||||
|
||||
default:
|
||||
return ErrorResponse("UNAUTHORIZED");
|
||||
}
|
||||
|
||||
let guildId: bigint;
|
||||
try {
|
||||
guildId = zodBigIntId.parse(event.params.guildId);
|
||||
} catch (e) {
|
||||
return ErrorResponse("BAD_REQUEST", JSON.stringify(e));
|
||||
}
|
||||
|
||||
const guild = await db.query.guilds
|
||||
.findFirst({
|
||||
where: eq(guilds.id, guildId),
|
||||
with: { tpMessages: true },
|
||||
})
|
||||
.execute();
|
||||
|
||||
if (!guild) return ErrorResponse("NOT_FOUND");
|
||||
|
||||
const unparsedBody = await new Response(event.request.body).json();
|
||||
|
||||
let body: RequestBody<Path, "put">;
|
||||
try {
|
||||
body = zodTpMessages.parse(unparsedBody);
|
||||
} catch (e) {
|
||||
return ErrorResponse("BAD_REQUEST", JSON.stringify(e));
|
||||
}
|
||||
|
||||
const { channelId, roles, messageIds } = body;
|
||||
if (guild.tpChannelId !== channelId)
|
||||
await db
|
||||
.update(guilds)
|
||||
.set({
|
||||
tpChannelId: channelId ? BigInt(channelId) : null,
|
||||
tpRolesEnabled: roles.enabled,
|
||||
isAvailableRoleId: roles.isAvailableRoleId
|
||||
? BigInt(roles.isAvailableRoleId)
|
||||
: null,
|
||||
wantsToBeNotifieRoledId: roles.wantsToBeNotifieRoledId
|
||||
? BigInt(roles.wantsToBeNotifieRoledId)
|
||||
: null,
|
||||
})
|
||||
.where(eq(guilds.id, guild.id))
|
||||
.execute();
|
||||
|
||||
await Promise.all(
|
||||
DayKeys.map(async (dayStr) => {
|
||||
const day = parseInt(dayStr);
|
||||
const messageId = messageIds[dayStr];
|
||||
await db
|
||||
.update(tpMessages)
|
||||
.set({ messageId: messageId ? BigInt(messageId) : null })
|
||||
.where(and(eq(tpMessages.guildId, guild.id), eq(tpMessages.day, day)))
|
||||
.execute();
|
||||
}),
|
||||
);
|
||||
|
||||
return Res("NO_CONTENT", null);
|
||||
};
|
125
src/routes/api/auth/callback/discord.ts
Normal file
|
@ -0,0 +1,125 @@
|
|||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { APIEvent } from "@solidjs/start/server";
|
||||
import { OAuth2RequestError } from "arctic";
|
||||
import { eq } from "drizzle-orm";
|
||||
import httpStatus from "http-status";
|
||||
import { getCookie, setCookie } from "vinxi/http";
|
||||
import db from "~/drizzle";
|
||||
import { discordTokens, users } from "~/drizzle/schema";
|
||||
import { discord, lucia } from "~/lib/auth";
|
||||
import { discordApi } from "~/lib/cachedDiscord";
|
||||
|
||||
export async function GET(event: APIEvent): Promise<Response> {
|
||||
const url = new URL(event.request.url);
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
const error = url.searchParams.get("error");
|
||||
const error_description = url.searchParams.get("error_description");
|
||||
if (error)
|
||||
switch (error) {
|
||||
case "access_denied":
|
||||
return new Response(null, {
|
||||
status: httpStatus.FOUND,
|
||||
headers: { Location: "/" },
|
||||
});
|
||||
default:
|
||||
console.log("Discord oauth error:", error_description);
|
||||
return new Response(decodeURI(error_description ?? ""), {
|
||||
status: httpStatus.BAD_REQUEST,
|
||||
});
|
||||
}
|
||||
|
||||
const storedState = getCookie("discord_oauth_state") ?? null;
|
||||
if (!code || !state || !storedState || state !== storedState) {
|
||||
return new Response(null, {
|
||||
status: httpStatus.BAD_REQUEST,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const tokens = await discord.validateAuthorizationCode(code);
|
||||
const discordUserResponse = await discordApi("/users/@me", {
|
||||
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
||||
});
|
||||
if (discordUserResponse.error) throw discordUserResponse.error;
|
||||
const discordUser = discordUserResponse.data;
|
||||
const existingUser = await db.query.users
|
||||
.findFirst({
|
||||
where: eq(users.discord_id, discordUser.id),
|
||||
})
|
||||
.execute();
|
||||
|
||||
if (existingUser) {
|
||||
const session = await lucia.createSession(
|
||||
existingUser.id,
|
||||
{},
|
||||
{ sessionId: createId() },
|
||||
);
|
||||
const sessionCookie = lucia.createSessionCookie(session.id);
|
||||
setCookie(
|
||||
sessionCookie.name,
|
||||
sessionCookie.value,
|
||||
sessionCookie.attributes,
|
||||
);
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
name: discordUser.global_name,
|
||||
image: discordUser.avatar,
|
||||
})
|
||||
.where(eq(users.discord_id, discordUser.id))
|
||||
.returning()
|
||||
.execute();
|
||||
|
||||
return new Response(null, {
|
||||
status: httpStatus.FOUND,
|
||||
headers: { Location: "/config" },
|
||||
});
|
||||
}
|
||||
|
||||
const userId = createId();
|
||||
await db.insert(users).values({
|
||||
id: userId,
|
||||
discord_id: discordUser.id,
|
||||
name: discordUser.global_name,
|
||||
image: discordUser.avatar,
|
||||
});
|
||||
await db
|
||||
.insert(discordTokens)
|
||||
.values({
|
||||
userId,
|
||||
accessToken: tokens.accessToken,
|
||||
expiresAt: tokens.accessTokenExpiresAt,
|
||||
refreshToken: tokens.refreshToken,
|
||||
})
|
||||
.execute();
|
||||
const session = await lucia.createSession(
|
||||
userId,
|
||||
{},
|
||||
{ sessionId: createId() },
|
||||
);
|
||||
const sessionCookie = lucia.createSessionCookie(session.id);
|
||||
setCookie(
|
||||
sessionCookie.name,
|
||||
sessionCookie.value,
|
||||
sessionCookie.attributes,
|
||||
);
|
||||
return new Response(null, {
|
||||
status: httpStatus.FOUND,
|
||||
headers: { Location: "/config" },
|
||||
});
|
||||
} catch (e) {
|
||||
// the specific error message depends on the provider
|
||||
if (e instanceof OAuth2RequestError) {
|
||||
// invalid code
|
||||
return new Response(null, {
|
||||
status: httpStatus.BAD_REQUEST,
|
||||
});
|
||||
}
|
||||
console.error("Unknown error on callback.");
|
||||
console.error(e);
|
||||
return new Response(null, {
|
||||
status: httpStatus.INTERNAL_SERVER_ERROR,
|
||||
});
|
||||
}
|
||||
}
|
27
src/routes/api/auth/login.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { generateState } from "arctic";
|
||||
import httpStatus from "http-status";
|
||||
import { setCookie } from "vinxi/http";
|
||||
import { discord } from "~/lib/auth";
|
||||
|
||||
if (typeof import.meta.env.PROD === "undefined")
|
||||
throw new Error("No env PROD found!");
|
||||
|
||||
export async function GET() {
|
||||
const state = generateState();
|
||||
const url = await discord.createAuthorizationURL(state, {
|
||||
scopes: ["identify", "guilds", "guilds.members.read"],
|
||||
});
|
||||
|
||||
setCookie("discord_oauth_state", state, {
|
||||
path: "/",
|
||||
secure: import.meta.env.PROD,
|
||||
httpOnly: true,
|
||||
maxAge: 60 * 10,
|
||||
sameSite: "lax",
|
||||
});
|
||||
|
||||
return new Response(null, {
|
||||
status: httpStatus.FOUND,
|
||||
headers: { Location: url.toString() },
|
||||
});
|
||||
}
|
17
src/routes/api/auth/logout.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { APIEvent } from "@solidjs/start/server";
|
||||
import httpStatus from "http-status";
|
||||
import { appendHeader } from "vinxi/http";
|
||||
import { lucia } from "~/lib/auth";
|
||||
|
||||
export const GET = async (event: APIEvent) => {
|
||||
const { session } = event.nativeEvent.context;
|
||||
|
||||
if (!session) return new Error("Unauthorized");
|
||||
|
||||
await lucia.invalidateSession(session.id);
|
||||
appendHeader("Set-Cookie", lucia.createBlankSessionCookie().serialize());
|
||||
return new Response(null, {
|
||||
status: httpStatus.FOUND,
|
||||
headers: { Location: "/" },
|
||||
});
|
||||
};
|
43
src/routes/api/boot.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { APIEvent } from "@solidjs/start/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import db from "~/drizzle";
|
||||
import { guilds } from "~/drizzle/schema";
|
||||
import { BasicAuth } from "~/lib/auth";
|
||||
import { buildConfig } from "~/lib/responseBuilders";
|
||||
import { ErrorResponse, Res } from "~/lib/responses";
|
||||
import { zodBigIntId } from "~/lib/zod";
|
||||
import { APIResponse } from "~/types/backend";
|
||||
|
||||
type Path = "/api/boot";
|
||||
|
||||
export const GET = async (
|
||||
event: APIEvent,
|
||||
): Promise<APIResponse<Path, "get">> => {
|
||||
switch (event.request.headers.get("authorization")) {
|
||||
case BasicAuth.unencoded:
|
||||
case BasicAuth.encoded:
|
||||
break;
|
||||
|
||||
default:
|
||||
return ErrorResponse("UNAUTHORIZED");
|
||||
}
|
||||
|
||||
let guildId: bigint;
|
||||
try {
|
||||
guildId = zodBigIntId.parse(event.params.guildId);
|
||||
} catch (e) {
|
||||
return ErrorResponse("BAD_REQUEST", JSON.stringify(e));
|
||||
}
|
||||
|
||||
const guildQuery = await db.query.guilds
|
||||
.findMany({
|
||||
where: eq(guilds.id, guildId),
|
||||
with: { tpMessages: true, matches: true },
|
||||
})
|
||||
.execute();
|
||||
|
||||
return Res(
|
||||
"OK",
|
||||
guildQuery.map((e) => buildConfig(e)),
|
||||
);
|
||||
};
|
543
src/routes/config/[guildId].tsx
Normal file
|
@ -0,0 +1,543 @@
|
|||
import { faToggleOff, faToggleOn } from "@fortawesome/pro-regular-svg-icons";
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { PgUpdateSetSource } from "drizzle-orm/pg-core";
|
||||
import moment from "moment-timezone";
|
||||
import {
|
||||
For,
|
||||
Index,
|
||||
Show,
|
||||
batch,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
} from "solid-js";
|
||||
import { createStore } from "solid-js/store";
|
||||
import { getRequestEvent } from "solid-js/web";
|
||||
import { FontAwesomeIcon } from "~/components/FontAwesomeIcon";
|
||||
import Layout from "~/components/Layout";
|
||||
import db from "~/drizzle";
|
||||
import { guilds } from "~/drizzle/schema";
|
||||
import { guildChannels, userGuilds } from "~/lib/cachedDiscord";
|
||||
import { combineInterval, splitInterval } from "~/lib/responseBuilders";
|
||||
import { zodBigIntId } from "~/lib/zod";
|
||||
import "../../styles/pages/config.scss";
|
||||
|
||||
if (typeof import.meta.env.VITE_DISCORD_BOT_TOKEN === "undefined")
|
||||
throw new Error("No env VITE_DISCORD_BOT_TOKEN found!");
|
||||
|
||||
const guessTZ = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
const getPayload = async (
|
||||
id: string,
|
||||
): Promise<
|
||||
| { success: false; message: string }
|
||||
| {
|
||||
success: true;
|
||||
guild: {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null | undefined;
|
||||
tpChannelId: string;
|
||||
channels: {
|
||||
id: string;
|
||||
name: string;
|
||||
}[];
|
||||
tpInterval: number;
|
||||
pingableRoles: boolean;
|
||||
timezone: string;
|
||||
};
|
||||
tzNames: string[];
|
||||
}
|
||||
> => {
|
||||
"use server";
|
||||
|
||||
let guildId: bigint;
|
||||
try {
|
||||
guildId = zodBigIntId.parse(id);
|
||||
} catch (e) {
|
||||
return { success: false, message: "ID is invalid" };
|
||||
}
|
||||
|
||||
const event = getRequestEvent();
|
||||
if (!event) return { success: false, message: "No request event!" };
|
||||
|
||||
const { user } = event.nativeEvent.context;
|
||||
if (!user) return { success: false, message: "User not logged in!" };
|
||||
|
||||
const guildsData = await userGuilds(user.id);
|
||||
const guild = guildsData?.find((e) => e.id === String(guildId));
|
||||
|
||||
if (!guild)
|
||||
return {
|
||||
success: false,
|
||||
message: "User is in no such guild with requested id!",
|
||||
};
|
||||
if (!(parseInt(guild.permissions) & (1 << 5)))
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
"User is no MANAGE_GUILD permissions on this guild with requested id!",
|
||||
};
|
||||
|
||||
const channelsData = await guildChannels(guildId);
|
||||
|
||||
const channels: {
|
||||
id: string;
|
||||
name: string;
|
||||
}[] = [];
|
||||
channelsData?.forEach((channel) => {
|
||||
if (channel.type !== 0) return;
|
||||
channels.push({
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
});
|
||||
});
|
||||
|
||||
const config = await db.query.guilds
|
||||
.findFirst({ where: eq(guilds.id, guildId) })
|
||||
.execute();
|
||||
|
||||
if (!config) return { success: false, message: "No config found!" };
|
||||
|
||||
console.log(new URL(event.request.url).pathname, "success");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
guild: {
|
||||
id: guild.id,
|
||||
name: guild.name,
|
||||
icon: guild.icon,
|
||||
tpChannelId: String(config.tpChannelId ?? ""),
|
||||
channels,
|
||||
tpInterval: config.tpInterval,
|
||||
pingableRoles: config.tpRolesEnabled,
|
||||
timezone: config.timezone,
|
||||
},
|
||||
tzNames: moment.tz.names(),
|
||||
};
|
||||
};
|
||||
|
||||
const saveConfig = async (
|
||||
id: string,
|
||||
updateValues: PgUpdateSetSource<typeof guilds>,
|
||||
): Promise<{ success: false; message: string } | { success: true }> => {
|
||||
"use server";
|
||||
|
||||
let guildId: bigint;
|
||||
try {
|
||||
guildId = zodBigIntId.parse(id);
|
||||
} catch (e) {
|
||||
return { success: false, message: "ID is invalid" };
|
||||
}
|
||||
|
||||
const event = getRequestEvent();
|
||||
if (!event) return { success: false, message: "No request event!" };
|
||||
|
||||
console.log({ updateValues });
|
||||
|
||||
await db
|
||||
.update(guilds)
|
||||
.set(updateValues)
|
||||
.where(eq(guilds.id, guildId))
|
||||
.execute();
|
||||
|
||||
console.log(new URL(event.request.url).pathname, "config save success");
|
||||
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
function config() {
|
||||
const params = useParams();
|
||||
const navigator = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const [timezoneRef, setTimezoneRef] = createSignal<HTMLInputElement>();
|
||||
const [tpEnabledRef, setTpEnabledRef] = createSignal<HTMLInputElement>();
|
||||
const [channelRef, setChannelRef] = createSignal<HTMLSelectElement>();
|
||||
const [targetMinuteRef, setTargetMinuteRef] =
|
||||
createSignal<HTMLSelectElement>();
|
||||
const [targetHourRef, setTargetHourRef] = createSignal<HTMLSelectElement>();
|
||||
const [targetDayRef, setTargetDayRef] = createSignal<HTMLSelectElement>();
|
||||
const [pingableRolesRef, setPingableRolesRef] =
|
||||
createSignal<HTMLInputElement>();
|
||||
|
||||
const [payload, { refetch }] = createResource(
|
||||
params.guildId,
|
||||
async (id) => {
|
||||
const payload = await getPayload(id);
|
||||
|
||||
if (!payload) {
|
||||
console.error(location.pathname, payload);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!payload.success) {
|
||||
console.log(payload);
|
||||
console.log(location.pathname, payload.message, "No success");
|
||||
navigator("/config", { replace: false });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return payload;
|
||||
},
|
||||
{
|
||||
deferStream: true,
|
||||
},
|
||||
);
|
||||
const [config, setConfig] = createStore({
|
||||
features: {
|
||||
timePlanning: {
|
||||
enabled: false,
|
||||
channelId: "",
|
||||
targetMinute: 0,
|
||||
targetHour: 0,
|
||||
targetDay: 0,
|
||||
pingableRoles: false,
|
||||
timezone: guessTZ(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const updateValues = createMemo(() => {
|
||||
const guild = !payload.loading && payload()?.guild;
|
||||
if (!guild) return {};
|
||||
const data = config.features.timePlanning;
|
||||
const tpInterval = combineInterval(
|
||||
data.targetMinute,
|
||||
data.targetHour,
|
||||
data.targetDay,
|
||||
);
|
||||
|
||||
const result: PgUpdateSetSource<typeof guilds> = {
|
||||
timezone: data.timezone !== guild.timezone ? data.timezone : undefined,
|
||||
tpChannelId:
|
||||
data.enabled || !data.channelId
|
||||
? data.channelId && data.channelId !== guild.tpChannelId
|
||||
? BigInt(data.channelId)
|
||||
: undefined
|
||||
: null,
|
||||
tpInterval:
|
||||
data.enabled && data.channelId && tpInterval !== guild.tpInterval
|
||||
? tpInterval
|
||||
: undefined,
|
||||
tpRolesEnabled:
|
||||
data.enabled &&
|
||||
data.channelId &&
|
||||
data.pingableRoles !== guild.pingableRoles
|
||||
? data.pingableRoles
|
||||
: undefined,
|
||||
};
|
||||
return result;
|
||||
});
|
||||
const willUpdateValues = () =>
|
||||
Object.values(updateValues()).filter((e) => typeof e !== "undefined")
|
||||
.length;
|
||||
|
||||
createEffect(() => {
|
||||
const guild = payload()?.guild;
|
||||
if (!guild) return;
|
||||
const channelId = guild.tpChannelId;
|
||||
const pingableRoles = guild.pingableRoles;
|
||||
const { targetMinute, targetHour, targetDay } = splitInterval(
|
||||
guild.tpInterval,
|
||||
);
|
||||
const timezone = guild.timezone;
|
||||
|
||||
batch(() => {
|
||||
setConfig("features", "timePlanning", "enabled", !!channelId);
|
||||
setConfig("features", "timePlanning", "channelId", channelId);
|
||||
setConfig("features", "timePlanning", "targetMinute", targetMinute);
|
||||
setConfig("features", "timePlanning", "targetHour", targetHour);
|
||||
setConfig("features", "timePlanning", "targetDay", targetDay);
|
||||
setConfig("features", "timePlanning", "pingableRoles", pingableRoles);
|
||||
setConfig("features", "timePlanning", "timezone", timezone);
|
||||
});
|
||||
});
|
||||
createEffect(() => {
|
||||
const ref = timezoneRef();
|
||||
if (ref) ref.value = config.features.timePlanning.timezone;
|
||||
});
|
||||
createEffect(() => {
|
||||
const ref = tpEnabledRef();
|
||||
if (ref) ref.checked = config.features.timePlanning.enabled;
|
||||
});
|
||||
createEffect(() => {
|
||||
const ref = channelRef();
|
||||
if (ref) ref.value = config.features.timePlanning.channelId;
|
||||
});
|
||||
createEffect(() => {
|
||||
const ref = targetMinuteRef();
|
||||
if (ref) ref.value = String(config.features.timePlanning.targetMinute);
|
||||
});
|
||||
createEffect(() => {
|
||||
const ref = targetHourRef();
|
||||
if (ref) ref.value = String(config.features.timePlanning.targetHour);
|
||||
});
|
||||
createEffect(() => {
|
||||
const ref = targetDayRef();
|
||||
if (ref) ref.value = String(config.features.timePlanning.targetDay);
|
||||
});
|
||||
createEffect(() => {
|
||||
const ref = pingableRolesRef();
|
||||
if (ref) ref.checked = config.features.timePlanning.pingableRoles;
|
||||
});
|
||||
|
||||
return (
|
||||
<Layout site="config">
|
||||
<div class="group">
|
||||
<h3>Configure li'l Judd in</h3>
|
||||
<div>
|
||||
<div>
|
||||
<div class="flex-row centered">
|
||||
<img
|
||||
class="guildpfp"
|
||||
src={
|
||||
payload()?.guild.icon
|
||||
? `https://cdn.discordapp.com/icons/${payload()?.guild.id}/${
|
||||
payload()?.guild.icon
|
||||
}.webp?size=240`
|
||||
: "https://cdn.discordapp.com/icons/1040502664506646548/bb5a51c4659cf47bdd942bb11e974da7.webp?size=240"
|
||||
}
|
||||
alt="Server pfp"
|
||||
/>
|
||||
<h1>{payload()?.guild.name}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="box">
|
||||
<h2>Guild</h2>
|
||||
<p>General settings for this guild.</p>
|
||||
<div class="flex-row">
|
||||
<label for="timezone">Timezone for your server:</label>
|
||||
<input
|
||||
type="text"
|
||||
list="timezones"
|
||||
id="timezone"
|
||||
ref={(e) => setTimezoneRef(e)}
|
||||
value={payload()?.guild.timezone}
|
||||
onInput={(e) =>
|
||||
setConfig(
|
||||
"features",
|
||||
"timePlanning",
|
||||
"timezone",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<datalist id="timezones">
|
||||
<Index each={payload()?.tzNames}>
|
||||
{(zone) => <option value={zone()} />}
|
||||
</Index>
|
||||
</datalist>
|
||||
|
||||
<button
|
||||
disabled={guessTZ() === config.features.timePlanning.timezone}
|
||||
title={"Detected: " + guessTZ()}
|
||||
onClick={() =>
|
||||
setConfig("features", "timePlanning", "timezone", guessTZ())
|
||||
}
|
||||
>
|
||||
Auto-detect
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="box">
|
||||
<h2>Features</h2>
|
||||
<p>Configure the features of the bot</p>
|
||||
<label for="timePlanning" class="flex-row">
|
||||
<p>Time Planning </p>
|
||||
<FontAwesomeIcon
|
||||
icon={
|
||||
config.features.timePlanning.enabled
|
||||
? faToggleOn
|
||||
: faToggleOff
|
||||
}
|
||||
size="xl"
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
hidden
|
||||
type="checkbox"
|
||||
id="timePlanning"
|
||||
ref={(e) => setTpEnabledRef(e)}
|
||||
checked={!!payload()?.guild.tpChannelId}
|
||||
onInput={(e) =>
|
||||
setConfig(
|
||||
"features",
|
||||
"timePlanning",
|
||||
"enabled",
|
||||
e.target.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div
|
||||
class="sub"
|
||||
classList={{ disabled: !config.features.timePlanning.enabled }}
|
||||
>
|
||||
<div class="flex-row">
|
||||
<label>Target channel:</label>
|
||||
<select
|
||||
ref={(e) => setChannelRef(e)}
|
||||
value={payload()?.guild.tpChannelId}
|
||||
onInput={(e) =>
|
||||
setConfig(
|
||||
"features",
|
||||
"timePlanning",
|
||||
"channelId",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option disabled value="">
|
||||
--Select a Channel--
|
||||
</option>
|
||||
<For each={payload()?.guild.channels}>
|
||||
{(channel) => (
|
||||
<option value={channel.id}>{channel.name}</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
<Show
|
||||
when={
|
||||
config.features.timePlanning.enabled &&
|
||||
!config.features.timePlanning.channelId
|
||||
}
|
||||
>
|
||||
{"<-- or changes won't be saved"}
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex-row">
|
||||
<label>Target Interval:</label>
|
||||
<select
|
||||
ref={(e) => setTargetDayRef(e)}
|
||||
value={
|
||||
splitInterval(payload()?.guild.tpInterval ?? 0).targetDay
|
||||
}
|
||||
onInput={(e) =>
|
||||
setConfig(
|
||||
"features",
|
||||
"timePlanning",
|
||||
"targetDay",
|
||||
Number(e.target.value),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Index each={Array.from(Array(7)).map((_e, i) => i)}>
|
||||
{(id) => {
|
||||
const weekdays = [
|
||||
"Sunday",
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
];
|
||||
return (
|
||||
<option value={String(id())}>{weekdays[id()]}</option>
|
||||
);
|
||||
}}
|
||||
</Index>
|
||||
</select>
|
||||
at
|
||||
<select
|
||||
ref={(e) => setTargetHourRef(e)}
|
||||
value={
|
||||
splitInterval(payload()?.guild.tpInterval ?? 0).targetHour
|
||||
}
|
||||
onInput={(e) =>
|
||||
setConfig(
|
||||
"features",
|
||||
"timePlanning",
|
||||
"targetHour",
|
||||
Number(e.target.value),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Index each={Array.from(Array(24)).map((_e, i) => i)}>
|
||||
{(id) => (
|
||||
<option value={String(id())}>{String(id())}</option>
|
||||
)}
|
||||
</Index>
|
||||
</select>
|
||||
:
|
||||
<select
|
||||
ref={(e) => setTargetMinuteRef(e)}
|
||||
value={
|
||||
splitInterval(payload()?.guild.tpInterval ?? 0).targetMinute
|
||||
}
|
||||
onInput={(e) =>
|
||||
setConfig(
|
||||
"features",
|
||||
"timePlanning",
|
||||
"targetMinute",
|
||||
Number(e.target.value),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Index each={Array.from(Array(60)).map((_e, i) => i)}>
|
||||
{(id) => (
|
||||
<option value={String(id())}>
|
||||
{String(id()).padStart(2, "0")}
|
||||
</option>
|
||||
)}
|
||||
</Index>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-row">
|
||||
<label for="pingableRoles" class="flex-row">
|
||||
<p>Enable pingable Roles:</p>
|
||||
<FontAwesomeIcon
|
||||
icon={
|
||||
config.features.timePlanning.pingableRoles
|
||||
? faToggleOn
|
||||
: faToggleOff
|
||||
}
|
||||
size="xl"
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
hidden
|
||||
type="checkbox"
|
||||
id="pingableRoles"
|
||||
ref={(e) => setPingableRolesRef(e)}
|
||||
checked={payload()?.guild.pingableRoles}
|
||||
onInput={(e) =>
|
||||
setConfig(
|
||||
"features",
|
||||
"timePlanning",
|
||||
"pingableRoles",
|
||||
e.target.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="box">
|
||||
<button
|
||||
onClick={async () => {
|
||||
const id = payload()?.guild.id;
|
||||
if (!id || !willUpdateValues()) return;
|
||||
await saveConfig(id, updateValues());
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
<button onClick={() => navigator("/config")}>Back</button>
|
||||
<button onClick={() => refetch()}>Reset</button>
|
||||
<Show when={willUpdateValues()}>UNSAVED CHANGES</Show>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default config;
|
144
src/routes/config/index.tsx
Normal file
|
@ -0,0 +1,144 @@
|
|||
import { faPlusCircle, faWrench } from "@fortawesome/pro-regular-svg-icons";
|
||||
import { useLocation, useNavigate } from "@solidjs/router";
|
||||
import { For, Show, createResource } from "solid-js";
|
||||
import { getRequestEvent } from "solid-js/web";
|
||||
import { FontAwesomeIcon } from "~/components/FontAwesomeIcon";
|
||||
import Layout from "~/components/Layout";
|
||||
import db from "~/drizzle";
|
||||
import { guilds } from "~/drizzle/schema";
|
||||
import { userGuilds } from "~/lib/cachedDiscord";
|
||||
import "../../styles/pages/config.scss";
|
||||
|
||||
if (typeof import.meta.env.VITE_DISCORD_CLIENT_ID === "undefined")
|
||||
throw new Error("No env VITE_DISCORD_CLIENT_ID found!");
|
||||
|
||||
if (typeof import.meta.env.VITE_DISCORD_OAUTH2_PERMISSIONS === "undefined")
|
||||
throw new Error("No env VITE_DISCORD_OAUTH2_PERMISSIONS found!");
|
||||
|
||||
interface Guild {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null | undefined;
|
||||
}
|
||||
|
||||
const initialValue = () => ({
|
||||
success: null as boolean | null,
|
||||
joined: [] as Guild[],
|
||||
canJoin: [] as Guild[],
|
||||
});
|
||||
|
||||
const getPayload = async (): Promise<
|
||||
| { success: false; message: string }
|
||||
| (ReturnType<typeof initialValue> & { success: true })
|
||||
> => {
|
||||
"use server";
|
||||
|
||||
const event = getRequestEvent();
|
||||
if (!event) return { success: false, message: "No request event!" };
|
||||
|
||||
const { user } = event.nativeEvent.context;
|
||||
if (!user) return { success: false, message: "User not logged in!" };
|
||||
|
||||
const configs = await db.select().from(guilds).execute();
|
||||
|
||||
const guildData = await userGuilds(user.id);
|
||||
|
||||
const joined: Guild[] = [];
|
||||
const canJoin: Guild[] = [];
|
||||
|
||||
guildData
|
||||
?.filter((e) => parseInt(e.permissions) & (1 << 5))
|
||||
.forEach(({ id, name, icon }) => {
|
||||
configs.map((e) => e.id.toString()).includes(id)
|
||||
? joined.push({ id, name, icon })
|
||||
: canJoin.push({ id, name, icon });
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
joined,
|
||||
canJoin,
|
||||
};
|
||||
};
|
||||
|
||||
function index() {
|
||||
const navigator = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const [payload] = createResource(
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
async () => {
|
||||
const payload = await getPayload().catch((e) => console.warn(e));
|
||||
|
||||
if (!payload) {
|
||||
console.error(location.pathname, payload);
|
||||
return initialValue();
|
||||
}
|
||||
|
||||
if (!payload.success) {
|
||||
console.log(location.pathname, payload.message, "No success");
|
||||
navigator("/", { replace: false });
|
||||
return initialValue();
|
||||
}
|
||||
|
||||
return payload;
|
||||
},
|
||||
{ deferStream: true, initialValue: initialValue() },
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout site="config">
|
||||
<div class="group">
|
||||
<h3>Configure li'l Judd in</h3>
|
||||
<GuildList list={payload().joined} joined={true} />
|
||||
</div>
|
||||
<div class="group">
|
||||
<h3>Add li'l Judd to</h3>
|
||||
<GuildList list={payload().canJoin} joined={false} />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
function GuildList(props: { list: Guild[]; joined: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<Show
|
||||
when={props.list.length}
|
||||
fallback={<div class="box flex-row centered">Nothing here</div>}
|
||||
>
|
||||
<For each={props.list}>
|
||||
{(guild) => {
|
||||
return (
|
||||
<a
|
||||
href={
|
||||
props.joined
|
||||
? `/config/${guild.id}`
|
||||
: `https://discord.com/api/oauth2/authorize?client_id=${import.meta.env.VITE_DISCORD_CLIENT_ID}&permissions=${import.meta.env.VITE_DISCORD_OAUTH2_PERMISSIONS}&scope=bot&guild_id=${guild.id}`
|
||||
}
|
||||
class="box effect flex-row centered"
|
||||
>
|
||||
<img
|
||||
class="guildpfp"
|
||||
src={
|
||||
guild.icon
|
||||
? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.webp?size=240`
|
||||
: "https://cdn.discordapp.com/icons/1040502664506646548/bb5a51c4659cf47bdd942bb11e974da7.webp?size=240"
|
||||
}
|
||||
alt="Server pfp"
|
||||
/>
|
||||
<h1>{guild.name}</h1>
|
||||
<FontAwesomeIcon
|
||||
icon={props.joined ? faWrench : faPlusCircle}
|
||||
size="xl"
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default index;
|
26
src/routes/contact.tsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
import Layout from "~/components/Layout";
|
||||
import "../styles/pages/contact.scss";
|
||||
|
||||
function contact() {
|
||||
return (
|
||||
<Layout site="contact">
|
||||
<h1>Contact</h1>
|
||||
<section>
|
||||
<a href="mailto:contact@moonleay.net" target="_blank">
|
||||
<img src="/assets/icons/email.svg" alt="Email" />
|
||||
contact@moonleay.net
|
||||
</a>
|
||||
<a href="https://discord.com/users/372703841151614976" target="_blank">
|
||||
<img src="/assets/icons/discord.svg" alt="Discord" />
|
||||
@moonleay
|
||||
</a>
|
||||
<a href="https://discord.gg/HTZRktfH4A" target="_blank">
|
||||
<img src="/assets/icons/discord.svg" alt="discord" />
|
||||
li'l Judd's home base
|
||||
</a>
|
||||
</section>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default contact;
|
51
src/routes/features.tsx
Normal file
|
@ -0,0 +1,51 @@
|
|||
import ImageSection from "~/components/ImageSection";
|
||||
import Layout from "~/components/Layout";
|
||||
import "../styles/pages/features.scss";
|
||||
|
||||
function features() {
|
||||
return (
|
||||
<Layout site="features">
|
||||
<h1>Features</h1>
|
||||
<div class="gridlayout">
|
||||
<ImageSection
|
||||
imgUrl="/assets/screenshots/timeplanner.png"
|
||||
imgAlt="Screenshot of the time planning feature of li'l Judd"
|
||||
title="Time Planning and Management"
|
||||
description="Helps you to see on which days your fellow team mates are available."
|
||||
note="The bot can send these planning messages every monday at 3AM. Members can click the buttons on each message to communicate, if they have time on that day."
|
||||
/>
|
||||
<ImageSection
|
||||
imgUrl="/assets/screenshots/matchplanner.png"
|
||||
imgAlt="Screenshot of the match planning feature of li'l Judd"
|
||||
title="Match Planner"
|
||||
description="Make sure that you know when your next match is and who will participate."
|
||||
note="The bot can send these planning messages, when the command /match is used. Members can click the buttons on each message to communicate, if they will participate in the match. Participating members will get a role until the match has started."
|
||||
/>
|
||||
<ImageSection
|
||||
imgUrl="/assets/screenshots/notifs.png"
|
||||
imgAlt="Screenshot of the notification feature of li'l Judd"
|
||||
title="Notifications"
|
||||
description="Make sure that you and your team members vote in the Time Planner."
|
||||
note="The bot can add roles. The first one gets pinged, when the time planner sends the messages, the other one gets assigned to the available members of the day, so that it is possible to ping all available people."
|
||||
/>
|
||||
<ImageSection
|
||||
imgUrl="/assets/screenshots/rotationstatus.png"
|
||||
imgAlt="Screenshot of the current x map rotation in li'l Judd's status"
|
||||
title="Rotation Status"
|
||||
description="Li'l Judd can show you the current map rotation in his status."
|
||||
note="The bot cycles through the current map and mode rotation. It updates every few seconds."
|
||||
/>
|
||||
<ImageSection
|
||||
span
|
||||
imgUrl="/assets/screenshots/unknown.png"
|
||||
imgAlt="A Question Mark"
|
||||
title="More to come.."
|
||||
description="The bot is still in development. More features will be added."
|
||||
note="If you have a specific feature request, you can contact me on Discord: @moonleay or email: contact at moonleay dot net"
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default features;
|
62
src/routes/how-do-i.tsx
Normal file
|
@ -0,0 +1,62 @@
|
|||
import Layout from "~/components/Layout";
|
||||
import "../styles/pages/how-do-i.scss";
|
||||
|
||||
function howDoI() {
|
||||
return (
|
||||
<Layout site="how-do-i">
|
||||
<h1>How do I...?</h1>
|
||||
<section>
|
||||
<h2>.. enable / disable certain features?</h2>
|
||||
<p>
|
||||
Features can be enabled and disables using the <code>/feature</code>
|
||||
command.
|
||||
<br />
|
||||
Example:
|
||||
</p>
|
||||
<div class="imgwrapper">
|
||||
<img
|
||||
class="desktop"
|
||||
src="/assets/screenshots/featureexample.png"
|
||||
alt="A screenshot of the example in Discord."
|
||||
/>
|
||||
<img
|
||||
class="mobile"
|
||||
src="/assets/screenshots/featureexamplemobile.png"
|
||||
alt="A screenshot of the example in Discord."
|
||||
/>
|
||||
{/* <p><code>/feature feature:Time Planning Feature set:Enable channel:#ich-kann-heute</code></p> */}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>.. create a match?</h2>
|
||||
<p>
|
||||
You can create a match time using the <code>/match</code> command.
|
||||
<br />
|
||||
Example:
|
||||
</p>
|
||||
<div class="imgwrapper">
|
||||
<img
|
||||
class="desktop"
|
||||
src="/assets/screenshots/matchexample.png"
|
||||
alt="A screenshot of the example in Discord."
|
||||
/>
|
||||
<img
|
||||
class="mobile"
|
||||
src="/assets/screenshots/matchexamplemobile.png"
|
||||
alt="A screenshot of the example in Discord."
|
||||
/>
|
||||
{/* <p><code>/match match:Ladder Match timestamp:24.12.2069 04:20 opponent:Forbidden</code></p> */}
|
||||
</div>
|
||||
</section>
|
||||
<section class="note">
|
||||
<p>
|
||||
Is something missing here?
|
||||
<br />
|
||||
Please <a href="/contact">contact me</a>!
|
||||
</p>
|
||||
</section>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default howDoI;
|
62
src/routes/imprint.tsx
Normal file
|
@ -0,0 +1,62 @@
|
|||
import Layout from "~/components/Layout";
|
||||
import "../styles/pages/imprint.scss";
|
||||
|
||||
function imprint() {
|
||||
return (
|
||||
<Layout site="imprint">
|
||||
<section>
|
||||
<h1>Imprint</h1>
|
||||
<section>
|
||||
<a href="/contact">
|
||||
<h2>Contact me</h2>
|
||||
</a>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Liability for contents</h2>
|
||||
<p>
|
||||
As a service provider, we are responsible for our own content on
|
||||
these pages in accordance with general legislation pursuant to
|
||||
Section 7 (1) of the German Telemedia Act (TMG). According to §§ 8
|
||||
to 10 TMG, however, we are not obligated as a service provider to
|
||||
monitor transmitted or stored third-party information or to
|
||||
investigate circumstances that indicate illegal activity.
|
||||
Obligations to remove or block the use of information under the
|
||||
general laws remain unaffected. However, liability in this regard is
|
||||
only possible from the point in time at which a concrete
|
||||
infringement of the law becomes known. If we become aware of any
|
||||
such infringements, we will remove the relevant content immediately.
|
||||
</p>
|
||||
<h5>
|
||||
Source:{" "}
|
||||
<a href="https://www.e-recht24.de/impressum-generator.html">
|
||||
eRecht24
|
||||
</a>
|
||||
</h5>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Liability for links</h2>
|
||||
<p>
|
||||
Our offer contains links to external websites of third parties, on
|
||||
whose contents we have no influence. Therefore, we cannot assume any
|
||||
liability for these external contents. The respective provider or
|
||||
operator of the pages is always responsible for the content of the
|
||||
linked pages. The linked pages were checked for possible legal
|
||||
violations at the time of linking. Illegal contents were not
|
||||
recognizable at the time of linking. However, a permanent control of
|
||||
the contents of the linked pages is not reasonable without concrete
|
||||
evidence of a violation of the law. If we become aware of any
|
||||
infringements, we will remove such links immediately.{" "}
|
||||
</p>
|
||||
<h5>
|
||||
Source:{" "}
|
||||
<a href="https://www.e-recht24.de/impressum-generator.html">
|
||||
eRecht24
|
||||
</a>
|
||||
</h5>
|
||||
</section>
|
||||
</section>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default imprint;
|
21
src/routes/index.tsx
Normal file
|
@ -0,0 +1,21 @@
|
|||
import Layout from "~/components/Layout";
|
||||
import "../styles/pages/index.scss";
|
||||
|
||||
function index() {
|
||||
return (
|
||||
<Layout site="index">
|
||||
<section>
|
||||
<h1>li'l Judd</h1>
|
||||
<h5>The competetive Splatoon Bot</h5>
|
||||
<div>
|
||||
<p class="p1">Improve your competitive Splatoon experience!</p>
|
||||
<p class="p2">
|
||||
See what li'l Judd can help you with: <a href="/features">here</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default index;
|
178
src/routes/privacy-policy.tsx
Normal file
|
@ -0,0 +1,178 @@
|
|||
import Layout from "~/components/Layout";
|
||||
import "../styles/pages/privacy-policy.scss";
|
||||
|
||||
function privacyPolicy() {
|
||||
return (
|
||||
<Layout site="privacyPolicy">
|
||||
<div>
|
||||
<h1>Privacy Policy for li'l Judd</h1>
|
||||
<h4>Last updated: 2023-12-05</h4>
|
||||
</div>
|
||||
<section>
|
||||
<h2>1. Introduction</h2>
|
||||
<p>
|
||||
Welcome to li'l Judd! This Privacy Policy explains how we
|
||||
collect, use, disclose, and safeguard your personal information when
|
||||
you use our Discord bot service.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>2. Data Controller</h2>
|
||||
<p>
|
||||
The data controller for the processing of your personal data is
|
||||
moonleay.
|
||||
<br />
|
||||
Please note that "moonleay" is primarily used as a username and may
|
||||
not directly reflect my legal or real-world identity.
|
||||
<br />
|
||||
You can reach me <a href="/contact">here</a>.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>3. Information We Collect</h2>
|
||||
<h3>3.1 Discord User Data</h3>
|
||||
<p>
|
||||
We may collect and process the following (personal) data related to
|
||||
your Discord account & guilds:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>- Discord User ID</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>- Discord username, discriminator and IDs of users</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>- Guild name and ID</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>- Channel names and IDs of channels with active features</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>- Message IDs of the bot messages</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>- Role IDs of created roles</p>
|
||||
</li>
|
||||
</ul>
|
||||
<h3>3.2 Usage Data</h3>
|
||||
<p>
|
||||
We may collect information on how you interact with our bot, including
|
||||
but not limited to:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>- Commands issued</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>- Server and channel information</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>- Timestamps of interactions</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h2>4. How we use your Information</h2>
|
||||
<p>We process your personal data for the following purposes:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>- To provide and maintain the bot service.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>- To improve, customize, and optimize our bot.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>- To respond to your requests, comments, or inquiries.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>- To comply with legal obligations.</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h2>5. Legal Basis for Processing</h2>
|
||||
<p>We process your personal data on the following legal bases:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>
|
||||
- Consent: You have given your consent for the processing of your
|
||||
personal data for one or more specific purposes.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
- Performance of a contract: The processing is necessary for the
|
||||
performance of the agreement between you and us.
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h2>6. Data Sharing</h2>
|
||||
<p>
|
||||
We do not sell, trade, or otherwise transfer your personal information
|
||||
to third parties. However, we may share your information with:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>
|
||||
- Third-party service providers involved in the operation and
|
||||
maintenance of the bot.
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h2>7. Data Security</h2>
|
||||
<p>
|
||||
We implement reasonable security measures to protect your personal
|
||||
information from unauthorized access, disclosure, alteration, and
|
||||
destruction.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>8. Your Rights</h2>
|
||||
<p>You have the following rights regarding your personal data:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>
|
||||
- Right to withdraw consent: You have the right to withdraw your
|
||||
consent at any time. You can do this by contacting us at
|
||||
contact@moonleay.net.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
- Right to rectification: You can request corrections to
|
||||
inaccurate or incomplete information.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
- Right to erasure: You can request the deletion of your personal
|
||||
data.
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h2>9. Changes to this Privacy Policy</h2>
|
||||
<p>
|
||||
We may update this Privacy Policy to reflect changes in our practices.
|
||||
The updated version will be posted on
|
||||
https://liljudd.ink/privacy-policy.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>10. Contact Information</h2>
|
||||
<p>
|
||||
If you have any questions or concerns about this Privacy Policy,
|
||||
please contact us at contact@moonleay.net.
|
||||
</p>
|
||||
</section>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default privacyPolicy;
|
55
src/routes/stack.tsx
Normal file
|
@ -0,0 +1,55 @@
|
|||
import Layout from "~/components/Layout";
|
||||
import "../styles/pages/stack.scss";
|
||||
|
||||
function stack() {
|
||||
return (
|
||||
<Layout site="stack">
|
||||
<h1>The Stack</h1>
|
||||
<section>
|
||||
<img src="/assets/logos/kotlin.svg" alt="Kotlin 'K' logo" />
|
||||
<div class="stackgrid_3 stackitm">
|
||||
<h1>The Kotlin programming language</h1>
|
||||
<p>
|
||||
A programming language, which runs in the JVM. Also my main
|
||||
language.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<img src="/assets/logos/kord.png" alt="The Kord logo" />
|
||||
<div class="stackgrid_3 stackitm">
|
||||
<h1>The Kord library</h1>
|
||||
<p>A Kotlin library for creating Discord bots. Pretty bare bones.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<img
|
||||
src="/assets/logos/kordextensions.png"
|
||||
alt="The Kord-Extensions logo"
|
||||
/>
|
||||
<div class="stackgrid_3 stackitm">
|
||||
<h1>The Kord Extensions library</h1>
|
||||
<p>A Kotlin library to improve the Kord experience.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<img src="/assets/logos/pgelephant.png" alt="The PostgreSQL elephant" />
|
||||
<div class="stackgrid_3 stackitm">
|
||||
<h1>The PostgreSQL database</h1>
|
||||
<p>
|
||||
A fast and reliable database. Also something, which I already used
|
||||
beforehand.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="stack-note">
|
||||
<p>
|
||||
To view all used libraries and their licenses, check the{" "}
|
||||
<a href="/acknowledgements">Acknowledgements</a>.
|
||||
</p>
|
||||
</section>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default stack;
|
95
src/routes/terms-of-service.tsx
Normal file
|
@ -0,0 +1,95 @@
|
|||
import Layout from "~/components/Layout";
|
||||
import "../styles/pages/terms-of-service.scss";
|
||||
|
||||
function termsOfService() {
|
||||
return (
|
||||
<Layout site="termsOfService">
|
||||
<h1>Terms of Service</h1>
|
||||
<div>
|
||||
<h2>Usage Agreement</h2>
|
||||
<p>
|
||||
By inviting the bot and using its features (commands, planning system)
|
||||
are you agreeing to the below mentioned Terms and Privacy Policy
|
||||
(Policy) of the bot.
|
||||
<br />
|
||||
You acknowledge that you have the privilege to use the bot freely on
|
||||
any Discord Server (Server) you share with it, that you can invite it
|
||||
to any Server that you have "Manage Server" rights for and that this
|
||||
privilege might get revoked for you, if you're subject of breaking the
|
||||
terms and/or policy of this bot, or the{" "}
|
||||
<a href="https://discord.com/terms" target="_blank">
|
||||
Terms of Service
|
||||
</a>
|
||||
,{" "}
|
||||
<a href="https://discord.com/privacy" target="_blank">
|
||||
Privacy Policy
|
||||
</a>{" "}
|
||||
and/or{" "}
|
||||
<a href="https://discord.com/guidelines" target="_blank">
|
||||
Community Guidelines
|
||||
</a>{" "}
|
||||
of{" "}
|
||||
<a href="https://discord.com/" target="_blank">
|
||||
Discord Inc
|
||||
</a>
|
||||
.<br />
|
||||
Through Inviting the bot may it collect specific data as described in
|
||||
its Policy.
|
||||
<br />
|
||||
The intended usage of this data is for core functionalities of the bot
|
||||
such as command handling, guild-specific settings and the
|
||||
time-planning system.
|
||||
<br />
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Intended Age</h2>
|
||||
<p>
|
||||
The bot may not be used by individuals under the minimal age described
|
||||
in Discord's Terms of Service.
|
||||
<br />
|
||||
Doing so will be seen as a violation of these terms and will result in
|
||||
a removal of the bot from any servers you own.
|
||||
<br />
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Affiliation</h2>
|
||||
<p>
|
||||
The Bot is not affiliated with, supported or made by Discord Inc.
|
||||
<br />
|
||||
Any direct connection to Discord or any of its Trademark objects is
|
||||
purely coincidental. We do not claim to have the copyright ownership
|
||||
of any of Discord's assets, trademarks or other intellectual property.
|
||||
<br />
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Liability</h2>
|
||||
<p>
|
||||
The owner of the bot may not be made liable for individuals breaking
|
||||
these Terms at any given time.
|
||||
<br />
|
||||
He has faith in the end users being truthfull about their information
|
||||
and not misusing this bot or the services of Discord Inc in a
|
||||
malicious way.
|
||||
<br />
|
||||
We reserve the right to update these terms at our own discretion,
|
||||
giving you a 1-Week (7 days) period to opt out of these terms if
|
||||
you're not agreeing with the new changes. You may opt out by Removing
|
||||
the bot from any Server you have the rights for.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Contact</h2>
|
||||
<p>
|
||||
People may get in contact through e-mail at contact@moonleay.net, or
|
||||
through the official Support Discord of the Bot. Other ways of support
|
||||
may be provided but aren't guaranteed.
|
||||
</p>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default termsOfService;
|
|
@ -24,9 +24,18 @@ footer {
|
|||
}
|
||||
|
||||
.footerTable {
|
||||
padding: 20px 0;
|
||||
padding: 15px;
|
||||
display: grid;
|
||||
@media (max-width: 800px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0;
|
||||
div {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 801px) {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
gap: 20px;
|
||||
|
||||
div {
|
||||
|
|