Compare commits

..

13 Commits

34 changed files with 1058 additions and 521 deletions

View File

@ -1,11 +1,19 @@
"use server";
import { auth } from "@/auth";
import { prisma } from "@/prisma/prismaClient";
import { isEmailVerified } from "@/util/isEmailVerified";
export const createGameSystem = async (name: string) => {
const session = await auth();
if (!session?.user?.id) return null;
if (!isEmailVerified(session.user.id)) return null;
const { id } = await prisma.gameSystem.create({
data: {
name,
authorId: session.user.id,
},
select: {
id: true,

View File

@ -1,13 +1,18 @@
"use server";
import { auth } from "@/auth";
import { prisma } from "@/prisma/prismaClient";
import { isEmailVerified } from "@/util/isEmailVerified";
import { redirect } from "next/navigation";
export const createSchema = async (form: FormData) => {
const name = form.get("name")?.toString();
const gsId = form.get("gsId")?.toString();
if (!name || !gsId) return;
const session = await auth();
if (!name || !gsId || !session?.user?.id || !isEmailVerified(session.user.id))
return;
const { id } = await prisma.schema.create({
data: {
@ -16,6 +21,7 @@ export const createSchema = async (form: FormData) => {
types: "{}",
version: 0,
gameSystemId: gsId,
authorId: session.user.id,
},
select: { id: true },
});

View File

@ -1,20 +1,22 @@
import { prisma } from "@/prisma/prismaClient";
"use client";
import { createGameSystem } from "@/actions/GameSystems/create";
import { useToast } from "@/components/toast";
import { redirect } from "next/navigation";
export default function CreateGameSystem() {
const { createToast } = useToast();
async function create(form: FormData) {
"use server";
const name = form.get("name")?.toString();
if (!name) return;
const { id } = await prisma.gameSystem.create({
data: {
name,
},
select: {
id: true,
},
});
if (!name)
return createToast({ msg: "Please provide a name", fading: true });
createToast({ msg: "Creating Game System", fading: true });
const id = await createGameSystem(name);
if (!id)
return createToast({
msg: "Issue creating game system. Is your email verified?",
fading: true,
type: "error",
});
redirect(`/game-systems/${id}`);
}
@ -22,7 +24,6 @@ export default function CreateGameSystem() {
<form action={create}>
<input
type="text"
// {...bind}
name="name"
placeholder="Create a new game system..."
className="w-min"

5
app/testing/page.tsx Normal file
View File

@ -0,0 +1,5 @@
import { MDEditor } from "@/components/mdeditor";
export default function Testing() {
return <MDEditor />;
}

View File

@ -5,45 +5,36 @@ import Credentials from "next-auth/providers/credentials";
import Discord from "next-auth/providers/discord";
import bcrypt from "bcryptjs";
import { SecretClient } from "@/lib/secret/init";
const prisma = new PrismaClient();
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Discord({
clientId: process.env.DISCORD_CLIENT_ID,
clientSecret: process.env.DISCORD_CLIENT_SECRET,
}),
Credentials({
credentials: {
email: {},
password: {},
},
authorize: async (credentials) => {
let user = null;
export const { handlers, signIn, signOut, auth } = NextAuth(async () => {
const sClient = SecretClient();
const pwHash = await saltAndHashPassword(
credentials.password as string
);
user = await prisma.user.findFirst({
where: {
email: credentials.email as string,
},
select: {
name: true,
image: true,
email: true,
emailVerified: true,
username: true,
passwordHash: true,
},
});
const clientId = await sClient.fetchSecret("discord_client_id");
const clientSecret = await sClient.fetchSecret("discord_client_secret");
if (!user) {
user = await prisma.user.create({
data: {
return {
providers: [
Discord({
clientId,
clientSecret,
}),
Credentials({
credentials: {
email: {},
password: {},
},
authorize: async (credentials) => {
let user = null;
const pwHash = await saltAndHashPassword(
credentials.password as string
);
user = await prisma.user.findFirst({
where: {
email: credentials.email as string,
passwordHash: pwHash,
},
select: {
name: true,
@ -51,18 +42,35 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
email: true,
emailVerified: true,
username: true,
passwordHash: true,
},
});
if (!user) {
user = await prisma.user.create({
data: {
email: credentials.email as string,
passwordHash: pwHash,
},
select: {
name: true,
image: true,
email: true,
emailVerified: true,
username: true,
},
});
return user;
}
user.passwordHash = null;
return user;
}
user.passwordHash = null;
return user;
},
}),
],
adapter: PrismaAdapter(prisma),
},
}),
],
adapter: PrismaAdapter(prisma),
};
});
async function saltAndHashPassword(password: string) {
const hash = await bcrypt.hash(password, 10);

View File

@ -0,0 +1,21 @@
import CodeMirror from "@uiw/react-codemirror";
import { markdown } from "@codemirror/lang-markdown";
import { duotoneDark } from "@uiw/codemirror-theme-duotone";
interface TextEditorProps {
value: string;
onChange: (value: string) => void;
}
export const TextEditor: React.FC<TextEditorProps> = ({ value, onChange }) => {
return (
<CodeMirror
value={value}
extensions={[markdown({ extensions: [] })]}
onChange={(value, _viewUpdate) => {
onChange(value);
}}
theme={duotoneDark}
/>
);
};

View File

@ -0,0 +1,19 @@
"use client";
import { useDeferredValue, useState } from "react";
import { TextEditor } from "./TextEditor";
import { TTCMD } from "../ttcmd";
export const MDEditor: React.FC = () => {
const [text, setText] = useState("??<<2d6,$0.distribution>>");
const body = useDeferredValue(text);
return (
<div className="flex gap-8">
<div className="w-1/2">
<TextEditor value={text} onChange={setText} />
</div>
<div className="w-1/2">
<TTCMD body={body} parserId="preview" title="preview" />
</div>
</div>
);
};

View File

@ -1,9 +1,17 @@
import { sum } from "./utils/sum";
export class Dice {
private count!: number;
private sides!: number;
private diceString: string;
toString() {
return this.diceString;
}
constructor(dice: string) {
this.parseDice(dice);
this.diceString = dice;
}
private parseDice(dice: string) {
@ -13,11 +21,23 @@ export class Dice {
}
public roll() {
let total = 0;
let results = [];
for (let i = 0; i < this.count; i++) {
total += this.rollSingle();
results.push(this.rollSingle());
}
return total;
return {
total: sum(...results),
max: Math.max(...results),
min: Math.min(...results),
results,
};
}
public rollMax() {
return this.roll().max;
}
public rollMin() {
return this.roll().min;
}
private rollSingle() {
@ -25,13 +45,13 @@ export class Dice {
}
public rollAvg() {
return this.roll() / this.count;
return this.roll().total / this.count;
}
public rollTimes(times: number) {
let total = 0;
for (let i = 0; i < times; i++) {
total += this.roll();
total += this.roll().total;
}
return total;
}
@ -58,32 +78,36 @@ export class Dice {
return this.computeDistribution();
}
private computeDistribution(): Record<number, number> {
public computeDistribution(): Record<number, number> {
const maxSum = this.count * this.sides;
const dp: number[][] = Array.from({ length: this.count + 1 }, () =>
Array(maxSum + 1).fill(0)
);
dp[0][0] = 1;
for (let dice = 1; dice <= this.count; dice++) {
for (let sum = 0; sum <= maxSum; sum++) {
for (let face = 1; face <= this.sides; face++) {
if (sum >= face) {
dp[dice][sum] += dp[dice - 1][sum - face];
}
}
}
}
const distribution: Record<number, number> = {};
// Helper function to compute the sum distribution for given number of dice
const computeSumDistribution = (
dice: number,
sides: number,
currentSum: number,
currentDice: number
): void => {
if (currentDice === dice) {
distribution[currentSum] = (distribution[currentSum] || 0) + 1;
return;
}
for (let i = 1; i <= sides; i++) {
computeSumDistribution(dice, sides, currentSum + i, currentDice + 1);
}
};
// Compute distribution
computeSumDistribution(this.count, this.sides, 0, 0);
for (let sum = this.count; sum <= maxSum; sum++) {
distribution[sum] = dp[this.count][sum];
}
return distribution;
}
// STATIC
static isDice(d: string) {
return /\d+[dD]\d+/.test(d);
}
}
// globalThis.Dice = Dice;

View File

@ -1,5 +1,7 @@
// import { mkdirSync, readFileSync, writeFileSync } from "fs";
// import { writeFile } from "fs/promises";
import { writeFile } from "fs/promises";
import { mkdirSync, readFileSync } from "fs";
export class DHSecretClient {
private token!: Promise<string>; //Set by init
@ -13,14 +15,10 @@ export class DHSecretClient {
* @param dhBaseUri uri for hosted Dragon's Hoard instance
* @param cacheDir path to cache dir
*/
constructor(
private dhBaseUri: string,
private cacheDir: string,
) {
constructor(private dhBaseUri: string, private cacheDir: string) {
this.cacheLocation = this.cacheDir.trim().replace(/\/^/, "") + "/.dh_cache";
// mkdirSync(this.cacheDir, { recursive: true });
// writeFileSync(this.cacheLocation, "{}", { encoding: "utf-8", flag: "wx" });
// this.readDiskCache();
mkdirSync(this.cacheDir, { recursive: true });
this.readDiskCache();
this.token = this.fetchToken();
}
@ -43,19 +41,22 @@ export class DHSecretClient {
return token;
}
// private readDiskCache() {
// const cache = readFileSync(this.cacheLocation, "utf-8");
private readDiskCache() {
const cache = readFileSync(this.cacheLocation, "utf-8");
// this.cache = JSON.parse(cache || "{}");
// }
// private async writeDiskCache() {
// await writeFile(this.cacheLocation, JSON.stringify(this.cache), "utf-8");
// }
if (!cache) {
this.cache = {};
this.writeDiskCache();
} else this.cache = JSON.parse(cache || "{}");
}
private async writeDiskCache() {
await writeFile(this.cacheLocation, JSON.stringify(this.cache), "utf-8");
}
private writeCache(key: string, value: string, expires?: number) {
this.cache[key] = { value, expires };
// this.writeDiskCache();
this.writeDiskCache();
}
private readCache(key: string) {
@ -73,7 +74,10 @@ export class DHSecretClient {
}
async fetchSecret(secret_name: string, environment?: string) {
const uri = this.dhBaseUri + "/api/keys/" + secret_name +
const uri =
this.dhBaseUri +
"/api/keys/" +
secret_name +
(environment ? "?env=" + environment : "");
const cached = this.readCache(secret_name);

View File

@ -5,8 +5,8 @@ if (!globalThis.Secrets) {
"https://dragonshoard.cyborggrizzly.com",
process.env.NODE_ENV === "development"
? "./.dragonshoard"
: "/.dragonshoard",
: "/.dragonshoard"
);
}
export const SecretClient = () => globalThis.Secrets;
export const SecretClient = (): DHSecretClient => globalThis.Secrets;

View File

@ -1,18 +1,27 @@
import { PublicationAtom } from "@/recoil/atoms/publication";
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useCallback, useRef, ReactNode } from "react";
import { useRecoilValue } from "recoil";
import { TTCQueryResolver } from "../ttcQuery/TTCResolvers";
export function Resolver({ resolver }: { resolver: string }) {
const parser = useRecoilValue(PublicationAtom);
const [res] = useState(new TTCQueryResolver(parser));
const [content, setContent] = useState("");
const [content, setContent] = useState<ReactNode>("");
useEffect(() => {
setContent(res.resolve(resolver));
let resolved = res.resolve(resolver);
setContent(
typeof resolved?.display === "function" ? (
<resolved.display />
) : (
resolved?.display
)
);
}, [resolver, res]);
return <span>{content}</span>;
}
const defaultTemplate = "$x";
export function OnDemandResolver({
resolver,
template,
@ -24,17 +33,27 @@ export function OnDemandResolver({
}) {
const parser = useRecoilValue(PublicationAtom);
const res = useRef(new TTCQueryResolver(parser));
const [content, setContent] = useState("");
const [content, setContent] = useState<ReactNode>("");
const generateContent = useCallback(() => {
let content = template;
const stackIdxs = Array.from(new Set(template.match(/\$\d/g)));
for (const idx of stackIdxs) {
let thing = res.current.getFromStack(idx);
if (Array.isArray(thing)) thing = thing.at(0);
if (typeof thing === "function") thing = thing();
content = content.replaceAll(idx, thing as string);
}
setContent(content);
setContent(() => {
let content = template || defaultTemplate;
const stackIdxs = Array.from(new Set(content.match(/\$(?:\d+|x)/g)));
for (const idx of stackIdxs) {
let thing = res.current.getFromStack(idx);
if (Array.isArray(thing)) thing = thing.at(0);
console.log(thing);
if (typeof thing.display === "function") {
const disp = thing.display();
if (typeof disp === "string" || typeof disp === "number")
content = content.replaceAll(idx, disp.toString());
else return disp as ReactNode;
}
// else if (idx === defaultTemplate && )
else content = content.replaceAll(idx, thing.display as string);
return content;
}
});
}, [res, template]);
const resolve = useCallback(() => {
@ -43,12 +62,16 @@ export function OnDemandResolver({
}, [res, resolver, generateContent]);
return (
<>
<button onMouseDown={() => setContent("")} onClick={resolve}>
<div className="my-2 rounded-md p-1 bg-black/20 flex flex-col">
<button
className="text-primary-600"
onMouseDown={() => setContent("")}
onClick={resolve}
>
{title ?? "Resolve"}
</button>
<br />
{!!content && <span>{content}</span>}
</>
</div>
);
}

View File

@ -549,6 +549,7 @@ export const buildOnlyDefaultElements = () => {
// paragraph
registerIdentifier(
"p",
// /(?<=\n\n|^)([\s\S]*?)(?=\n\n|$)/g,
/(?<=\n\n)([\s\S]*?)(?=\n\n)/g,
(s) => {
return {
@ -802,11 +803,11 @@ export const buildOnlyDefaultElements = () => {
// on-demand resolver
registerIdentifier(
"on-demand resolver",
/\?\?\[.*?\](\(.*?\))?<<(.*?)>>/g,
/\?\?\[.*?\](\(.*?\))<<(.*?)>>/g,
(s) => {
const inp = s.match(/(?<=<<)(.*?)(?=>>)/)![0];
const template = s.match(/(?<=\?\?\[)(.*?)(?=\])/)![0];
const title = s.match(/(?<=\]\()(.*?)(?=\))/)?.at(0);
const title = s.match(/(?<=\?\?\[)(.*?)(?=\])/)![0];
const template = s.match(/(?<=\]\()(.*?)(?=\))/)![0];
if (inp == undefined)
return {
content: "Error parsing resolver: " + s,
@ -931,7 +932,7 @@ function search(
function generateId(t: string, usedIds: string[]) {
let id = t
.toLowerCase()
.replace(/[^a-z\s]/gi, "")
.replace(/[^a-z\s-\d]/gi, "")
.trim()
.replaceAll(" ", "-");
let idNum = 1;

View File

@ -11,7 +11,10 @@ export const createElements = (body: string): Token[] => {
const tokenize = (body: string) => {
const tokenizedBody: TokenMarker[] = [];
body = body.replaceAll(/[ \t]+\n/g, "\n").replaceAll(/\n{3,}/g, "\n\n");
body = body
.trim()
.replaceAll(/[ \t]+\n/g, "\n")
.replaceAll(/\n{3,}/g, "\n\n");
const addToken = (thing: TokenMarker) => {
tokenizedBody.push(thing);
@ -23,6 +26,7 @@ const tokenize = (body: string) => {
const rx = new RegExp(token.rx);
let match;
while ((match = rx.exec(body)) !== null) {
if (type === "p") debugger;
const start = match.index;
const end = rx.lastIndex;
@ -269,7 +273,10 @@ export function extractFrontMatter(body: string): [FrontMatter, string] {
const rx = /^---([\s\S]*?)---/;
const [_, frontmatterString] = body.match(rx) || ["", ""];
body = body.replace(rx, "");
console.log(body.replace(rx, ""));
body = body.replace(rx, "").trim();
console.log(body);
const frontMatter: FrontMatter = {};

View File

@ -0,0 +1,48 @@
import { FC } from "react";
export const DiceChart: FC<{ dice: Record<string, number> }> = ({ dice }) => {
const data = Object.entries(dice).sort((a, b) => Number(a[0]) - Number(b[0]));
const max = Math.max(...data.map((d) => d[1]));
const _min = Math.min(...data.map((d) => d[1]));
const uniqueValues = Array.from(new Set(data.map((d) => d[1])));
return (
<div className="p-8 bg-black/30 rounded-md max-w-[35rem]">
<div className="relative flex px-2 gap-1 justify-around items-end h-48 border-b border-l">
<div className="absolute inset-0 flex flex-col-reverse">
{uniqueValues.map((_, i) => (
<div
key={"dicechartline" + i}
className="relative w-full border-t border-mixed-300/50 flex-grow"
>
{/* <div className="absolute right-full text-xs flex items-center -translate-y-1/2">
<span>
{Math.round(max / uniqueValues.length) * (i + 1)} {}
</span>
<div className="border-b w-1 ml-1"></div>
</div> */}
</div>
))}
</div>
{data.map((d) => (
<div
key={"dice" + d[0]}
title={d[0] + ": " + d[1]}
className="flex-grow px-[1px] border hover:border-mixed-300/50 border-transparent h-full flex transition-colors rounded-md @container"
>
<div
className="bg-purple-800 relative rounded-t-sm w-full mt-auto"
style={{ height: (d[1] / max) * 100 + "%" }}
>
<span className="absolute top-full left-1/2 -translate-x-1/2 text-xs @[.75rem]:inline hidden">
{d[0]}
</span>
</div>
</div>
))}
</div>
</div>
);
};

View File

@ -1,11 +1,20 @@
/* eslint-disable react/display-name */
import { ReactNode } from "react";
import { Dice } from "../dice";
import { sum } from "../utils/sum";
import { DiceChart } from "./DiceChart";
import { TTCQueryParser } from "./TTCQueryParser";
interface StackItem {
value: any;
display: ReactNode | (() => ReactNode);
}
export class TTCQueryResolver {
private parser: TTCQueryParser | null;
private context: QueryableObject | null = null;
private stack: any[] = [];
private stack: StackItem[] = [];
constructor(parser?: TTCQueryParser) {
this.parser = parser || null;
@ -19,21 +28,35 @@ export class TTCQueryResolver {
this.context = obj;
}
public resolve(resolver: string, onDemand?: boolean) {
const resList = resolver.split(",");
for (const res of resList) {
this.stack.push(this.parseResolver(res));
}
const last = this.stack.at(-1);
if (typeof last === "function" && !onDemand) return last();
public resolve(resolver: string, onDemand?: boolean): StackItem | undefined {
try {
const resList = resolver.split(",");
for (const res of resList) {
this.stack.push(this.parseResolver(res));
}
const last = this.stack.at(-1);
if (typeof last?.display === "function" && !onDemand) {
last.display = last.display();
}
return last;
return last;
} catch (e) {
return { value: e?.toString(), display: e?.toString() };
}
}
private parseResolver(resolver: string) {
private parseResolver(resolver: string): StackItem {
if (this.isArithmetic(resolver)) return this.solveArithmetic(resolver);
if (this.isQuery(resolver)) return this.runQuery(resolver);
return resolver;
if (Dice.isDice(resolver)) {
const value = new Dice(resolver);
const dice: StackItem = {
value,
display: () => value.toString(),
};
return dice;
}
return { value: resolver, display: resolver };
}
private isQuery(resolver: string) {
return (
@ -42,76 +65,141 @@ export class TTCQueryResolver {
/^\$\d\./.test(resolver)
);
}
private runQuery(query: string) {
private runQuery(query: string): StackItem {
if (!this.parser) throw "Parser not defined in query resolver";
if (query.startsWith("$")) {
const [_, idx, q] = query.match(/^(\$\d+)\.(.*)/) || [];
if (!_) throw "Detected stack query but did not match the regex";
const stackItem = this.getFromStack(idx);
if (typeof stackItem === "string" && Dice.isDice(stackItem)) {
return this.handleDice(stackItem, q);
if (stackItem.value instanceof Dice) {
return {
value: query,
display: this.handleDice(stackItem.value, q),
};
}
return this.parser.search(q, stackItem as QueryableObject);
const [res] = this.parser.search(q, stackItem.value as QueryableObject);
debugger;
if (Dice.isDice(res)) {
const value = new Dice(res);
return {
value,
display: () => value.toString(),
};
}
return {
value: res,
display() {
return (
this.value.render ?? this.value ?? "Error resolving query: " + query
);
},
};
}
// if (query.startsWith("?") || query.startsWith("_"))
return query.startsWith("_") && this.context
? this.parser.search(query.replace("_", "^"), this.context).at(0)
: this.parser.search(query.replace(/^[?_].?/, "")).at(0);
}
private handleDice(dice: string, query: string) {
const d = new Dice(dice);
const [method, n] = query.split(":");
let num = Number(n);
if (n && n.startsWith("$")) num = this.getFromStack(n);
switch (method) {
case "roll":
return () => d.roll();
case "rollAvg":
return () => d.rollAvg();
case "rollTimes":
return () => d.rollTimes(num);
case "rollTimesAvg":
return () => d.rollTimesAvg(num);
default:
return "No valid method provided for dice";
const res =
query.startsWith("_") && this.context
? this.parser.search(query.replace("_", "^"), this.context).at(0)
: this.parser.search(query.replace(/^[?_].?/, "")).at(0);
if (Dice.isDice(res)) {
const value = new Dice(res);
return {
value,
display: () => value.toString(),
};
}
return {
value: res,
display() {
return (
this.value.render ?? this.value ?? "Error resolving query: " + query
);
},
};
}
private isArithmetic(resolver: string) {
return resolver.split(/\+|\/|-|\*|\^/).filter((e) => !!e).length > 1;
}
private solveArithmetic(resolver: string) {
private solveArithmetic(resolver: string): StackItem {
const [n1, op, n2] = resolver
.match(/(\$?\d+)([+\-*\/^])(\$?\d+)/)
?.slice(1) || ["", "+", ""];
let num1 = Number(n1),
num2 = Number(n2);
let num1: number = Number(n1),
num2: number = Number(n2);
if (n1.startsWith("$")) num1 = this.getFromStack<number>(n1);
if (n2.startsWith("$")) num2 = this.getFromStack<number>(n2);
switch (op) {
case "+":
return num1 + num2;
case "-":
return num1 - num2;
case "*":
return num1 * num2;
case "/":
return num1 / num2;
case "^":
return num1 ^ num2;
default:
throw "Arithmetic detected but no proper operator assigned";
if (n1.startsWith("$")) {
const result = this.getFromStack(n1).value;
num1 = result instanceof Dice ? result.roll().total : Number(result);
}
if (n2.startsWith("$")) {
const result = this.getFromStack(n1).value;
num2 = result instanceof Dice ? result.roll().total : Number(result);
}
const thing: StackItem = {
value: () => {
switch (op) {
case "+":
return num1 + num2;
case "-":
return num1 - num2;
case "*":
return num1 * num2;
case "/":
return num1 / num2;
case "^":
return num1 ^ num2;
default:
throw "Arithmetic detected but no proper operator assigned";
}
},
display() {
return typeof this.value === "function" ? this.value() : this.value;
},
};
return thing;
}
public getFromStack<T>(stackIndex: string): T {
public getFromStack(stackIndex: string): StackItem {
if (stackIndex === "$x") return this.stack.at(-1)!;
const i = Number(stackIndex.replace("$", ""));
const val = this.stack[i] as T;
const val = this.stack[i];
return val;
}
private handleDice(dice: Dice, query: string) {
const [method, n] = query.split(":");
let num = Number(n);
// if (n && n.startsWith("$")) num = this.getFromStack(n);
switch (method) {
case "roll":
return () => dice.roll.apply(dice).total;
case "rollAvg":
return () => dice.rollAvg.apply(dice);
case "rollTimes":
return () => dice.rollTimes.apply(dice, [num]);
case "rollTimesAvg":
return () => dice.rollTimesAvg.apply(dice, [num]);
case "rollLowest":
return () => dice.rollMin.apply(dice);
case "rollHighest":
return () => dice.rollMax.apply(dice);
case "rollDropHighest":
return () =>
sum(...dice.roll.apply(dice).results.toSorted().toSpliced(-1, 1));
case "rollDropLowest":
return () =>
sum(...dice.roll.apply(dice).results.toSorted().toSpliced(0, 1));
case "distribution":
return () => DiceChart({ dice: dice.getRollDistribution.apply(dice) });
case "distribution.normalized":
return () =>
DiceChart({ dice: dice.getNormalizedRollDistribution.apply(dice) });
default:
return () => "No valid method provided for dice";
}
}
}

3
lib/utils/sum.ts Normal file
View File

@ -0,0 +1,3 @@
export const sum = (...args: number[]) => {
return args.reduce((a, b) => a + b, 0);
};

View File

@ -2,7 +2,7 @@
title: How to use ttcMD
author: Emmaline Autumn
date: March 14th, 2024
updated: March 14th, 2024
updated: August 21st, 2024
---
# Table of Contents
@ -368,17 +368,17 @@ Let's say you want to get the the result of rolling a dice field. You would simp
This works very similarly to the normal resolver, but when it renders it will have a button that you can press. When you press it, it recalculates its value. This is very useful for dice and decks of cards. It has not been fully implemented yet
Here's the syntax: `??[Template](Text?)<<List::QueryValue>>`. Template is a basic string that has access to all values of the resolver using the `$#` variables. If template is left blank, the value that the resolver finally reaches will be rendered. In lieu of a template, you can also have it render the display of a field if it has one. Text is the text that will be rendered in the button. If not provided, the button will simply same "Resolve."
Here's the syntax: `??[Text](Template)<<List::QueryValue>>`. Template is a basic string that has access to all values of the resolver using the `$#` variables. If template is left blank, the value that the resolver finally reaches will be rendered. In lieu of a template, you can also have it render the display of a field if it has one. Text is the text that will be rendered in the button. If not provided, the button will simply same "Resolve."
[][]
[[
To use the dice as an example again, here's how you would do that: `??[Rolling $0, you got: $1](Roll 2d6)<<_.path.to.dice,$0.roll>>`
To use the dice as an example again, here's how you would do that: `??[Roll 2d6](Rolling $0, you got: $1)<<_.path.to.dice,$0.roll>>`
??[Rolling $0, you got: $1](Roll 2d6)<<_.path.to.dice,$0.roll>>
??[Roll 2d6](Rolling $0, you got: $1)<<_.path.to.dice,$0.roll>>
]]
[[
For drawing a card and having it show the card's display: `??[]<<_.path.to.deck,$0.draw,$1.display>>`
For drawing a card and having it show the card's display: `??[]()<<_.path.to.deck,$0.draw,$1.display>>`
]]
/[]

View File

@ -12,4 +12,9 @@
3. my
4. name
5. is
6. welcome
6. welcome
- [-\_hello1234!@#$%^\&\*()-](#-_hello1234-)
# -_hello1234!@#$%^&*()-

472
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,9 +11,13 @@
},
"dependencies": {
"@auth/prisma-adapter": "^2.4.2",
"@codemirror/lang-markdown": "^6.2.5",
"@heroicons/react": "^2.1.1",
"@prisma/client": "^5.18.0",
"@tailwindcss/container-queries": "^0.1.1",
"@types/bcryptjs": "^2.4.6",
"@uiw/codemirror-theme-duotone": "^4.23.0",
"@uiw/react-codemirror": "^4.23.0",
"bcryptjs": "^2.4.3",
"isomorphic-dompurify": "^2.4.0",
"jotai": "^2.9.3",

View File

@ -1,46 +0,0 @@
-- CreateTable
CREATE TABLE `GameSystem` (
`id` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`created` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Schema` (
`id` VARCHAR(191) NOT NULL,
`gameSystemId` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`schema` JSON NOT NULL,
`version` INTEGER NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Publication` (
`id` VARCHAR(191) NOT NULL,
`schemaId` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`data` JSON NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Tag` (
`id` VARCHAR(191) NOT NULL,
`publicationId` VARCHAR(191) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `Schema` ADD CONSTRAINT `Schema_gameSystemId_fkey` FOREIGN KEY (`gameSystemId`) REFERENCES `GameSystem`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Publication` ADD CONSTRAINT `Publication_schemaId_fkey` FOREIGN KEY (`schemaId`) REFERENCES `Schema`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Tag` ADD CONSTRAINT `Tag_publicationId_fkey` FOREIGN KEY (`publicationId`) REFERENCES `Publication`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -1,8 +0,0 @@
/*
Warnings:
- A unique constraint covering the columns `[name]` on the table `GameSystem` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX `GameSystem_name_key` ON `GameSystem`(`name`);

View File

@ -1,8 +0,0 @@
/*
Warnings:
- Added the required column `types` to the `Schema` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE `Schema` ADD COLUMN `types` JSON NOT NULL;

View File

@ -1,83 +0,0 @@
/*
Warnings:
- Added the required column `authorId` to the `GameSystem` table without a default value. This is not possible if the table is not empty.
- Added the required column `authorId` to the `Publication` table without a default value. This is not possible if the table is not empty.
- Added the required column `authorId` to the `Schema` table without a default value. This is not possible if the table is not empty.
- Added the required column `originalId` to the `Schema` table without a default value. This is not possible if the table is not empty.
- Added the required column `name` to the `Tag` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE `Schema` DROP FOREIGN KEY `Schema_gameSystemId_fkey`;
-- DropForeignKey
ALTER TABLE `Tag` DROP FOREIGN KEY `Tag_publicationId_fkey`;
-- AlterTable
ALTER TABLE `GameSystem` ADD COLUMN `authorId` VARCHAR(191) NOT NULL;
-- AlterTable
ALTER TABLE `Publication` ADD COLUMN `authorId` VARCHAR(191) NOT NULL;
-- AlterTable
ALTER TABLE `Schema` ADD COLUMN `authorId` VARCHAR(191) NOT NULL,
ADD COLUMN `originalId` VARCHAR(191) NOT NULL,
MODIFY `gameSystemId` VARCHAR(191) NULL;
-- AlterTable
ALTER TABLE `Tag` ADD COLUMN `name` VARCHAR(191) NOT NULL,
MODIFY `publicationId` VARCHAR(191) NULL;
-- CreateTable
CREATE TABLE `TagsOnPublications` (
`publicationId` VARCHAR(191) NOT NULL,
`tagId` VARCHAR(191) NOT NULL,
PRIMARY KEY (`publicationId`, `tagId`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `TagsOnTags` (
`parentTagId` VARCHAR(191) NOT NULL,
`childTagId` VARCHAR(191) NOT NULL,
PRIMARY KEY (`parentTagId`, `childTagId`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `User` (
`id` VARCHAR(191) NOT NULL,
`username` VARCHAR(191) NOT NULL,
`email` VARCHAR(191) NOT NULL,
UNIQUE INDEX `User_email_key`(`email`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `GameSystem` ADD CONSTRAINT `GameSystem_authorId_fkey` FOREIGN KEY (`authorId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Schema` ADD CONSTRAINT `Schema_gameSystemId_fkey` FOREIGN KEY (`gameSystemId`) REFERENCES `GameSystem`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Schema` ADD CONSTRAINT `Schema_authorId_fkey` FOREIGN KEY (`authorId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Publication` ADD CONSTRAINT `Publication_authorId_fkey` FOREIGN KEY (`authorId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `TagsOnPublications` ADD CONSTRAINT `TagsOnPublications_publicationId_fkey` FOREIGN KEY (`publicationId`) REFERENCES `Publication`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `TagsOnPublications` ADD CONSTRAINT `TagsOnPublications_tagId_fkey` FOREIGN KEY (`tagId`) REFERENCES `Tag`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Tag` ADD CONSTRAINT `Tag_publicationId_fkey` FOREIGN KEY (`publicationId`) REFERENCES `Publication`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `TagsOnTags` ADD CONSTRAINT `TagsOnTags_parentTagId_fkey` FOREIGN KEY (`parentTagId`) REFERENCES `Tag`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `TagsOnTags` ADD CONSTRAINT `TagsOnTags_childTagId_fkey` FOREIGN KEY (`childTagId`) REFERENCES `Tag`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -1,71 +0,0 @@
/*
Warnings:
- A unique constraint covering the columns `[username]` on the table `User` will be added. If there are existing duplicate values, this will fail.
- Added the required column `updatedAt` to the `User` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE `User` ADD COLUMN `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
ADD COLUMN `emailVerified` DATETIME(3) NULL,
ADD COLUMN `image` VARCHAR(191) NULL,
ADD COLUMN `name` VARCHAR(191) NULL,
ADD COLUMN `updatedAt` DATETIME(3) NOT NULL,
MODIFY `username` VARCHAR(191) NULL,
MODIFY `email` VARCHAR(191) NULL;
-- CreateTable
CREATE TABLE `Account` (
`id` VARCHAR(191) NOT NULL,
`userId` VARCHAR(191) NOT NULL,
`type` VARCHAR(191) NOT NULL,
`provider` VARCHAR(191) NOT NULL,
`providerAccountId` VARCHAR(191) NOT NULL,
`refresh_token` TEXT NULL,
`access_token` TEXT NULL,
`expires_at` INTEGER NULL,
`token_type` VARCHAR(191) NULL,
`scope` VARCHAR(191) NULL,
`id_token` TEXT NULL,
`session_state` VARCHAR(191) NULL,
`refresh_token_expires_in` INTEGER NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `Account_userId_key`(`userId`),
INDEX `Account_userId_idx`(`userId`),
UNIQUE INDEX `Account_provider_providerAccountId_key`(`provider`, `providerAccountId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Session` (
`id` VARCHAR(191) NOT NULL,
`sessionToken` VARCHAR(191) NOT NULL,
`userId` VARCHAR(191) NOT NULL,
`expires` DATETIME(3) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `Session_sessionToken_key`(`sessionToken`),
INDEX `Session_userId_idx`(`userId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `VerificationToken` (
`identifier` VARCHAR(191) NOT NULL,
`token` VARCHAR(191) NOT NULL,
`expires` DATETIME(3) NOT NULL,
UNIQUE INDEX `VerificationToken_identifier_token_key`(`identifier`, `token`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateIndex
CREATE UNIQUE INDEX `User_username_key` ON `User`(`username`);
-- AddForeignKey
ALTER TABLE `Account` ADD CONSTRAINT `Account_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Session` ADD CONSTRAINT `Session_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE `User` ADD COLUMN `passwordHash` VARCHAR(191) NULL;

View File

@ -0,0 +1,177 @@
-- CreateTable
CREATE TABLE "GameSystem" (
"id" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"created" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GameSystem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Schema" (
"id" TEXT NOT NULL,
"gameSystemId" TEXT,
"authorId" TEXT NOT NULL,
"originalId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"schema" JSONB NOT NULL,
"types" JSONB NOT NULL,
"version" INTEGER NOT NULL,
CONSTRAINT "Schema_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Publication" (
"id" TEXT NOT NULL,
"schemaId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"data" JSONB NOT NULL,
CONSTRAINT "Publication_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TagsOnPublications" (
"publicationId" TEXT NOT NULL,
"tagId" TEXT NOT NULL,
CONSTRAINT "TagsOnPublications_pkey" PRIMARY KEY ("publicationId","tagId")
);
-- CreateTable
CREATE TABLE "Tag" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"publicationId" TEXT,
CONSTRAINT "Tag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TagsOnTags" (
"parentTagId" TEXT NOT NULL,
"childTagId" TEXT NOT NULL,
CONSTRAINT "TagsOnTags_pkey" PRIMARY KEY ("parentTagId","childTagId")
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"name" TEXT,
"username" TEXT,
"email" TEXT,
"emailVerified" TIMESTAMP(3),
"passwordHash" TEXT,
"image" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
"refresh_token_expires_in" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VerificationToken" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "GameSystem_name_key" ON "GameSystem"("name");
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Account_userId_key" ON "Account"("userId");
-- CreateIndex
CREATE INDEX "Account_userId_idx" ON "Account"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
-- AddForeignKey
ALTER TABLE "GameSystem" ADD CONSTRAINT "GameSystem_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Schema" ADD CONSTRAINT "Schema_gameSystemId_fkey" FOREIGN KEY ("gameSystemId") REFERENCES "GameSystem"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Schema" ADD CONSTRAINT "Schema_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Publication" ADD CONSTRAINT "Publication_schemaId_fkey" FOREIGN KEY ("schemaId") REFERENCES "Schema"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Publication" ADD CONSTRAINT "Publication_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TagsOnPublications" ADD CONSTRAINT "TagsOnPublications_publicationId_fkey" FOREIGN KEY ("publicationId") REFERENCES "Publication"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TagsOnPublications" ADD CONSTRAINT "TagsOnPublications_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "Tag"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Tag" ADD CONSTRAINT "Tag_publicationId_fkey" FOREIGN KEY ("publicationId") REFERENCES "Publication"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TagsOnTags" ADD CONSTRAINT "TagsOnTags_parentTagId_fkey" FOREIGN KEY ("parentTagId") REFERENCES "Tag"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TagsOnTags" ADD CONSTRAINT "TagsOnTags_childTagId_fkey" FOREIGN KEY ("childTagId") REFERENCES "Tag"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Schema" ALTER COLUMN "originalId" DROP NOT NULL;

View File

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

View File

@ -9,7 +9,7 @@ generator client {
}
datasource db {
provider = "mysql"
provider = "postgresql"
url = env("DATABASE_URL")
}
@ -31,7 +31,7 @@ model Schema {
author User @relation(fields: [authorId], references: [id])
authorId String
originalId String
originalId String?
name String
schema Json
types Json

View File

@ -51,6 +51,6 @@ const config: Config = {
},
},
},
plugins: [],
plugins: [require("@tailwindcss/container-queries")],
};
export default config;

View File

@ -38,6 +38,7 @@
"node_modules"
],
"files": [
"global.d.ts"
"global.d.ts",
"./components/mdeditor/TextEditor.tsx"
]
}

54
util/isEmailVerified.ts Normal file
View File

@ -0,0 +1,54 @@
import { prisma } from "@/prisma/prismaClient";
export async function isEmailVerified(id: string) {
const user = await prisma.user.findUnique({
where: { id },
select: {
emailVerified: true,
email: true,
accounts: { select: { provider: true, access_token: true } },
},
});
if (!user) return false;
if (user?.emailVerified) return true;
const discordAccount = user.accounts.find((a) => a.provider === "discord");
if (user && discordAccount?.access_token) {
const dcUser = await getDiscordUserInfo(discordAccount.access_token);
if (!dcUser.verified) return false;
prisma.user.update({ where: { id }, data: { emailVerified: new Date() } });
return true;
}
}
async function getDiscordUserInfo(accessToken: string): Promise<{
id: string;
username: string;
discriminator: string;
avatar: string;
verified: boolean;
email: string;
flags: number;
banner: string;
accent_color: number;
premium_type: number;
public_flags: number;
avatar_decoration_data: {
sku_id: string;
asset: string;
};
}> {
try {
const response = await fetch("https://discord.com/api/users/@me", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return await response.json();
} catch (error) {
console.error("Error fetching user info from Discord:", error);
throw error;
}
}