Compare commits

...

30 Commits

Author SHA1 Message Date
c8f20fbda8 Fixes broken accordion ref
Fixes broken poppable ref
adds Schema page
Fixes schema creation not including game system id
2024-09-09 08:37:53 -06:00
a2fde9cc79 game system nav context
sse endpoint
2024-09-08 06:43:39 -06:00
84cbea8ce1 full switch to jotai, finishes schema version query fixes 2024-09-05 05:22:29 -06:00
b529445851 changes schema editor to jotai 2024-09-04 04:28:57 -06:00
f87a759048 new models 2024-09-04 03:08:35 -06:00
5b16cc60f7 enables schema saving in db, initial refactor of db schema for revisions 2024-09-02 03:16:10 -06:00
fd5e5bcc8b ttcmd: attempts to fix infinite loop on p tags 2024-08-22 11:55:57 -06:00
6760d06b18 update last edited 2024-08-21 17:35:44 -06:00
b2e7223c16 Adds capability of on-demand resolver to render more complex stack items 2024-08-21 16:08:10 -06:00
1799c8da79 Adds a default template to resolver, adds "last" variable 2024-08-21 15:43:59 -06:00
9c9edd9e90 resolver rework 2024-08-21 15:19:41 -06:00
3417fdd3d7 Fixes id creator making inaccurate ids 2024-08-21 14:34:35 -06:00
f6fc85bf7e Changed order of resolver fields 2024-08-21 14:23:06 -06:00
e5f3cb0c34 Let's you get a number from the stack to use in dice 2024-08-20 19:49:43 -06:00
df3171b646 More dice stuff 2024-08-20 17:50:10 -06:00
d17ff63662 Markdown editor 2024-08-20 14:56:51 -06:00
e42a938b13 move to postgres, adds user checks to content creation 2024-08-20 09:55:49 -06:00
545656cf22 inlines secret fetching for auth to remove postinstall step 2024-08-20 08:25:02 -06:00
5f2243b49a fixes create to now check for author before creating game system 2024-08-20 07:51:20 -06:00
729aba68ce AUTHMODE BABAY 2024-08-18 12:34:43 -06:00
da044ac9d5 icon "fixes" 2024-08-18 12:34:34 -06:00
b9b744e97f toast messages 2024-08-15 04:11:57 -06:00
9838324b35 upgrades prisma, adds jotai 2024-08-15 04:11:42 -06:00
9e2184352f new tags, migration 2024-08-15 04:11:20 -06:00
0f100bba3d allows for arbitrary pushing of values onto stack 2024-08-05 06:31:58 -06:00
2b2b88f970 MD handlers for resolvers 2024-08-05 06:25:55 -06:00
3656fc42ac first iteration of query and resolver 2024-08-05 04:00:00 -06:00
7839dbbc1c small update to ttcmd help article 2024-08-05 03:59:33 -06:00
e6d8583220 dice parser 2024-08-05 03:54:38 -06:00
f8fa3ec924 fixes list again 2024-08-05 03:54:21 -06:00
71 changed files with 3641 additions and 848 deletions

2
.gitignore vendored
View File

@@ -43,3 +43,5 @@ temp.json
temp.md
.dragonshoard/
certificates

View File

@@ -4,6 +4,9 @@ WORKDIR /ttc
ADD . .
ENV NODE_ENV production
ENV AUTH_TRUST_HOST true
RUN npm i
RUN npm run build

View File

@@ -1,15 +0,0 @@
"use server";
import { prisma } from "@/prisma/prismaClient";
export const createGameSystem = async (name: string) => {
const { id } = await prisma.gameSystem.create({
data: {
name,
},
select: {
id: true,
},
});
return id;
};

View File

@@ -1,7 +0,0 @@
"use server";
import { prisma } from "@/prisma/prismaClient";
// DEV TOOL ONLY
export async function deleteAllGameSystems() {
await prisma.gameSystem.deleteMany();
}

View File

@@ -1,23 +0,0 @@
"use server";
import { prisma } from "@/prisma/prismaClient";
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 { id } = await prisma.schema.create({
data: {
name,
schema: "{}",
types: "{}",
version: 0,
gameSystemId: gsId,
},
select: { id: true },
});
redirect(`/game-systems/${gsId}/schema/${id}`);
};

View File

@@ -1,20 +0,0 @@
"use server";
import { prisma } from "@/prisma/prismaClient";
export const findSchema = async (id: string) => {
const schema = await prisma.schema.findFirst({
where: {
id,
},
include: {
gameSystem: {
select: {
id: true,
name: true,
},
},
},
});
return schema;
};

130
actions/Schemas/index.ts Normal file
View File

@@ -0,0 +1,130 @@
"use server";
import { auth } from "@/auth";
import { isEmailVerified } from "@/util/isEmailVerified";
import { redirect } from "next/navigation";
import { prisma } from "@/prisma/prismaClient";
import { Schema } from "@/types";
export const saveSchemaDb = async (s: Schema, version: number) => {
const sesh = await auth();
if (!sesh?.user?.id) return;
const { id, SchemaRevision } = await prisma.schema.upsert({
// data: {
// ...s,
// },
create: {
name: s.name,
SchemaRevision: {
create: {
fields: s.fields,
types: s.types,
},
},
authorId: sesh.user.id,
gameSystemId: s.gameSystemId,
id: undefined,
},
update: {
name: s.name,
},
where: {
id: s.id,
},
include: {
// id: true,
SchemaRevision: {
where: {
version: s.version,
},
select: {
version: true,
isFinal: true,
},
},
},
});
// const schema2 = await prisma.schema.findUnique({where:{id}})
if (
!SchemaRevision.at(0) ||
SchemaRevision[0].version < version ||
SchemaRevision[0].isFinal
) {
await prisma.schemaRevision.create({
data: {
schemaId: id,
types: s.types,
fields: s.fields,
version,
},
});
}
redirect(`/game-systems/${s.gameSystemId}/schema/${id}`);
};
export const findSchema = async (
id: string,
version: number,
): Promise<Schema | null> => {
const schema = await prisma.schema.findFirst({
where: {
id,
},
// include: {
// gameSystem: {
// select: {
// id: true,
// name: true,
// },
// },
// },
include: {
SchemaRevision: {
where: {
version,
},
select: {
version: true,
fields: true,
types: true,
},
},
},
// select: {
// id: true,
// name: true,
// },
});
if (!schema?.SchemaRevision[0]) return null;
return {
fields: schema.SchemaRevision[0].fields,
types: schema.SchemaRevision[0].types,
id: schema.id,
gameSystemId: schema.gameSystemId,
name: schema.name,
} as Schema;
};
export const createSchema = async (form: FormData) => {
const name = form.get("name")?.toString();
const gsId = form.get("gsId")?.toString();
const session = await auth();
if (!name || !gsId || !session?.user?.id || !isEmailVerified(session.user.id))
return;
const { id } = await prisma.schema.create({
data: {
name,
gameSystemId: gsId,
authorId: session.user.id,
},
select: { id: true },
});
redirect(`/game-systems/${gsId}/schema/${id}`);
};

14
actions/auth/index.ts Normal file
View File

@@ -0,0 +1,14 @@
"use server";
import { auth, signIn, signOut } from "@/auth";
export const signInWithDiscord = async () => {
await signIn("discord");
};
export const signInWithCreds = async (formData: FormData) => {
await signIn("credentials", formData);
};
export const signOutOfApp = () => signOut();
export const getSession = async () => await auth();

View File

@@ -0,0 +1,3 @@
import { handlers } from "@/auth";
export const { GET, POST } = handlers;

View File

@@ -5,12 +5,5 @@ import { FC, use } from "react";
export const HomeClient: FC<{ body: Promise<string> }> = ({ body }) => {
const text = use(body);
return (
<TTCMD
body={text}
parserId="home"
title="home"
/>
);
return <TTCMD body={text} parserId="home" title="home" />;
};

View File

@@ -1,18 +1,21 @@
// import { setCurrentGameSystem } from "@/actions/GameSystems/client";
import { setCurrentGameSystem } from "@/actions/GameSystems/client";
import { auth } from "@/auth";
import { prisma } from "@/prisma/prismaClient";
import Link from "next/link";
export default async function GameSystem(
{ params: { id } }: { params: { id: string } },
) {
export default async function GameSystem({
params: { id },
}: {
params: { id: string };
}) {
if (!id) throw "HOW DID YOU GET HERE?";
const gameSystem = await prisma.gameSystem.findFirst({
where: {
id,
},
select: {
id: true,
name: true,
include: {
schemas: {
select: {
name: true,
@@ -26,8 +29,16 @@ export default async function GameSystem(
},
},
},
// select: {
// id: true,
// name: true,
// },
});
const session = await auth();
session?.user?.id && (await setCurrentGameSystem(session.user.id, id));
return (
<>
<section className="heading">

View File

@@ -1,6 +1,6 @@
"use client";
import { deleteAllGameSystems } from "@/actions/GameSystems/deleteAll";
import { deleteAllGameSystems } from "@/actions/GameSystems/devactions";
import { DevTool } from "@/components/devtools/DevTool";
import { useRouter } from "next/navigation";
import { FC, PropsWithChildren } from "react";

View File

@@ -1,20 +1,22 @@
import { prisma } from "@/prisma/prismaClient";
"use client";
import { createGameSystem } from "@/actions/GameSystems";
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"

View File

@@ -2,8 +2,12 @@ import { prisma } from "@/prisma/prismaClient";
import CreateGameSystem from "./create";
import { GameSystemsClient } from "./client";
import Link from "next/link";
import { setCurrentGameSystem } from "@/actions/GameSystems/client";
import { auth } from "@/auth";
export default async function GameSystems() {
const session = await auth();
session?.user?.id && (await setCurrentGameSystem(session.user.id));
const existingGameSystems = await prisma.gameSystem.findMany({
orderBy: {
created: "asc",

View File

@@ -11,7 +11,7 @@
}
input,
select {
@apply py-2 px-4 rounded-full dark:bg-mixed-200 bg-mixed-600 placeholder:text-dark-500;
@apply py-2 px-4 rounded-lg dark:bg-mixed-200 bg-mixed-600 placeholder:text-dark-500;
}
textarea {
@apply dark:bg-mixed-200 bg-primary-600 rounded-md p-1;
@@ -48,7 +48,7 @@
}
.btn {
@apply rounded-full;
@apply rounded-lg;
}
.btn-primary {
@apply dark:bg-primary-500 bg-primary-100 py-4 px-6 dark:text-mixed-100 text-white font-bold text-lg btn;
@@ -93,6 +93,23 @@
}
}
@layer utilities {
.fade-toast {
animation: fadeOut 300ms forwards;
}
.separated-list > li:not(:last-child) {
@apply border-b border-mixed-600 w-full;
}
.fade-menu {
animation: fadeIn 100ms forwards;
}
.fade-menu[data-closing="true"] {
animation: fadeOut 100ms forwards;
}
}
@keyframes identifier {
}
@@ -102,3 +119,20 @@
list-style: square;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

View File

@@ -1,16 +1,17 @@
import type { Metadata } from "next";
import { Roboto } from "next/font/google";
import "./globals.css";
import {
BookOpenIcon,
CircleStackIcon,
Cog8ToothIcon,
PuzzlePieceIcon,
QuestionMarkCircleIcon,
} from "@heroicons/react/24/solid";
import Link from "next/link";
import { DevToolboxContextProvider } from "@/components/devtools/context";
import { RecoilRootClient } from "@/components/recoilRoot";
import { JotaiProvider } from "@/components/jotaiProvider";
import { Toaster } from "@/components/toast";
import { SessionProvider } from "next-auth/react";
import { User } from "@/components/user/index";
import { getCurrentGameSystem } from "@/actions/GameSystems";
import { Nav } from "@/components/nav";
import { SSE } from "@/components/sse";
import { auth } from "@/auth";
const roboto = Roboto({ subsets: ["latin"], weight: "400" });
@@ -19,71 +20,42 @@ export const metadata: Metadata = {
description: "Rules and tools for tabletop games!",
};
export default function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const navItems = [
{
to: "/game-systems",
icon: PuzzlePieceIcon,
text: "Game Systems",
},
{
to: "/schemas",
icon: CircleStackIcon,
text: "Schemas",
},
{
to: "/publications",
icon: BookOpenIcon,
text: "Publications",
},
{
to: "/settings",
icon: Cog8ToothIcon,
text: "Settings",
},
{
to: "/help",
icon: QuestionMarkCircleIcon,
text: "How do?",
},
];
const currentGame = await getCurrentGameSystem();
return (
<html lang="en">
<body className={roboto.className + " flex min-h-[100vh]"}>
<nav className="h-[100vh] sticky top-0 left-0 bottom-0 p-8 rounded-r-3xl dark:bg-mixed-300 bg-primary-400 w-max shadow-2xl">
<h1 className="text-lg font-bold pb-6 border-b dark:border-dark-500 border-primary-600">
<Link href="/">Tabletop Commander</Link>
</h1>
<ul className="my-6 flex flex-col gap-6">
{navItems.map((n) => (
<li key={"nav-item" + n.text}>
<Link
href={n.to}
className="flex items-center gap-2 group hover:text-purple-300 transition-colors"
>
<n.icon className="w-6 h-6 group-hover:fill-purple-300 transition-colors" />
{n.text}
</Link>
</li>
))}
</ul>
</nav>
<RecoilRootClient>
<DevToolboxContextProvider
isDev={process.env.NODE_ENV !== "production"}
>
<main className="p-8 w-full overflow-visible">
{children}
</main>
</DevToolboxContextProvider>
</RecoilRootClient>
<div id="root-portal"></div>
</body>
<SessionProvider>
<body className={roboto.className + " flex min-h-[100vh]"}>
<nav className="h-[100vh] sticky top-0 left-0 bottom-0 p-8 rounded-r-3xl dark:bg-mixed-300 bg-primary-400 w-max shadow-2xl">
<div className="flex flex-col h-full">
<h1 className="text-lg font-bold pb-6 border-b dark:border-dark-500 border-primary-600">
<Link href="/">Tabletop Commander</Link>
</h1>
<Nav game={currentGame ?? undefined} />
<div className="mt-auto">
<User />
</div>
</div>
</nav>
<RecoilRootClient>
<JotaiProvider>
<DevToolboxContextProvider
isDev={process.env.NODE_ENV !== "production"}
>
<main className="p-8 w-full overflow-visible">{children}</main>
<Toaster />
</DevToolboxContextProvider>
</JotaiProvider>
</RecoilRootClient>
<div id="root-portal"></div>
</body>
</SessionProvider>
<SSE />
</html>
);
}

View File

@@ -11,8 +11,7 @@ export default function Home() {
<h2 className="strapline">Tabletop Commander</h2>
<h1>How does it work?</h1>
</section>
{
/* <section className="w-full my-6">
{/* <section className="w-full my-6">
<div className="card">
<p>
Tabletop Commander (TC) is a rules-and-tools app for tabletop games
@@ -131,8 +130,7 @@ export default function Home() {
will be done. If this makes it to production, tell Emma she forgot to
turn the home page into magic
</cite>
</section> */
}
</section> */}
<Suspense fallback={<MDSkeletonLoader />}>
<HomeClient body={body} />
</Suspense>

19
app/sign-in/page.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { auth } from "@/auth";
import SignIn from "@/components/signIn";
import { redirect } from "next/navigation";
async function SignInUp() {
const session = await auth();
if (session?.user) redirect("/");
return (
<div className="grid place-items-center h-full">
<div>
<SignIn />
</div>
</div>
);
}
export default SignInUp;

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

@@ -1,6 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect class="anvil-base" x="4.5" y="9.5" width="10" height="2" rx="0.5" stroke="inherit" fill="none" />
<path class="anvil-body"
d="M6 1H2C3 2 5.5 2.5 7 3C8.5 3.5 8.54315 4.2918 7 6L6 7V8H13V7C13 7 11.5 6 11 5C10.5 4 11 2.5 15 2V0.5H6V1Z"
stroke="inherit" fill="none" />
<rect class="prefix__anvil-base" x="4.5" y="9.5" width="10" height="2" rx=".5" stroke="#000" />
<path class="prefix__anvil-body" d="M6 1H2c1 1 3.5 1.5 5 2s1.543 1.292 0 3L6 7v1h7V7s-1.5-1-2-2 0-2.5 4-3V.5H6V1z"
stroke="#000" />
</svg>

Before

Width:  |  Height:  |  Size: 385 B

After

Width:  |  Height:  |  Size: 342 B

5
assets/icons/Discord.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-discord"
viewBox="0 0 16 16">
<path
d="M13.545 2.907a13.2 13.2 0 0 0-3.257-1.011.05.05 0 0 0-.052.025c-.141.25-.297.577-.406.833a12.2 12.2 0 0 0-3.658 0 8 8 0 0 0-.412-.833.05.05 0 0 0-.052-.025c-1.125.194-2.22.534-3.257 1.011a.04.04 0 0 0-.021.018C.356 6.024-.213 9.047.066 12.032q.003.022.021.037a13.3 13.3 0 0 0 3.995 2.02.05.05 0 0 0 .056-.019q.463-.63.818-1.329a.05.05 0 0 0-.01-.059l-.018-.011a9 9 0 0 1-1.248-.595.05.05 0 0 1-.02-.066l.015-.019q.127-.095.248-.195a.05.05 0 0 1 .051-.007c2.619 1.196 5.454 1.196 8.041 0a.05.05 0 0 1 .053.007q.121.1.248.195a.05.05 0 0 1-.004.085 8 8 0 0 1-1.249.594.05.05 0 0 0-.03.03.05.05 0 0 0 .003.041c.24.465.515.909.817 1.329a.05.05 0 0 0 .056.019 13.2 13.2 0 0 0 4.001-2.02.05.05 0 0 0 .021-.037c.334-3.451-.559-6.449-2.366-9.106a.03.03 0 0 0-.02-.019m-8.198 7.307c-.789 0-1.438-.724-1.438-1.612s.637-1.613 1.438-1.613c.807 0 1.45.73 1.438 1.613 0 .888-.637 1.612-1.438 1.612m5.316 0c-.788 0-1.438-.724-1.438-1.612s.637-1.613 1.438-1.613c.807 0 1.451.73 1.438 1.613 0 .888-.631 1.612-1.438 1.612" />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

80
auth/index.ts Normal file
View File

@@ -0,0 +1,80 @@
import { PrismaAdapter } from "@auth/prisma-adapter";
import { PrismaClient } from "@prisma/client";
import NextAuth from "next-auth";
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(async () => {
const sClient = SecretClient();
const clientId = await sClient.fetchSecret("discord_client_id");
const clientSecret = await sClient.fetchSecret("discord_client_secret");
return {
providers: [
Discord({
clientId,
clientSecret,
// redirectProxyUrl:
// "https://bottomsurgery.local:3000/api/auth/callback/discord",
}),
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,
},
select: {
name: true,
image: true,
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;
},
}),
],
adapter: PrismaAdapter(prisma),
};
});
async function saltAndHashPassword(password: string) {
const hash = await bcrypt.hash(password, 10);
return hash;
}

View File

@@ -1,23 +1,184 @@
import { FC } from "react";
import Help from "../../assets/icons/Help Icon.svg";
import Trash from "../../assets/icons/Trash Icon.svg";
import Trash_hover from "../../assets/icons/Trash Icon Open.svg";
import Anvil from "../../assets/icons/Anvil Icon.svg";
const Help = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
stroke="#fff"
fill="#fff"
height="28"
width="28"
>
<circle
stroke-width="3"
fill="none"
stroke="currentColor"
r="12.5"
cy="14"
cx="14"
/>
<path
fill="inherit"
d="M12.004 17.816v-.867c0-.531.074-1 .223-1.406.148-.414.386-.805.714-1.172.328-.375.762-.758 1.301-1.148.485-.344.871-.653 1.16-.926.297-.274.512-.543.645-.809.14-.273.21-.582.21-.925 0-.508-.187-.895-.562-1.16-.375-.266-.898-.4-1.57-.4s-1.34.106-2.004.317c-.656.211-1.324.489-2.004.832L8.84 7.586c.781-.438 1.629-.79 2.543-1.055.914-.273 1.914-.41 3-.41 1.672 0 2.965.402 3.879 1.207.922.797 1.382 1.813 1.382 3.047 0 .656-.105 1.227-.316 1.71a4.165 4.165 0 01-.937 1.337c-.414.406-.934.836-1.559 1.289-.469.344-.828.633-1.078.867-.25.235-.422.469-.516.703a2.356 2.356 0 00-.129.832v.703zm-.375 4.008c0-.734.2-1.25.598-1.547.406-.297.894-.445 1.464-.445.555 0 1.032.148 1.43.445.406.297.61.813.61 1.547 0 .703-.204 1.211-.61 1.524-.398.312-.875.468-1.43.468-.57 0-1.058-.156-1.464-.468-.399-.313-.598-.82-.598-1.524z"
/>
</svg>
);
const Trash = () => (
<svg
className="trash-can"
width="35"
height="30"
viewBox="0 0 22 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2 7H20V27C20 28.1046 19.1046 29 18 29H4C2.89543 29 2 28.1046 2 27V7Z"
stroke="currentColor"
fill="none"
stroke-width="2"
/>
<path
d="M6 11L6 24"
stroke="currentColor"
fill="none"
stroke-width="2"
stroke-linecap="round"
/>
<path
d="M11 11V24"
stroke="currentColor"
fill="none"
stroke-width="2"
stroke-linecap="round"
/>
<path
d="M16 11V24"
stroke="currentColor"
fill="none"
stroke-width="2"
stroke-linecap="round"
/>
<path
className="trash-lid"
d="M9 0.5H13C13.2761 0.5 13.5 0.723858 13.5 1V2.5H8.5V1C8.5 0.723858 8.72386 0.5 9 0.5Z"
stroke="currentColor"
fill="none"
/>
<path
className="trash-lid"
d="M2 2.5H20C20.8284 2.5 21.5 3.17157 21.5 4V6.5H0.5V4C0.5 3.17157 1.17157 2.5 2 2.5Z"
stroke="currentColor"
fill="none"
/>
</svg>
);
const Trash_hover = () => (
<svg
width="35"
height="35"
viewBox="0 0 23 35"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 12H21V32C21 33.1046 20.1046 34 19 34H5C3.89543 34 3 33.1046 3 32V12Z"
stroke="currentColor"
fill="none"
stroke-width="2"
/>
<path
d="M7 16L7 29"
stroke="currentColor"
fill="none"
stroke-width="2"
stroke-linecap="round"
/>
<path
d="M12 16V29"
stroke="currentColor"
fill="none"
stroke-width="2"
stroke-linecap="round"
/>
<path
d="M17 16V29"
stroke="currentColor"
fill="none"
stroke-width="2"
stroke-linecap="round"
/>
<path
d="M8.59244 1.36064L12.5317 0.666048C12.8036 0.618097 13.063 0.799681 13.1109 1.07163L13.3714 2.54884L8.44734 3.41708L8.18687 1.93987C8.13891 1.66792 8.3205 1.40859 8.59244 1.36064Z"
stroke="currentColor"
fill="none"
/>
<path
d="M2.05644 4.54394L19.783 1.41827C20.5988 1.27442 21.3768 1.81917 21.5207 2.63501L21.9548 5.09703L1.27382 8.74365L0.8397 6.28163C0.695845 5.46578 1.2406 4.6878 2.05644 4.54394Z"
stroke="currentColor"
fill="none"
/>
</svg>
);
const Anvil = () => (
<svg
width="16"
height="16"
viewBox="0 0 16 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
className="prefix__anvil-base"
x="4.5"
y="9.5"
width="10"
height="2"
rx=".5"
stroke="currentColor"
/>
<path
className="prefix__anvil-body"
d="M6 1H2c1 1 3.5 1.5 5 2s1.543 1.292 0 3L6 7v1h7V7s-1.5-1-2-2 0-2.5 4-3V.5H6V1z"
stroke="currentColor"
/>
</svg>
);
const Discord = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-discord"
viewBox="0 0 16 16"
>
<path d="M13.545 2.907a13.2 13.2 0 0 0-3.257-1.011.05.05 0 0 0-.052.025c-.141.25-.297.577-.406.833a12.2 12.2 0 0 0-3.658 0 8 8 0 0 0-.412-.833.05.05 0 0 0-.052-.025c-1.125.194-2.22.534-3.257 1.011a.04.04 0 0 0-.021.018C.356 6.024-.213 9.047.066 12.032q.003.022.021.037a13.3 13.3 0 0 0 3.995 2.02.05.05 0 0 0 .056-.019q.463-.63.818-1.329a.05.05 0 0 0-.01-.059l-.018-.011a9 9 0 0 1-1.248-.595.05.05 0 0 1-.02-.066l.015-.019q.127-.095.248-.195a.05.05 0 0 1 .051-.007c2.619 1.196 5.454 1.196 8.041 0a.05.05 0 0 1 .053.007q.121.1.248.195a.05.05 0 0 1-.004.085 8 8 0 0 1-1.249.594.05.05 0 0 0-.03.03.05.05 0 0 0 .003.041c.24.465.515.909.817 1.329a.05.05 0 0 0 .056.019 13.2 13.2 0 0 0 4.001-2.02.05.05 0 0 0 .021-.037c.334-3.451-.559-6.449-2.366-9.106a.03.03 0 0 0-.02-.019m-8.198 7.307c-.789 0-1.438-.724-1.438-1.612s.637-1.613 1.438-1.613c.807 0 1.45.73 1.438 1.613 0 .888-.637 1.612-1.438 1.612m5.316 0c-.788 0-1.438-.724-1.438-1.612s.637-1.613 1.438-1.613c.807 0 1.451.73 1.438 1.613 0 .888-.631 1.612-1.438 1.612" />
</svg>
);
const library = {
Help,
Trash,
Trash_hover,
Anvil,
Discord,
};
interface IProps {
className: string;
className?: string;
icon: keyof typeof library;
}
export const Icon: FC<IProps> = ({ className, icon }) => {
const ICON = library[icon];
return <ICON className={className} />;
return (
<span className={className}>
<ICON />
</span>
);
};

View File

@@ -1,6 +1,6 @@
import { FC, PropsWithChildren } from "react";
import { Poppable } from "@/lib/poppables/components/poppable";
import { Icon } from "@/components/Icon";
import { QuestionMarkCircleIcon } from "@heroicons/react/24/solid";
export const HelpPopper: FC<PropsWithChildren> = ({ children }) => {
return (
@@ -9,7 +9,7 @@ export const HelpPopper: FC<PropsWithChildren> = ({ children }) => {
preferredAlign="centered"
preferredEdge="bottom"
>
<Icon icon="Help" className="svg-white w-4 h-4" />
<QuestionMarkCircleIcon className="w-4 h-4 fill-white" />
</Poppable>
);
};

View File

@@ -0,0 +1,7 @@
"use client";
import { Provider } from "jotai";
export function JotaiProvider(props: React.PropsWithChildren) {
return <Provider>{props.children}</Provider>;
}

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

@@ -2,13 +2,14 @@ import { FC, useCallback, useEffect, useState } from "react";
import { useObjectStateWrapper } from "../../hooks/useObjectState";
import { ValueField } from "./value-field";
import { HelpPopper } from "../Poppables/help";
import { Icon } from "../Icon";
import { RESERVED_FIELDS } from "../../constants/ReservedFields";
import {
fieldTypeOptions,
FieldTypes,
fieldTypesWithValues,
} from "./fieldtypes";
import { TrashIcon } from "@heroicons/react/24/solid";
import { FieldType } from "@/types";
interface IProps {
update: (arg: FieldType) => void;
@@ -17,9 +18,12 @@ interface IProps {
deleteField: (arg: string) => void;
}
export const FieldEditor: FC<IProps> = (
{ update, field, fieldName, deleteField },
) => {
export const FieldEditor: FC<IProps> = ({
update,
field,
fieldName,
deleteField,
}) => {
const { bindProperty, bindPropertyCheck } = useObjectStateWrapper(
field,
(e) => update(typeof e === "function" ? e(field) : e),
@@ -83,9 +87,8 @@ export const FieldEditor: FC<IProps> = (
)}
<span className="flex items-center gap-2">
<label>
<input type="checkbox" {...bindPropertyCheck("isConstant")} />
{" "}
Is constant
<input type="checkbox" {...bindPropertyCheck("isConstant")} /> Is
constant
</label>
<HelpPopper>
<p className="text-sm">
@@ -108,7 +111,11 @@ export const FieldEditor: FC<IProps> = (
</label>
<label className="w-min">
Limit:
<input className="w-12 min-w-min" type="number" {...bindProperty("limit")} />
<input
className="w-12 min-w-min"
type="number"
{...bindProperty("limit")}
/>
</label>
<HelpPopper>
<p className="text-sm">
@@ -122,11 +129,7 @@ export const FieldEditor: FC<IProps> = (
className="no-default self-end ml-auto"
onClick={() => deleteField(fieldName)}
>
<Icon
className="svg-red-700 hover:svg-red-500 trash-can w-6 h-6"
icon="Trash"
>
</Icon>
<TrashIcon className="w-6 h-6 fill-white" />
</button>
</div>
)}

View File

@@ -1,14 +1,15 @@
import { useRecoilValue } from "recoil";
import { SchemaEditAtom } from "../../recoil/atoms/schema";
import { TEMPLATE_TYPES } from "../../constants/TemplateTypes";
import { FC, PropsWithChildren } from "react";
import { useAtom } from "jotai";
import { InputBinder } from "@/types";
interface IProps {
bind: InputBinder;
}
export const FieldTypeInput: FC<PropsWithChildren<IProps>> = ({ bind }) => {
const schema = useRecoilValue(SchemaEditAtom);
const [schema] = useAtom(SchemaEditAtom);
return (
<label className="w-min">

View File

@@ -1,52 +1,79 @@
"use client";
import { FC, useCallback, useState } from "react";
import { FC, useCallback, useEffect, useState } from "react";
import AnimatedPageContainer from "@/components/AnimatedPageContainer";
import { TypeEditor } from "./type-editor";
import { useObjectStateWrapper } from "@/hooks/useObjectState";
import { useInput } from "../../hooks/useInput";
import { useRecoilState, useResetRecoilState } from "recoil";
import { SchemaEditAtom } from "@/recoil/atoms/schema";
import { SchemaViewer } from "./schema-viewer";
import { TemplateEditor } from "./template-editor";
import { Icon } from "@/components/Icon";
import { useParams } from "next/navigation";
import { FieldTypes } from "./fieldtypes";
import { findSchema, saveSchemaDb } from "@/actions/Schemas/index";
import { useToast } from "../toast";
import { useAtom } from "jotai";
import { Schema, TypeType } from "@/types";
import { TrashIcon } from "@heroicons/react/24/solid";
import { PencilSquareIcon } from "@heroicons/react/24/solid";
export const SchemaBuilder: FC = () => {
const [schema, setSchema] = useRecoilState(SchemaEditAtom);
const resetSchema = useResetRecoilState(SchemaEditAtom);
const [schema, setSchema] = useAtom<Schema>(SchemaEditAtom);
// const resetSchema = useResetRecoilState(SchemaEditAtom);
const { createToast } = useToast();
const { update: updateSchema, bindProperty: bindSchemaProperty } =
useObjectStateWrapper<Schema>(schema, setSchema);
const { schemaId } = useParams<{ schemaId: string }>();
const { schemaId, id: gameSystemId } = useParams<{
schemaId: string;
id: string;
}>();
const { value: typeName, bind: bindTypeName, reset: resetTypeName } =
useInput("");
useEffect(() => {
if (schemaId !== "create" && schemaId !== schema.id)
findSchema(schemaId, 0).then((sc) => {
if (!sc) return;
setSchema(sc);
});
}, [schema.id, schemaId, setSchema]);
useEffect(() => {
if (gameSystemId && !schema.gameSystemId)
setSchema((sc) => ({ ...sc, gameSystemId }));
}, [gameSystemId, schema.gameSystemId, setSchema]);
const {
value: typeName,
bind: bindTypeName,
reset: resetTypeName,
} = useInput("");
const [pageNumber, setPageNumber] = useState(0);
const [lastSaved, setLastSaved] = useState(schema);
const [lastSaved, _setLastSaved] = useState(schema);
const [selectedType, setSelectedType] = useState("");
const saveType = useCallback((name: string, type: TypeType) => {
updateSchema((e) => ({
types: {
...e.types,
[name]: type,
},
}));
resetTypeName();
setPageNumber(0);
setSelectedType("");
}, [resetTypeName, updateSchema]);
const saveType = useCallback(
(name: string, type: TypeType) => {
updateSchema((e) => ({
types: {
...e.types,
[name]: type,
},
}));
resetTypeName();
setPageNumber(0);
setSelectedType("");
},
[resetTypeName, updateSchema],
);
const saveSchema = useCallback(async () => {
setLastSaved(schema);
// const sid = await GameSystemsService.saveSchema(schema);
// if (schemaId === 'new') navigate('/schema/'+sid)
}, [schema]);
createToast({ msg: "Saving Schema", fading: true });
await saveSchemaDb(schema, schema.version);
}, [createToast, schema]);
const selectTypeForEdit = useCallback((typeKey: string) => {
setSelectedType(typeKey);
@@ -60,33 +87,36 @@ export const SchemaBuilder: FC = () => {
} = useInput("", { disallowSpaces: true });
const addSchemaField = useCallback(() => {
updateSchema((s) => ({
schema: {
...s.schema,
[schemaFieldName]: {
display: "",
type: FieldTypes.any,
},
fields: {
...s.fields,
[schemaFieldName]: FieldTypes.any,
},
}));
resetSchemaFieldName();
}, [resetSchemaFieldName, schemaFieldName, updateSchema]);
const updateSchemaField = useCallback((key: string, template: Template) => {
updateSchema((s) => ({
schema: {
...s.schema,
[key]: template,
},
}));
}, [updateSchema]);
const updateSchemaField = useCallback(
(key: string, fieldType: FieldTypes) => {
updateSchema((s) => ({
fields: {
...s.fields,
[key]: fieldType,
},
}));
},
[updateSchema],
);
const deleteType = useCallback((key: string) => {
updateSchema((s) => {
const types = { ...s.types };
delete types[key];
return { types };
});
}, [updateSchema]);
const deleteType = useCallback(
(key: string) => {
updateSchema((s) => {
const types = { ...s.types };
delete types[key];
return { types };
});
},
[updateSchema],
);
return (
<div className="flex gap-4 p-8">
@@ -107,16 +137,16 @@ export const SchemaBuilder: FC = () => {
</button>
</div>
<ul className="rounded-lg overflow-hidden">
{Object.entries(schema.schema).map((
[schemaFieldKey, schemaField],
) => (
<TemplateEditor
key={schemaFieldKey}
templateKey={schemaFieldKey}
template={schemaField}
update={updateSchemaField}
/>
))}
{Object.entries(schema.fields).map(
([schemaFieldKey, schemaField]) => (
<TemplateEditor
key={schemaFieldKey}
templateKey={schemaFieldKey}
fieldType={schemaField as FieldTypes}
update={updateSchemaField}
/>
),
)}
</ul>
</div>
<hr />
@@ -136,9 +166,11 @@ export const SchemaBuilder: FC = () => {
<TypeEditor
name={selectedType || typeName}
saveType={saveType}
type={selectedType
? schema.types[selectedType as keyof typeof schema.types]
: undefined}
type={
selectedType
? schema.types[selectedType as keyof typeof schema.types]
: undefined
}
/>
</AnimatedPageContainer>
<ul className="mt-3 w-96">
@@ -154,20 +186,14 @@ export const SchemaBuilder: FC = () => {
className="no-default"
onClick={() => selectTypeForEdit(t)}
>
<Icon
icon="Anvil"
className="anvil svg-olive-drab hover:svg-olive-drab-100 w-6 h-6"
/>
<PencilSquareIcon className="w-6 h-6 fill-white" />
</button>
<button
title="Delete"
className="no-default"
onClick={() => deleteType(t)}
>
<Icon
icon="Trash"
className="trash-can svg-red-700 hover:svg-red-500 w-6 h-6"
/>
<TrashIcon className="w-6 h-6 fill-white" />
</button>
</div>
</li>

View File

@@ -34,10 +34,10 @@ export const SchemaViewer: FC<IProps> = ({ schema, onTypeClick }) => {
<hr />
<p className="font-bold italic">Templates</p>
<ul>
{Object.entries(schema.schema).map(([templateKey, template]) => (
{Object.entries(schema.fields).map(([templateKey, template]) => (
<li key={templateKey}>
<p className="font-bold">{templateKey}</p>
<p className="font-thin text-xs">{template.type}</p>
<p className="text-mixed-600 ml-2">Type: {template}</p>
</li>
))}
</ul>

View File

@@ -1,39 +1,38 @@
import { FC, useCallback } from "react";
import { useObjectStateWrapper } from "@/hooks/useObjectState";
import { FC, useCallback, useEffect } from "react";
import { TEMPLATE_TYPES } from "@/constants/TemplateTypes";
import { SchemaEditAtom } from "@/recoil/atoms/schema";
import { useRecoilState } from "recoil";
import { Icon } from "@/components/Icon";
import { FieldTypes } from "./fieldtypes";
import { useAtom } from "jotai";
import { useInput } from "@/hooks/useInput";
import { Schema } from "@/types";
import { TrashIcon } from "@heroicons/react/24/solid";
interface IProps {
templateKey: string;
update: (arg0: string, arg1: Template) => void;
template: Template;
update: (arg0: string, arg1: FieldTypes) => void;
fieldType: FieldTypes;
}
export const TemplateEditor: FC<IProps> = (
{ templateKey, update, template },
) => {
const [schema, setSchema] = useRecoilState(SchemaEditAtom);
const updateTemplate = useCallback(
(t: Template | ((arg: Template) => Template)) => {
update(templateKey, typeof t === "function" ? t(template) : t);
},
[templateKey, update, template],
);
export const TemplateEditor: FC<IProps> = ({
templateKey,
update,
fieldType,
}) => {
const [schema, setSchema] = useAtom(SchemaEditAtom);
const { bind: bindFieldType, value } = useInput(fieldType);
const { bindProperty } = useObjectStateWrapper(
template,
updateTemplate,
);
useEffect(() => {
update(templateKey, value);
}, []);
const deleteTemplate = useCallback(() => {
const deleteField = useCallback(() => {
setSchema((s: Schema) => {
const templates = { ...s.schema };
delete templates[templateKey];
const fields = { ...s.fields };
delete fields[templateKey];
return {
...s,
schema: templates,
schema: fields,
};
});
}, [setSchema, templateKey]);
@@ -46,28 +45,25 @@ export const TemplateEditor: FC<IProps> = (
Type:
<input
type="text"
{...bindProperty("type", { disallowSpaces: true })}
{...bindFieldType}
list="type-editor-type-list"
/>
<datalist id="type-editor-type-list">
{Object.keys(TEMPLATE_TYPES).map((k) => (
<option key={"templatetype" + k} value={k}>{k}</option>
<option key={"templatetype" + k} value={k}>
{k}
</option>
))}
{Object.keys(schema.types).map((k) => (
<option key={"schematype" + k} value={k}>{k}</option>
<option key={"schematype" + k} value={k}>
{k}
</option>
))}
</datalist>
</label>
<textarea {...bindProperty("display")} cols={30} rows={10}></textarea>
</div>
<button
className="no-default"
onClick={deleteTemplate}
>
<Icon
icon="Trash"
className="svg-red-700 hover:svg-red-500 trash-can w-6 h-6"
/>
<button className="no-default" onClick={deleteField}>
<TrashIcon className="w-6 h-6 fill-white" />
</button>
</div>
</li>

View File

@@ -9,6 +9,7 @@ import { useObjectState } from "../../hooks/useObjectState";
import { useInput } from "../../hooks/useInput";
import { FieldEditor } from "./field-editor";
import { FieldTypes } from "./fieldtypes";
import { FieldType, TypeType } from "@/types";
interface IProps {
name: string;
@@ -18,9 +19,11 @@ interface IProps {
const constantProperties = ["metadata"];
export const TypeEditor: FC<PropsWithChildren<IProps>> = (
{ saveType, name, type: passedType },
) => {
export const TypeEditor: FC<PropsWithChildren<IProps>> = ({
saveType,
name,
type: passedType,
}) => {
const {
update: updateType,
reset: resetType,
@@ -39,19 +42,22 @@ export const TypeEditor: FC<PropsWithChildren<IProps>> = (
resetType();
};
const addField = useCallback((e: FormEvent) => {
e.preventDefault();
updateType({
[propertyName]: {
type: FieldTypes.number,
value: "",
isConstant: false,
limit: 1,
minimum: 1,
},
});
resetPropertyName();
}, [propertyName, updateType, resetPropertyName]);
const addField = useCallback(
(e: FormEvent) => {
e.preventDefault();
updateType({
[propertyName]: {
type: FieldTypes.number,
value: "",
isConstant: false,
limit: 1,
minimum: 1,
},
});
resetPropertyName();
},
[propertyName, updateType, resetPropertyName],
);
const updateField = useCallback(
(k: keyof typeof type) => (field: FieldType) => {
@@ -64,13 +70,16 @@ export const TypeEditor: FC<PropsWithChildren<IProps>> = (
passedType && setType(passedType);
}, [passedType, setType]);
const deleteField = useCallback((name: string) => {
setType((t) => {
const fields = { ...t };
delete fields[name];
return fields;
});
}, [setType]);
const deleteField = useCallback(
(name: string) => {
setType((t) => {
const fields = { ...t };
delete fields[name];
return fields;
});
},
[setType],
);
return (
<div>
@@ -82,17 +91,18 @@ export const TypeEditor: FC<PropsWithChildren<IProps>> = (
<button disabled={!propertyName}>Add Field</button>
</form>
<ul className="rounded-lg overflow-hidden">
{Object.entries(type).reverse().filter(([k]) =>
!constantProperties.includes(k)
).map(([key, value]) => (
<FieldEditor
key={"field-editor" + key}
field={value}
update={updateField(key)}
fieldName={key}
deleteField={deleteField}
/>
))}
{Object.entries(type)
.reverse()
.filter(([k]) => !constantProperties.includes(k))
.map(([key, value]) => (
<FieldEditor
key={"field-editor" + key}
field={value}
update={updateField(key)}
fieldName={key}
deleteField={deleteField}
/>
))}
</ul>
<div>
<button onClick={save} disabled={!Object.keys(type).length}>

44
components/signIn.tsx Normal file
View File

@@ -0,0 +1,44 @@
import { signInWithCreds, signInWithDiscord } from "@/actions/auth";
import { Icon } from "./Icon";
export default function SignIn() {
return (
<div className="flex flex-col gap-2">
<form action={signInWithCreds} className="flex flex-col gap-2">
<input
className="w-full"
placeholder="email"
type="email"
name="email"
/>
<input
className="w-full"
placeholder="password"
type="password"
name="password"
/>
<button
role="button"
type="submit"
className="w-full p-2 rounded-lg bg-primary-500"
>
Sign In
</button>
</form>
<div className="flex items-center gap-1">
<div className="dark:border-dark-500 border-primary-600 flex-grow border-b"></div>
<div className="dark:text-dark-500 text-primary-600 ">or</div>
<div className="dark:border-dark-500 border-primary-600 flex-grow border-b"></div>
</div>
<form action={signInWithDiscord}>
<button
className="w-full p-2 bg-[#816ab1] rounded-lg flex items-center justify-center"
type="submit"
>
<Icon icon="Discord" className="mr-4 inline-block" />
Sign in with Discord
</button>
</form>
</div>
);
}

111
components/toast/index.tsx Normal file
View File

@@ -0,0 +1,111 @@
"use client";
import { Portal } from "@/lib/portal/components";
import { atom, useAtom } from "jotai";
import React, { useCallback, useEffect, useState } from "react";
import { ReactNode } from "react";
type toastMessage = {
msg: ReactNode;
type?: "error" | "default";
fading: boolean;
duration?: number;
};
type IDToastMessage = toastMessage & {
id: string;
};
const toastAtom = atom<IDToastMessage[]>([]);
export function useToast() {
const [_, setToasts] = useAtom(toastAtom);
const createToast = useCallback(
(t: toastMessage) => {
const idd = { ...t, id: crypto.randomUUID() };
setToasts((toasts) => {
return [...toasts, idd];
});
return idd;
},
[setToasts]
);
const clearToast = useCallback(
(t: toastMessage) => setToasts((toasts) => toasts.filter((to) => to != t)),
[setToasts]
);
return {
createToast,
clearToast,
};
}
export function Toaster() {
const [toasts, setToasts] = useAtom(toastAtom);
const clearToast = useCallback(
(t: toastMessage) => {
setToasts((toasts) => {
return toasts.filter((to) => to !== t);
});
},
[setToasts]
);
if (!toasts.length) return <></>;
return (
<Portal>
<div className="fixed bottom-12 left-1/2 -translate-x-1/2 max-w-[95vw] flex flex-col gap-4">
{toasts.map((t) => (
<Toast key={"toast " + t.id} toast={t} clearToast={clearToast} />
))}
</div>
</Portal>
);
}
function Toast(props: {
toast: toastMessage;
clearToast: (t: toastMessage) => void;
}) {
const { toast, clearToast } = props;
const [fading, setFading] = useState(false);
const clear = useCallback(() => {
setFading(true);
setTimeout(() => {
clearToast(toast);
}, 300);
}, [clearToast, toast]);
const fadeOut = useCallback(() => {
setTimeout(clear, toast.duration ?? 3000);
}, [clear, toast]);
useEffect(() => {
if (!toast.fading) return;
fadeOut();
}, [fadeOut, toast]);
return (
<div
data-fading={fading}
data-type={toast.type}
className="relative p-6 px-16 toast data-[fading=true]:fade-toast rounded-md bg-mixed-300 data-[type=error]:bg-red-900 border-2 border-mixed-400 data-[type=error]:border-red-700"
>
{toast.msg}
{!toast.fading && (
<button
className="top-2 right-2 text-xs absolute"
onClick={() => clear()}
>
Dismiss
</button>
)}
</div>
);
}

33
components/user/index.tsx Normal file
View File

@@ -0,0 +1,33 @@
/* eslint-disable @next/next/no-img-element */
import { auth } from "@/auth";
import { UserCircleIcon } from "@heroicons/react/24/solid";
import { FC } from "react";
import { UserMenu } from "./menu";
export const User: FC = async () => {
const session = await auth();
return (
<UserMenu signedIn={!!session?.user}>
<div className="flex gap-2 items-center">
{session?.user?.image ? (
<img
src={session.user.image}
alt="user avatar"
className="rounded-full w-12"
/>
) : (
<span className="w-12 h-12 inline-block">
<UserCircleIcon className="w-full h-full" />
</span>
)}
{session?.user?.name ? (
<span>Hey there, {session.user.name}!</span>
) : (
<a className="block flex-grow h-full" href="/sign-in">
Sign In
</a>
)}
</div>
</UserMenu>
);
};

50
components/user/menu.tsx Normal file
View File

@@ -0,0 +1,50 @@
"use client";
import { signOutOfApp } from "@/actions/auth";
import { FC, PropsWithChildren, useCallback, useState } from "react";
export const UserMenu: FC<PropsWithChildren<{ signedIn: boolean }>> = ({
children,
signedIn,
}) => {
const [visible, setVisible] = useState(false);
const [closing, setClosing] = useState(true);
const toggle = useCallback(() => {
setClosing((c) => !c);
setTimeout(
() => {
setVisible((v) => !v);
},
visible ? 100 : 0
);
}, [visible]);
return (
<div
onClick={signedIn ? toggle : undefined}
className="relative bg-mixed-200 p-2 rounded-lg cursor-pointer w-[220px]"
>
{visible && (
<div
data-closing={closing}
className="absolute bottom-full left-0 right-0 fade-menu"
>
<ul className="separated-list w-full">
<li>
<a className="block p-2" href="/profile">
Profile
</a>
</li>
<li>
<button className="p-2" onClick={() => signOutOfApp()}>
Sign Out
</button>
</li>
</ul>
</div>
)}
{children}
</div>
);
};

View File

@@ -6,17 +6,21 @@ interface IProps {
title?: ReactNode;
}
export const Accordion: FC<PropsWithChildren<IProps>> = (
{ children, expandOnHover, expanded, title },
) => {
export const Accordion: FC<PropsWithChildren<IProps>> = ({
children,
expandOnHover,
expanded,
title,
}) => {
const [open, setOpen] = useState(false);
return (
<div
data-expanded={open || expanded}
data-expandonhover={expandOnHover}
className={(expandOnHover ? "group/hover" : "group/controlled") +
" group"}
className={
(expandOnHover ? "group/hover" : "group/controlled") + " group"
}
onClick={() => !title && !expandOnHover && setOpen(!open)}
>
{!!title && (
@@ -24,9 +28,7 @@ export const Accordion: FC<PropsWithChildren<IProps>> = (
className="flex justify-between cursor-pointer"
onClick={() => !expandOnHover && setOpen(!open)}
>
<div className="accordion-title">
{title}
</div>
<div className="accordion-title">{title}</div>
<div
className={`
group-hover/hover:-rotate-180
@@ -41,10 +43,8 @@ export const Accordion: FC<PropsWithChildren<IProps>> = (
scale-y-50
`}
>
<span className="block w-2 h-2 rotate-45 border-r-2 border-b-2 place-self-center">
</span>
<span className="block w-2 h-2 rotate-45 border-r-2 border-b-2 place-self-center">
</span>
<span className="block w-2 h-2 rotate-45 border-r-2 border-b-2 place-self-center"></span>
<span className="block w-2 h-2 rotate-45 border-r-2 border-b-2 place-self-center"></span>
</div>
</div>
)}
@@ -64,15 +64,15 @@ export const AccordionContent: FC<PropsWithChildren> = ({ children }) => {
}
}, []);
const Child = () => (
<div className="absolute bottom-0 w-full" ref={updateRef}>
{children}
</div>
);
return (
<div className="relative overflow-hidden">
{<Child />}
<div
key={"accordion-content"}
className="absolute bottom-0 w-full"
ref={updateRef}
>
{children}
</div>
<span
style={{ ["--v-height" as never]: height + "px" }}
className="w-0 block h-0 group-hover/hover:h-variable group-data-[expanded]/controlled:h-variable transition-all duration-700"

113
lib/dice.ts Normal file
View File

@@ -0,0 +1,113 @@
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) {
const [c, s] = dice.split(/[dD]/);
this.count = Number(c);
this.sides = Number(s);
}
public roll() {
let results = [];
for (let i = 0; i < this.count; i++) {
results.push(this.rollSingle());
}
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() {
return Math.ceil(Math.random() * this.sides);
}
public rollAvg() {
return this.roll().total / this.count;
}
public rollTimes(times: number) {
let total = 0;
for (let i = 0; i < times; i++) {
total += this.roll().total;
}
return total;
}
public rollTimesAvg(times: number) {
return this.rollTimes(times) / times;
}
public getNormalizedRollDistribution(): Record<number, number> {
const distribution: Record<number, number> = this.computeDistribution();
// Normalize the distribution
const totalOutcomes = Math.pow(this.sides, this.count);
for (const sum in distribution) {
if (distribution.hasOwnProperty(sum)) {
distribution[sum] /= totalOutcomes;
}
}
return distribution;
}
public getRollDistribution(): Record<number, number> {
return this.computeDistribution();
}
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> = {};
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,6 +1,13 @@
"use client";
import { FC, PropsWithChildren, ReactNode, useCallback, useState } from "react";
import {
FC,
PropsWithChildren,
ReactNode,
useCallback,
useRef,
useState,
} from "react";
import { PoppableContent } from "./poppable-content";
import { useDebounce } from "../../../hooks/useDebounce";
@@ -12,38 +19,45 @@ interface IProps {
spacing?: number;
}
export const Poppable: FC<PropsWithChildren<IProps>> = (
{ className, content, children, preferredEdge, preferredAlign, spacing },
) => {
export const Poppable: FC<PropsWithChildren<IProps>> = ({
className,
content,
children,
preferredEdge,
preferredAlign,
spacing,
}) => {
const [isHovered, setIsHovered] = useState(false);
const closing = useDebounce(!isHovered, 1000);
const closed = useDebounce(closing, 300);
const [ref, setRef] = useState<HTMLElement>();
// const [ref, setRef] = useState<HTMLElement>();
const updateRef = useCallback((node: HTMLElement) => {
if (!node) return;
setRef(node);
}, []);
// const updateRef = useCallback((node: HTMLElement) => {
// if (!node) return;
// setRef(node);
// }, []);
const ref = useRef(null);
return (
<>
<span
ref={updateRef}
ref={ref}
className={className}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{children}
</span>
{!!ref && (
{!!ref.current && (
<PoppableContent
preferredAlign={preferredAlign}
preferredEdge={preferredEdge}
spacing={spacing}
isClosing={closing}
isClosed={closed}
relativeElement={ref}
relativeElement={ref.current}
setHover={setIsHovered}
>
{content}

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
@@ -18,9 +20,9 @@ export class DHSecretClient {
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 +45,23 @@ export class DHSecretClient {
return token;
}
// 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");
// }
private readDiskCache() {
try {
const cache = readFileSync(this.cacheLocation, "utf-8");
this.cache = JSON.parse(cache || "{}");
} catch {
this.cache = {};
this.writeDiskCache().then(this.readDiskCache);
}
}
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 +79,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;

77
lib/tcmd/Resolver.tsx Normal file
View File

@@ -0,0 +1,77 @@
import { PublicationAtom } from "@/recoil/atoms/publication";
import { useState, useEffect, useCallback, useRef, ReactNode } from "react";
import { TTCQueryResolver } from "../ttcQuery/TTCResolvers";
import { useAtom } from "jotai";
export function Resolver({ resolver }: { resolver: string }) {
const [parser] = useAtom(PublicationAtom);
const [res] = useState(new TTCQueryResolver(parser));
const [content, setContent] = useState<ReactNode>("");
useEffect(() => {
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,
title,
}: {
resolver: string;
template: string;
title?: string;
}) {
const [parser] = useAtom(PublicationAtom);
const res = useRef(new TTCQueryResolver(parser));
const [content, setContent] = useState<ReactNode>("");
const generateContent = useCallback(() => {
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(() => {
res.current.resolve(resolver, true);
generateContent();
}, [res, resolver, generateContent]);
return (
<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

@@ -3,12 +3,15 @@ import Link from "next/link";
import React, { Fragment } from "react";
import { Poppable } from "../poppables/components/poppable";
import { Accordion, AccordionContent } from "../accordion";
import { OnDemandResolver, Resolver } from "./Resolver";
// import "crypto";
export const TokenRenderers = new Map<string, TokenRenderer<any>>();
export function buildIdentifierMap(): [
TokenIdentifierMap,
IdentifierRegistration
IdentifierRegistration,
] {
const TokenIdentifiers = new Map<string, TokenIdentifier<any>>();
@@ -16,7 +19,7 @@ export function buildIdentifierMap(): [
type: string,
match: RegExp,
parseFunction: (s: string, rx: RegExp) => IdentifiedToken<M>,
renderFunction: TokenRenderer<M>
renderFunction: TokenRenderer<M>,
): void;
function registerIdentifier<M>(
type: string,
@@ -24,7 +27,7 @@ export function buildIdentifierMap(): [
parseFunction: (s: string, rx: RegExp) => IdentifiedToken<M>,
renderFunction: TokenRenderer<M>,
openTagRx: RegExp,
closeTagRx: RegExp
closeTagRx: RegExp,
): void;
function registerIdentifier<M = Record<string, string>>(
type: string,
@@ -32,7 +35,7 @@ export function buildIdentifierMap(): [
parseFunction: (s: string, rx: RegExp) => IdentifiedToken<M>,
renderFunction: TokenRenderer<M>,
openTagRx?: RegExp,
closeTagRx?: RegExp
closeTagRx?: RegExp,
) {
TokenIdentifiers.set(type, {
rx: match,
@@ -53,7 +56,7 @@ export function buildIdentifierMap(): [
start,
end,
new RegExp(openTagRx, "g"),
new RegExp(closeTagRx, "g")
new RegExp(closeTagRx, "g"),
);
}
: undefined,
@@ -123,8 +126,8 @@ export const buildOnlyDefaultElements = () => {
</div>
);
},
/(?<!\/)(?:\[\])+/g,
/\/\[\]/g
/(?<![\/\?])(?:\[\])+/g,
/\/\[\]/g,
);
// card
@@ -169,7 +172,7 @@ export const buildOnlyDefaultElements = () => {
);
},
/\[\[/g,
/\]\]/g
/\]\]/g,
);
// fenced code block
@@ -191,13 +194,13 @@ export const buildOnlyDefaultElements = () => {
{token.content}
</pre>
);
}
},
);
// list
registerIdentifier(
"list",
/(?<=\n\n|^) *-\s([\s\S]*?)(?=\n\n|$)/g,
/(?<=\n\n?|^) *-\s([\s\S]*?)(?=\n\n|$)/g,
(s, rx) => {
return {
content: s.match(new RegExp(rx, ""))?.at(0) || "Unable to parse list",
@@ -230,7 +233,7 @@ export const buildOnlyDefaultElements = () => {
</ul>
</>
);
}
},
);
// ordered-list
@@ -238,7 +241,6 @@ export const buildOnlyDefaultElements = () => {
"ordered-list",
/(?<=\n\n|^)\s*\d+\.\s([\s\S]*?)(?=\n\n|$)/g,
(s, rx) => {
// debugger;
return {
content:
s.match(new RegExp(rx, ""))?.at(0) || "Unable to parse ordered list",
@@ -253,7 +255,6 @@ export const buildOnlyDefaultElements = () => {
},
(token) => {
const { children } = token;
debugger;
return (
<>
<ol
@@ -272,7 +273,7 @@ export const buildOnlyDefaultElements = () => {
</ol>
</>
);
}
},
);
// ordered list-item
@@ -301,7 +302,7 @@ export const buildOnlyDefaultElements = () => {
))}
</li>
);
}
},
);
// heading
@@ -337,7 +338,7 @@ export const buildOnlyDefaultElements = () => {
{token.content}
</div>
);
}
},
);
// image
@@ -374,7 +375,7 @@ export const buildOnlyDefaultElements = () => {
}
// eslint-disable-next-line @next/next/no-img-element
return <img src={metadata.src} alt={token.content} />;
}
},
);
// anchor
@@ -418,7 +419,7 @@ export const buildOnlyDefaultElements = () => {
{token.content}
</Link>
);
}
},
);
// inline-code
@@ -441,7 +442,7 @@ export const buildOnlyDefaultElements = () => {
{token.content}
</span>
);
}
},
);
// bold
@@ -459,7 +460,7 @@ export const buildOnlyDefaultElements = () => {
},
(token) => {
return <span className="font-bold">{token.content}</span>;
}
},
);
// italic
@@ -478,7 +479,7 @@ export const buildOnlyDefaultElements = () => {
},
(token) => {
return <span className="italic">{token.content}</span>;
}
},
);
// popover
@@ -514,9 +515,10 @@ export const buildOnlyDefaultElements = () => {
</span>
</Poppable>
);
}
},
);
// accordion
registerIdentifier(
"accordion",
/\[accordion(\s.*?)?]\n+((?:.|\n)*?)\n+\[\/accordion\]/g,
@@ -543,11 +545,13 @@ export const buildOnlyDefaultElements = () => {
</Accordion>
</div>
);
}
},
);
// paragraph
registerIdentifier(
"p",
// /(?<=\n\n|^)([\s\S]*?)(?=\n\n|$)/g,
/(?<=\n\n)([\s\S]*?)(?=\n\n)/g,
(s) => {
return {
@@ -567,9 +571,10 @@ export const buildOnlyDefaultElements = () => {
})}
</div>
);
}
},
);
// horizontal rule
registerIdentifier(
"hr",
/^-{3,}$/gm,
@@ -584,9 +589,10 @@ export const buildOnlyDefaultElements = () => {
},
() => {
return <div className="w-full border-b border-mixed-500 my-3"></div>;
}
},
);
// comment
registerIdentifier(
"comment",
/<!--[\s\S]+?-->/g,
@@ -601,9 +607,10 @@ export const buildOnlyDefaultElements = () => {
},
() => {
return <></>;
}
},
);
// frontmatter
registerIdentifier(
"frontmatter",
/^---([\s\S]*?)---/g,
@@ -619,9 +626,10 @@ export const buildOnlyDefaultElements = () => {
},
(token) => {
return <>{token.raw}</>;
}
},
);
// table
registerIdentifier(
"table",
/(?<=\n|^)\| [\s\S]*? \|(?=(\n|$)(?!\|))/g,
@@ -649,8 +657,8 @@ export const buildOnlyDefaultElements = () => {
r
.split("|")
.map((c) => c.trim())
.filter((c) => !!c)
)
.filter((c) => !!c),
),
);
let headerRows: string[][] = [],
@@ -673,7 +681,7 @@ export const buildOnlyDefaultElements = () => {
}
const maxColumns = Math.max(
...[...headerRows, ...bodyRows, ...footerRows].map((r) => r.length)
...[...headerRows, ...bodyRows, ...footerRows].map((r) => r.length),
);
return {
@@ -764,7 +772,76 @@ export const buildOnlyDefaultElements = () => {
)}
</table>
);
}
},
);
// resolver
registerIdentifier(
"resolver",
/\?\?<<(.*?)>>/g,
(s) => {
const inp = s.match(/(?<=<<)(.*?)(?=>>)/)![0];
if (inp == undefined)
return {
content: "Error parsing resolver: " + s,
metadata: {},
raw: "ERROR",
uuid: crypto.randomUUID(),
};
return {
content: inp,
metadata: {},
raw: s,
uuid: crypto.randomUUID(),
};
},
(t) => {
if (t.content.startsWith("Error"))
return <span className="red-500">{t.content}</span>;
return <Resolver resolver={t.content} />;
},
);
// on-demand resolver
registerIdentifier(
"on-demand resolver",
/\?\?\[.*?\](\(.*?\))<<(.*?)>>/g,
(s) => {
const inp = s.match(/(?<=<<)(.*?)(?=>>)/)![0];
const title = s.match(/(?<=\?\?\[)(.*?)(?=\])/)![0];
const template = s.match(/(?<=\]\()(.*?)(?=\))/)![0];
if (inp == undefined)
return {
content: "Error parsing resolver: " + s,
metadata: {
title: "",
template: "",
},
raw: "ERROR",
uuid: crypto.randomUUID(),
};
return {
content: inp,
metadata: {
title,
template,
},
raw: s,
uuid: crypto.randomUUID(),
};
},
(t) => {
if (t.content.startsWith("Error"))
return <span className="red-500">{t.content}</span>;
return (
<OnDemandResolver
resolver={t.content}
template={t.metadata.template}
title={t.metadata.title}
/>
);
},
);
return TokenIdentifiers;
@@ -773,7 +850,7 @@ export const buildOnlyDefaultElements = () => {
function findMatchingClosedParenthesis(
str: string,
openRegex: RegExp,
closedRegex: RegExp
closedRegex: RegExp,
): number | null {
let openings = 0;
let closings = 0;
@@ -829,7 +906,7 @@ function search(
start: number,
end: number,
openRx: RegExp,
closeRx: RegExp
closeRx: RegExp,
): SearchResult {
const oldEnd = end;
@@ -837,10 +914,11 @@ function search(
s,
// s.substring(0, end - start),
openRx,
closeRx
closeRx,
);
if (newEnd === null) throw Error("There was an issue finding a closing tag");
if (newEnd === null)
throw Error("There was an issue finding a closing tag for ");
end = newEnd + start;
@@ -856,7 +934,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

@@ -5,14 +5,16 @@ import { buildOnlyDefaultElements, TokenRenderers } from "./TokenIdentifiers";
export const createElements = (body: string): Token[] => {
const tokens = tokenize(body);
console.log(tokens);
return buildAbstractSyntaxTree(tokens).map((t) => t.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);
@@ -177,9 +179,9 @@ const contentToChildren = (token: Token) => {
},
]
: undefined,
})
}),
),
token.children || []
token.children || [],
).filter((c) => c.children?.length || (c.rendersContentOnly && c.content));
};
@@ -270,7 +272,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,6 +1,4 @@
type QueryableObject = Record<string, any>;
class TtcQueryParser {
export class TTCQueryParser {
private data: QueryableObject;
private relativeMap: Map<QueryableObject, QueryableObject>;
@@ -26,24 +24,12 @@ class TtcQueryParser {
} else if (query.startsWith("$")) {
result = this.queryRelativeObject(query, currentObject);
} else {
result = this.queryPublication(query);
result = this.searchInObject(this.data, query);
}
return result;
}
private queryPublication(query: string): any[] {
// Example implementation for searching publication
const publicationMatch = query.match(/^(\w+)(\[(\w+)\])?(\..+)?/);
if (publicationMatch) {
const subQuery = publicationMatch[4];
// Search within the publication data
return this.searchInObject(this.data, subQuery);
}
return [];
}
private queryCurrentObject(
query: string,
currentObject: QueryableObject
@@ -109,7 +95,9 @@ class TtcQueryParser {
return [];
}
} else {
current = current.map((e: any) => e[key]);
current = Array.isArray(current)
? current.map((e: any) => e[key])
: current[key];
}
} else {
return [];
@@ -159,19 +147,3 @@ class TtcQueryParser {
}
}
}
// Example usage:
const data = {
relative: true,
weapon_abilities: [
{ name: "Rapid Fire", body: "Shoot again" },
{ name: "Sustained Hits", body: "More damage", relative: true },
],
};
const parser = new TtcQueryParser(data);
// Example queries
console.log(parser.search("weapon_abilities[name=Rapid Fire].body")); // Example output
// console.log(parser.search("^weapon_abilities[name=Rapid Fire]", data)); // Example output
console.log(parser.search("$weapon_abilities[name=Rapid Fire].body", data)); // Example output

View File

@@ -0,0 +1,204 @@
/* 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: StackItem[] = [];
constructor(parser?: TTCQueryParser) {
this.parser = parser || null;
}
public setParser(parser: TTCQueryParser) {
this.parser = parser;
}
public setContext(obj: QueryableObject) {
this.context = obj;
}
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;
} catch (e) {
return { value: e?.toString(), display: e?.toString() };
}
}
private parseResolver(resolver: string): StackItem {
if (this.isArithmetic(resolver)) return this.solveArithmetic(resolver);
if (this.isQuery(resolver)) return this.runQuery(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 (
resolver.startsWith("?") ||
resolver.startsWith("_") ||
/^\$\d\./.test(resolver)
);
}
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 (stackItem.value instanceof Dice) {
return {
value: query,
display: this.handleDice(stackItem.value, q),
};
}
const [res] = this.parser.search(q, stackItem.value as QueryableObject);
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("_"))
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): StackItem {
const [n1, op, n2] = resolver
.match(/(\$?\d+)([+\-*\/^])(\$?\d+)/)
?.slice(1) || ["", "+", ""];
let num1: number = Number(n1),
num2: number = Number(n2);
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(stackIndex: string): StackItem {
if (stackIndex === "$x") return this.stack.at(-1)!;
const i = Number(stackIndex.replace("$", ""));
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,12 +2,12 @@
title: How to use ttcMD
author: Emmaline Autumn
date: March 14th, 2024
updated: March 14th, 2024
updated: August 21st, 2024
---
# Table of Contents
- [Table of Contents](#table-of-contents)
- [What even is ttcMD?](#what-even-is-ttcmd)
- [What even is ttcMD?](#what-even-is-ttcmd)
- [Enhanced Standard Elements](#enhanced-standard-elements)
- [Links](#links)
- [Tables](#tables)
@@ -26,19 +26,25 @@ updated: March 14th, 2024
---
# What even is ttcMD?
This help article contains a lot of examples of how to use the syntax of ttcMD and what they look like when put into practice. It's very information dense and also a bit chaotic with all of the examples, so please feel free to use the table of contents to find the specific section you are looking for.
## What even is ttcMD?
ttcMD is a flavor of markdown that has been specifically designed to use with [ttcQuery](/help/ttcQuery.md). It has all of the basic syntax of [markdown](https://www.markdownguide.org/cheat-sheet/), but also includes Tables, basic Fenced Code Blocks and a slew of custom elements and styling annotations.
One thing to note, however, is that ttcMD is *not* standard markdown. It does not generate valid markup for just about any element, and as such it will not be released in any scope wider than inside TTC itself. One could more accurately call it a layout language than actual markdown.
---
## Enhanced Standard Elements
This section will cover all of the enhancements that are added for basic markdown elements
---
### Links
You can use the typical link syntax `[link name](/link/location)`, but there are a few presets that allow you to style them to look a bit nicer.
You can use the typical link syntax `[link name](/link/location)` but there are a few presets that allow you to style them to look a bit nicer.
**Primary Button:**
@@ -54,6 +60,8 @@ You can use the typical link syntax `[link name](/link/location)`, but there are
[~~cta link name](#links)
---
### Tables
Generally tables will only be as wide as their content needs. To make a table take up the full width, you can use this syntax:
@@ -116,10 +124,14 @@ Full width with a centered body column
/[]
---
## Custom Elements
This section will cover the specific elements custom built for Tabletop Commander.
---
### Pop-outs
Pop-outs, or popovers, are the little cards that "pop out" when you hover over an item.
@@ -132,10 +144,14 @@ This syntax `^[This is my favorite image]<<*Goofy!* ![goofy](https:///example.co
Note: currently, only inline elements are available, so formatting is limited
---
## Block-level Elements
Block-level elements have a slightly different syntax than the single-line and inline elements we've seen so far. In order to use block-level elements, they *must* be formatted correctly, including the empty lines. As a general rule, you cannot nest block-level elements within themselves, but you can nest different block-level elements within it.
---
### Accordions
Accordions are when you can click an item to expand it to show additional information.
@@ -202,6 +218,8 @@ You'll never believe me when I tell you that this legitimately is one of my favo
]]
/[]
---
### Card
Cards are just neat boxes. They can hold any markdown within them, but not other cards (it looks bad).
@@ -246,6 +264,8 @@ Additionally, you can specify a number after the opening brackets (`[[2 ... ]]`)
/[]
---
### Block
[][][]
@@ -271,6 +291,7 @@ Additionally, you can specify a number after the opening brackets (`[[!2 ... ]]`
/[]
---
### Grid
@@ -317,12 +338,16 @@ This card will end up in the third column...
/[]
---
## Query Elements
The following elements are used in combination with ttcQuery. These are definitely more advanced. If you understand generally what "dot notation" is in programming, then it will be a lot easier, but don't let that deter you. Once you understand what it is, you can come back to this and be able to create really cool layouts!
Query elements (aside for the on-demand resolver) are calculated before parsing the markdown. Will that matter to you? Probably not, but could be necessary as you think about how you are writing your query elements.
---
### Resolver
The resolver is the basic element that allows you to get data, but it has a lot of functionality to it. It has not been fully implemented yet
@@ -331,21 +356,33 @@ Syntax: `??<<List -> QueryValue>>`
If you've read the ttcQuery docs, you'll know that the `List` type means that you can input any number of `QueryValues` in this element. When the resolver runs, it runs each `QueryValue` from left to right. The last `QueryValue` to run is what gets rendered, but you have access to previous values through their index by using the `$#` variables.
As `QueryValues` are capable of arithmetic, you can quite easily do simple math inline, though it's unfortunately not quite as simple as just writing an equation. Each `QueryValue` can only do one arithmetic operation. This is to prevent performance issues with parsing arbitrary calculations. So, if you wanted to get the average of the values of 1, 2, 3 and 4, the query would look like this: `??<<1+2,3+4,$0+$1,$2/4>>` which will result in the value 2.5. Arithmetic will fail if a value provided is not a number and will render a message in the markdown.
As `QueryValues` are capable of arithmetic, you can quite easily do simple math inline, though it's unfortunately not quite as simple as just writing an equation. Each `QueryValue` can only do one arithmetic operation. This is to prevent performance issues with parsing arbitrary calculations. So, if you wanted to get the average of the values of 1, 2, 3 and 4, the query would look like this: `??<<1+2,3+4,$0+$1,$2/4>>` which will result in the value ??<<1+2,3+4,$0+$1,$2/4>>. Arithmetic will fail if a value provided is not a number and will render a message in the markdown.
If the resolver results in a list of items, it will list all of them together, separated by commas.
Let's say you want to get the the result of rolling a dice field. You would simply write `$$<<_.path.to.dice,$0.roll>>`. This will roll the dice when the markdown is render, which means it only happens once. If you want to reroll the dice, you either need to reload the markdown by closing the viewer and reopening it, or you need to use an On-demand Resolver.
Let's say you want to get the the result of rolling a dice field. You would simply write `??<<_.path.to.dice,$0.roll>>` and get the result of the dice roll like this: ??<<_.path.to.dice,$0.roll>>. This will roll the dice when the markdown is rendered, which means it only happens once. If you want to reroll the dice, you either need to reload the markdown by closing the viewer and reopening it, or you need to use an On-demand Resolver.
---
### On-demand Resolver
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]<<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.
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]<<_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>>`
For drawing a card and having it show the card's display: `??[]<<_path.to.deck,$0.draw,$1.display>>`
??[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>>`
]]
/[]
---
### Query Block Template
@@ -364,6 +401,8 @@ Syntax:
While in a Query Block Template, all resolvers will use the queried value as the root `_` variable. If the original query returns multiple values, it will render the markdown for each result. This is what makes it so useful. When you want to render your 40k army list, you can do so with very little markdown.
---
### Query Block
Similar to the Query Block Template, the query block collects the values of the query and passes it to the markdown inside of it. However, instead of rendering the whole block, it instead renders the markdown a single time. It has not been fully implemented yet

View File

@@ -1,4 +1,12 @@
- hello
- everybody
- yes you
- my
- name
- is
- welcome
1. hello
2. everybody
3. my
@@ -6,10 +14,7 @@
5. is
6. welcome
- hello
- everybody
- yes you
- my
- name
- is
- welcome
- [-\_hello1234!@#$%^\&\*()-](#-_hello1234-)
# -_hello1234!@#$%^&*()-

View File

@@ -1,13 +1,17 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
webpack(config) {
webpack(config, { isServer }) {
config.module.rules.push({
test: /\.svg$/i,
issuer: /\.[jt]sx?$/,
use: ["@svgr/webpack"],
});
if (isServer) {
import("./polyfills/customevent.js");
}
return config;
},
};

1
notmiddleware.ts Normal file
View File

@@ -0,0 +1 @@
export { auth as middleware } from "@/auth";

1578
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,17 +6,27 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"postinstall": "bun ./postinstall/index.ts"
},
"dependencies": {
"@auth/prisma-adapter": "^2.4.2",
"@codemirror/lang-markdown": "^6.2.5",
"@heroicons/react": "^2.1.1",
"@prisma/client": "^5.11.0",
"@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",
"next": "14.1.0",
"prisma": "^5.11.0",
"jotai": "^2.9.3",
"next": "^14.2.5",
"next-auth": "^5.0.0-beta.20",
"react": "^18",
"react-dom": "^18",
"recoil": "^0.7.7"
"recoil": "^0.7.7",
"url-loader": "^4.1.1"
},
"devDependencies": {
"@svgr/webpack": "^8.1.0",
@@ -27,6 +37,7 @@
"eslint": "^8",
"eslint-config-next": "14.1.0",
"postcss": "^8",
"prisma": "^5.18.0",
"tailwindcss": "^3.3.0",
"typescript": "^5",
"typescript-eslint": "^7.2.0"

22
postinstall/buildEnv.ts Normal file
View File

@@ -0,0 +1,22 @@
const { SecretClient } = require("../lib/secret/init");
const { writeFile } = require("fs/promises");
const requiredKeys = [
"discord_client_id",
"discord_client_secret",
"ttc:database_url",
];
const secretClient = SecretClient();
async function buildEnv() {
secretClient.fetchToken();
let secrets = "";
for (const key of requiredKeys) {
const value = await secretClient.fetchSecret(key);
secrets += `${key.replace("ttc:", "").toUpperCase()}=${value}\n`;
}
await writeFile(".env", secrets, "utf-8");
}
buildEnv();

1
postinstall/index.ts Normal file
View File

@@ -0,0 +1 @@
require("./buildEnv.ts");

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

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

@@ -0,0 +1,123 @@
/*
Warnings:
- You are about to drop the column `authorId` on the `Publication` table. All the data in the column will be lost.
- You are about to drop the column `data` on the `Publication` table. All the data in the column will be lost.
- You are about to drop the column `originalId` on the `Schema` table. All the data in the column will be lost.
- You are about to drop the column `schema` on the `Schema` table. All the data in the column will be lost.
- You are about to drop the column `types` on the `Schema` table. All the data in the column will be lost.
- You are about to drop the column `version` on the `Schema` table. All the data in the column will be lost.
- You are about to drop the `TagsOnPublications` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `TagsOnTags` table. If the table is not empty, all the data it contains will be lost.
- Added the required column `schemaVersion` to the `Publication` table without a default value. This is not possible if the table is not empty.
- Added the required column `publicationRevisionId` to the `Tag` table without a default value. This is not possible if the table is not empty.
- Made the column `publicationId` on table `Tag` required. This step will fail if there are existing NULL values in that column.
*/
-- DropForeignKey
ALTER TABLE "Publication" DROP CONSTRAINT "Publication_authorId_fkey";
-- DropForeignKey
ALTER TABLE "Tag" DROP CONSTRAINT "Tag_publicationId_fkey";
-- DropForeignKey
ALTER TABLE "TagsOnPublications" DROP CONSTRAINT "TagsOnPublications_publicationId_fkey";
-- DropForeignKey
ALTER TABLE "TagsOnPublications" DROP CONSTRAINT "TagsOnPublications_tagId_fkey";
-- DropForeignKey
ALTER TABLE "TagsOnTags" DROP CONSTRAINT "TagsOnTags_childTagId_fkey";
-- DropForeignKey
ALTER TABLE "TagsOnTags" DROP CONSTRAINT "TagsOnTags_parentTagId_fkey";
-- AlterTable
ALTER TABLE "GameSystem" ADD COLUMN "isPublic" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "Publication" DROP COLUMN "authorId",
DROP COLUMN "data",
ADD COLUMN "schemaVersion" INTEGER NOT NULL;
-- AlterTable
ALTER TABLE "Schema" DROP COLUMN "originalId",
DROP COLUMN "schema",
DROP COLUMN "types",
DROP COLUMN "version";
-- AlterTable
ALTER TABLE "Tag" ADD COLUMN "publicationRevisionId" TEXT NOT NULL,
ALTER COLUMN "publicationId" SET NOT NULL;
-- DropTable
DROP TABLE "TagsOnPublications";
-- DropTable
DROP TABLE "TagsOnTags";
-- CreateTable
CREATE TABLE "SchemaRevision" (
"id" TEXT NOT NULL,
"schemaId" TEXT NOT NULL,
"fields" JSONB NOT NULL DEFAULT '{}',
"types" JSONB NOT NULL DEFAULT '{}',
"version" INTEGER NOT NULL DEFAULT 0,
"isFinal" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "SchemaRevision_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PublicationRevision" (
"id" TEXT NOT NULL,
"previousId" TEXT,
"publicationId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"version" TEXT NOT NULL,
"isFinal" BOOLEAN NOT NULL DEFAULT false,
"data" JSONB NOT NULL DEFAULT '{}',
CONSTRAINT "PublicationRevision_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GameSystemFollows" (
"gameSystemId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "GameSystemFollows_pkey" PRIMARY KEY ("userId","gameSystemId")
);
-- CreateIndex
CREATE UNIQUE INDEX "SchemaRevision_schemaId_version_key" ON "SchemaRevision"("schemaId", "version");
-- CreateIndex
CREATE UNIQUE INDEX "PublicationRevision_publicationId_version_key" ON "PublicationRevision"("publicationId", "version");
-- AddForeignKey
ALTER TABLE "SchemaRevision" ADD CONSTRAINT "SchemaRevision_schemaId_fkey" FOREIGN KEY ("schemaId") REFERENCES "Schema"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Publication" ADD CONSTRAINT "Publication_schemaVersion_schemaId_fkey" FOREIGN KEY ("schemaVersion", "schemaId") REFERENCES "SchemaRevision"("version", "schemaId") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PublicationRevision" ADD CONSTRAINT "PublicationRevision_publicationId_fkey" FOREIGN KEY ("publicationId") REFERENCES "Publication"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PublicationRevision" ADD CONSTRAINT "PublicationRevision_previousId_fkey" FOREIGN KEY ("previousId") REFERENCES "PublicationRevision"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PublicationRevision" ADD CONSTRAINT "PublicationRevision_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("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;
-- AddForeignKey
ALTER TABLE "Tag" ADD CONSTRAINT "Tag_publicationRevisionId_fkey" FOREIGN KEY ("publicationRevisionId") REFERENCES "PublicationRevision"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GameSystemFollows" ADD CONSTRAINT "GameSystemFollows_gameSystemId_fkey" FOREIGN KEY ("gameSystemId") REFERENCES "GameSystem"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GameSystemFollows" ADD CONSTRAINT "GameSystemFollows_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

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

View File

@@ -1,67 +1,175 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
provider = "postgresql"
url = env("DATABASE_URL")
}
model GameSystem {
id String @id @default(cuid())
schemas Schema[]
author User @relation(fields: [authorId], references: [id])
authorId String
id String @id @default(cuid())
schemas Schema[]
author User @relation(fields: [authorId], references: [id])
authorId String
followers GameSystemFollows[]
name String @unique
created DateTime @default(now())
name String @unique
created DateTime @default(now())
isPublic Boolean @default(false)
thingUsers User[] @relation("currentGame")
}
model Schema {
id String @id @default(cuid())
gameSystem GameSystem @relation(fields: [gameSystemId], references: [id])
gameSystemId String
gameSystem GameSystem? @relation(fields: [gameSystemId], references: [id])
gameSystemId String?
publications Publication[]
author User @relation(fields: [authorId], references: [id])
authorId String
originalId String
name String
schema Json
types Json
version Int
name String
SchemaRevision SchemaRevision[]
}
model SchemaRevision {
id String @id @default(cuid())
schemaId String
parentSchema Schema @relation(fields: [schemaId], references: [id])
Publication Publication[]
fields Json @default("{}")
types Json @default("{}")
version Int @default(0)
isFinal Boolean @default(false)
@@unique([schemaId, version])
}
model Publication {
id String @id @default(cuid())
schema Schema @relation(fields: [schemaId], references: [id])
schemaId String
tags Tag[]
author User @relation(fields: [authorId], references: [id])
authorId String
id String @id @default(cuid())
schema Schema @relation(fields: [schemaId], references: [id])
schemaId String
schemaVersion Int
schemaRevision SchemaRevision @relation(fields: [schemaVersion, schemaId], references: [version, schemaId])
PublicationRevision PublicationRevision[]
tags Tag[]
name String
data Json
// TagsOnPublications TagsOnPublications[]
}
model PublicationRevision {
id String @id @default(cuid())
Tag Tag[]
previousId String?
publicationId String
publication Publication @relation(fields: [publicationId], references: [id])
previousRevision PublicationRevision? @relation(name: "downlineRevisions", fields: [previousId], references: [id])
downlineRevisions PublicationRevision[] @relation("downlineRevisions")
author User @relation(fields: [authorId], references: [id])
authorId String
version String
isFinal Boolean @default(false)
data Json @default("{}")
@@unique([publicationId, version])
}
// model TagsOnPublications {
// publication Publication @relation(fields: [publicationId], references: [id])
// publicationId String
// tagId String
// tag Tag @relation(fields: [tagId], references: [id])
// @@id([publicationId, tagId])
// }
model Tag {
id String @id @default(cuid())
publication Publication @relation(fields: [publicationId], references: [id])
publicationId String
id String @id @default(cuid())
name String
Publication Publication @relation(fields: [publicationId], references: [id])
publicationId String
PublicationRevision PublicationRevision @relation(fields: [publicationRevisionId], references: [id])
publicationRevisionId String
// TagsOnPublications TagsOnPublications[]
}
model User {
id String @id @default(cuid())
schemas Schema[]
gameSystems GameSystem[]
publications Publication[]
id String @id @default(cuid())
schemas Schema[]
gameSystems GameSystem[]
accounts Account[]
sessions Session[]
PublicationRevision PublicationRevision[]
followedSystems GameSystemFollows[]
currentGameSystem GameSystem? @relation("currentGame", fields: [currentGameSystemId], references: [id])
currentGameSystemId String?
username String
email String @unique
name String?
username String? @unique
email String? @unique
emailVerified DateTime?
passwordHash String?
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model GameSystemFollows {
gameSystemId String
gameSystem GameSystem @relation(fields: [gameSystemId], references: [id])
userId String
user User @relation(fields: [userId], references: [id])
@@id([userId, gameSystemId])
}
model Account {
id String @id @default(cuid())
userId String @unique
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
refresh_token_expires_in Int?
user User? @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([provider, providerAccountId])
@@index([userId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
}
model VerificationToken {
identifier String
token String
expires DateTime
@@unique([identifier, token])
}

View File

@@ -0,0 +1,14 @@
import { TTCQueryParser } from "@/lib/ttcQuery/TTCQueryParser";
import { atom } from "jotai";
// import { atom } from "recoil";
// export const PublicationAtom = atom({
// key: "publication",
// default: new TTCQueryParser({
// path: { to: { dice: "2d6" } },
// }),
// });
export const PublicationAtom = atom(
new TTCQueryParser({ path: { to: { dice: "2d6" } } }),
);

View File

@@ -1,6 +1,17 @@
import { atom } from "recoil";
// import { atom } from "recoil";
import { Schema } from "@/types";
import { atom } from "jotai";
// export const SchemaEditAtom = atom<Schema>({
// key: "schema-edit",
// default: { name: "", types: {}, schema: {}, id: "" },
// });
export const SchemaEditAtom = atom<Schema>({
key: "schema-edit",
default: { name: "", types: {}, schema: {}, id: "" },
name: "",
id: "",
types: {},
fields: {},
version: 0,
});

View File

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

View File

@@ -1,10 +1,6 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -22,22 +18,11 @@
}
],
"paths": {
"@/*": [
"./*"
]
"@/*": ["./*"]
},
"target": "es2022"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
],
"exclude": [
"node_modules"
],
"files": [
"global.d.ts"
]
}
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"],
"files": ["global.d.ts", "types.d.ts", "./components/mdeditor/TextEditor.tsx"]
}

15
types.d.ts vendored
View File

@@ -1,3 +1,5 @@
import { FieldTypes } from "./components/schema/fieldtypes";
// MD Parser
type IdentifiedToken<M> = {
metadata: M;
@@ -76,11 +78,17 @@ type Template = {
display: string;
};
type SchemaFields = Record<string, FieldTypes>;
type SchemaTypes = Record<string, TypeType>;
type Schema = {
id: string;
name: string;
schema: Record<string, Template>;
types: Record<string, TypeType>;
fields: SchemaFields;
types: SchemaTypes;
version: number;
gameSystemId?: string | null;
};
// Input Binder
@@ -91,3 +99,6 @@ type InputBinder = {
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>,
) => void;
};
// Query
type QueryableObject = Record<string, any>;

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