Compare commits

...

50 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
269a844a68 first version of query parser 2024-08-05 01:26:29 -06:00
7fc76d2781 adds user db model 2024-07-26 20:49:33 -06:00
1664d3bc7f schema: some schema editor fixes 2024-06-11 05:09:14 -06:00
e6880af3ee help: new help article, tweaks ttcmd article 2024-06-11 05:08:26 -06:00
71bf62b622 ttcMD: fixes for lists, adds ordered lists 2024-06-11 05:07:08 -06:00
3a5fe1911a ported schema builder 2024-03-19 14:49:50 -06:00
56f0442d33 game systems: game system pages, game system create
components: moved DevTool to client component
2024-03-19 11:20:15 -06:00
50e5ff0663 components: sticky component (draggable positionable box) 2024-03-19 11:19:29 -06:00
5654b5e15d database: setup of prisma 2024-03-19 01:45:24 -06:00
2f3f2fd81e dh secret manager 2024-03-17 09:33:27 -06:00
df20a47253 toolbox: adds devtoolbox to easily manage debug components 2024-03-16 10:15:03 -06:00
9cbd0a62ca ttcMD: Changes link decoration to ~~ instead of ``` 2024-03-16 06:53:48 -06:00
5e038ff5cf help article: includes tables and updated card/block logic 2024-03-16 04:08:06 -06:00
f6474f934c ttcMD: add col-span logic to blocks and cards 2024-03-16 04:07:26 -06:00
a72741e4d1 ttcMD: improves table with full width 2024-03-16 03:19:18 -06:00
2e7eaccde8 lint: updates lint rules for no-unused-vars 2024-03-16 02:12:20 -06:00
16497edd46 ttcMD: added a cleanup step to make sure that line endings are as we expect,
fixes p blocks not replacing line breaks with spaces leading to unintentional linguistic collisions
2024-03-16 00:06:22 -06:00
30a1c74711 help article: added section about query elements, added frontmatter on how to article,
added explanation of block elements
2024-03-15 12:22:07 -06:00
b2c7a35e8b ttcMD: initial table renderer 2024-03-15 12:20:42 -06:00
3c7ef5a185 ttcMD: allow for typing metadata 2024-03-15 10:02:46 -06:00
98 changed files with 14444 additions and 630 deletions

View File

@@ -1,3 +1,23 @@
{
"extends": "next/core-web-vitals"
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"next/core-web-vitals"
],
"rules": {
"@typescript-eslint/no-unused-vars": [
"error",
{
"args": "all",
"argsIgnorePattern": "^_",
"caughtErrors": "all",
"caughtErrorsIgnorePattern": "^_",
"destructuredArrayIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"ignoreRestSiblings": true
}
]
}
}

5
.gitignore vendored
View File

@@ -27,6 +27,7 @@ yarn-error.log*
# local env files
.env*.local
.env
# vercel
.vercel
@@ -40,3 +41,7 @@ next-env.d.ts
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

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

View File

@@ -0,0 +1,70 @@
// 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 };
}) {
if (!id) throw "HOW DID YOU GET HERE?";
const gameSystem = await prisma.gameSystem.findFirst({
where: {
id,
},
include: {
schemas: {
select: {
name: true,
id: true,
publications: {
select: {
name: true,
id: true,
},
},
},
},
},
// select: {
// id: true,
// name: true,
// },
});
const session = await auth();
session?.user?.id && (await setCurrentGameSystem(session.user.id, id));
return (
<>
<section className="heading">
<h2 className="strapline">Game System</h2>
<h1>{gameSystem?.name}</h1>
</section>
<section>
<>
<div>
<Link
className="btn-primary mb-6 block w-min whitespace-nowrap"
href={`/game-systems/${id}/schema/create`}
>
Create New Schema
</Link>
</div>
<ul>
{gameSystem?.schemas.map((schema) => (
<li key={schema.id}>{schema.name}</li>
))}
{!gameSystem?.schemas.length && (
<li>No schemas for {gameSystem?.name}</li>
)}
</ul>
</>
</section>
</>
);
}

View File

@@ -0,0 +1,21 @@
import { Heading } from "@/components/heading";
import { SchemaBuilder } from "@/components/schema";
import { prisma } from "@/prisma/prismaClient";
export default async function CreateSchemaForGameSystem(
{ params }: { params: { id: string } },
) {
const gs = await prisma.gameSystem.findFirst({
where: { id: params.id },
select: { name: true },
});
return (
<>
<Heading title={gs?.name || ""} strapline="Schemas" />
<section>
<SchemaBuilder></SchemaBuilder>
</section>
</>
);
}

View File

@@ -0,0 +1,26 @@
"use client";
import { deleteAllGameSystems } from "@/actions/GameSystems/devactions";
import { DevTool } from "@/components/devtools/DevTool";
import { useRouter } from "next/navigation";
import { FC, PropsWithChildren } from "react";
export const GameSystemsClient: FC<PropsWithChildren> = ({ children }) => {
const router = useRouter();
// DEV TOOL ONLY
async function deleteAll() {
await deleteAllGameSystems();
router.refresh();
}
return (
<>
<DevTool id="game-system-home">
<button onClick={deleteAll} className="btn-primary bg-lime-600">
Delete All Game Systems
</button>
</DevTool>
{children}
</>
);
};

View File

@@ -0,0 +1,36 @@
"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) {
const name = form.get("name")?.toString();
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}`);
}
return (
<form action={create}>
<input
type="text"
name="name"
placeholder="Create a new game system..."
className="w-min"
/>
<button className="btn-primary p-2 px-2 ml-2" type="submit">
Create
</button>
</form>
);
}

37
app/game-systems/page.tsx Normal file
View File

@@ -0,0 +1,37 @@
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",
},
});
return (
<GameSystemsClient>
<section className="heading">
<h2 className="strapline">Tabletop Commander</h2>
<h1>Game Systems</h1>
</section>
<section className="mb-6">
<CreateGameSystem />
</section>
<section className="">
<ul>
{existingGameSystems.map((g) => (
<li key={g.id} className="odd:bg-black/20 p-2 text-lg">
<Link href={`/game-systems/${g.id}`}>{g.name}</Link>
</li>
))}
</ul>
</section>
</GameSystemsClient>
);
}

View File

@@ -9,8 +9,12 @@
body {
@apply dark:bg-mixed-100 bg-primary-600;
}
input {
@apply py-2 px-4 rounded-full dark:bg-mixed-200 bg-mixed-600 placeholder:text-dark-500;
input,
select {
@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;
}
h1,
h2,
@@ -31,7 +35,7 @@
}
.heading {
@apply pb-6 border-b border-b-primary-500 dark:border-b-dark-500 min-w-full;
@apply pb-6 mb-6 border-b border-b-primary-500 dark:border-b-dark-500 min-w-full;
}
.heading h1 {
@apply text-5xl font-bold;
@@ -43,14 +47,20 @@
/* @apply bg-mixed-200 rounded-3xl p-6 shadow-2xl */;
}
.btn {
@apply rounded-lg;
}
.btn-primary {
@apply dark:bg-primary-500 bg-primary-100 py-4 px-6 dark:text-mixed-100 text-white rounded-full font-bold text-lg;
@apply dark:bg-primary-500 bg-primary-100 py-4 px-6 dark:text-mixed-100 text-white font-bold text-lg btn;
}
.btn-secondary {
@apply dark:text-primary-500 text-primary-100 py-4 px-6 font-bold text-lg;
}
.btn-small {
@apply px-2 py-1;
}
.p {
@apply py-1;
@apply pb-1;
}
.poppable {
@@ -73,6 +83,31 @@
.accordion:not(:has(+ .accordion)) {
@apply rounded-b-md;
}
.md-table {
@apply bg-black/20 rounded-md overflow-clip;
}
.md-table td,
.md-table th {
@apply px-4 border border-black/20 dark:border-white/20;
}
}
@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 {
@@ -84,3 +119,20 @@
list-style: square;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

View File

@@ -52,6 +52,8 @@ export const HelpClient: FC<{ body: Promise<string>; title: string }> = ({
<TTCMD
body={cleanBody}
escapeTOC={escapeTOC}
parserId={title}
title={title}
/>
</div>
{toc && (

View File

@@ -2,7 +2,6 @@
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;

View File

@@ -1,81 +1,61 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
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 inter = Inter({ subsets: ["latin"] });
const roboto = Roboto({ subsets: ["latin"], weight: "400" });
export const metadata: Metadata = {
title: "Tabletop Commander",
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={inter.className + " flex min-h-[100vh]"}>
<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>
<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 game={currentGame ?? undefined} />
<div className="mt-auto">
<User />
</div>
</div>
</nav>
<main className="p-8 w-full overflow-visible">
{children}
</main>
<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

@@ -1,6 +1,4 @@
import { readMD } from "@/actions/readMD";
import { TTCMD } from "@/components/ttcmd";
import { readFile } from "fs/promises";
import { Suspense } from "react";
import { HomeClient } from "./client";
import { MDSkeletonLoader } from "@/components/loader";
@@ -13,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
@@ -133,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

@@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<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>

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

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" stroke="#fff" fill="#fff" height="28" width="28"><circle stroke-width="3" fill="none" stroke="inherit" 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>

After

Width:  |  Height:  |  Size: 1017 B

View File

@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
inkscape:version="1.0 (4035a4fb49, 2020-05-01)"
sodipodi:docname="Help Icon.svg"
id="svg6"
version="1.1"
viewBox="0 0 28 28"
stroke="#ffffff"
fill="#ffffff"
height="28"
width="28">
<metadata
id="metadata12">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs10" />
<sodipodi:namedview
inkscape:current-layer="svg6"
inkscape:window-maximized="1"
inkscape:window-y="1432"
inkscape:window-x="-8"
inkscape:cy="14"
inkscape:cx="14"
inkscape:zoom="27.222222"
fit-margin-bottom="0"
fit-margin-right="0"
fit-margin-left="0"
fit-margin-top="0"
showgrid="false"
id="namedview8"
inkscape:window-height="1369"
inkscape:window-width="3440"
inkscape:pageshadow="2"
inkscape:pageopacity="0"
guidetolerance="10"
gridtolerance="10"
objecttolerance="10"
borderopacity="1"
bordercolor="#666666"
pagecolor="#ffffff" />
<circle
id="circle2"
stroke-width="3"
fill="none"
stroke="inherit"
r="12.5"
cy="14"
cx="14" />
<!-- <path d="M0.5,13.5a12.5,12.5 0 1,0 25,0a12.5,12.5 0 1,0 -25,0" fill="none" stroke="inherit" /> -->
<path
id="path4"
fill="inherit"
d="m 12.0039,17.8164 v -0.8672 c 0,-0.5312 0.0742,-1 0.2227,-1.4062 0.1484,-0.4141 0.3867,-0.8047 0.7148,-1.1719 0.3281,-0.375 0.7617,-0.7578 1.3008,-1.1484 0.4844,-0.3438 0.8711,-0.6524 1.1601,-0.9258 0.2969,-0.2735 0.5118,-0.543 0.6446,-0.8086 0.1406,-0.2735 0.2109,-0.5821 0.2109,-0.9258 0,-0.50781 -0.1875,-0.89453 -0.5625,-1.16016 -0.375,-0.26562 -0.8984,-0.39843 -1.5703,-0.39843 -0.6719,0 -1.3398,0.10547 -2.0039,0.3164 -0.6563,0.21094 -1.3242,0.48828 -2.00391,0.83203 L 8.83984,7.58594 c 0.78125,-0.4375 1.62891,-0.78906 2.54296,-1.05469 0.9141,-0.27344 1.9141,-0.41016 3,-0.41016 1.6719,0 2.9649,0.40235 3.8789,1.20703 0.9219,0.79688 1.3828,1.8125 1.3828,3.04688 0,0.6562 -0.1054,1.2266 -0.3164,1.7109 -0.2031,0.4766 -0.5156,0.9219 -0.9375,1.336 -0.414,0.4062 -0.9336,0.8359 -1.5586,1.289 -0.4687,0.3438 -0.8281,0.6329 -1.0781,0.8672 -0.25,0.2344 -0.4219,0.4688 -0.5156,0.7031 -0.086,0.2266 -0.1289,0.504 -0.1289,0.8321 v 0.7031 z m -0.375,4.0078 c 0,-0.7344 0.1992,-1.25 0.5977,-1.5469 0.4062,-0.2968 0.8945,-0.4453 1.4648,-0.4453 0.5547,0 1.0313,0.1485 1.4297,0.4453 0.4062,0.2969 0.6094,0.8125 0.6094,1.5469 0,0.7031 -0.2032,1.211 -0.6094,1.5235 -0.3984,0.3125 -0.875,0.4687 -1.4297,0.4687 -0.5703,0 -1.0586,-0.1562 -1.4648,-0.4687 -0.3985,-0.3125 -0.5977,-0.8204 -0.5977,-1.5235 z" />
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" inkscape:version="1.0 (4035a4fb49, 2020-05-01)"
sodipodi:docname="Help Icon.svg" id="svg6" version="1.1" viewBox="0 0 28 28" stroke="#ffffff" fill="#ffffff">
<metadata id="metadata12">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs id="defs10" />
<sodipodi:namedview inkscape:current-layer="svg6" inkscape:window-maximized="1" inkscape:window-y="1432"
inkscape:window-x="-8" inkscape:cy="14" inkscape:cx="14" inkscape:zoom="27.222222" fit-margin-bottom="0"
fit-margin-right="0" fit-margin-left="0" fit-margin-top="0" showgrid="false" id="namedview8"
inkscape:window-height="1369" inkscape:window-width="3440" inkscape:pageshadow="2" inkscape:pageopacity="0"
guidetolerance="10" gridtolerance="10" objecttolerance="10" borderopacity="1" bordercolor="#666666"
pagecolor="#ffffff" />
<circle id="circle2" stroke-width="3" fill="none" stroke="inherit" r="12.5" cy="14" cx="14" />
<!-- <path d="M0.5,13.5a12.5,12.5 0 1,0 25,0a12.5,12.5 0 1,0 -25,0" fill="none" stroke="inherit" /> -->
<path id="path4" fill="inherit"
d="m 12.0039,17.8164 v -0.8672 c 0,-0.5312 0.0742,-1 0.2227,-1.4062 0.1484,-0.4141 0.3867,-0.8047 0.7148,-1.1719 0.3281,-0.375 0.7617,-0.7578 1.3008,-1.1484 0.4844,-0.3438 0.8711,-0.6524 1.1601,-0.9258 0.2969,-0.2735 0.5118,-0.543 0.6446,-0.8086 0.1406,-0.2735 0.2109,-0.5821 0.2109,-0.9258 0,-0.50781 -0.1875,-0.89453 -0.5625,-1.16016 -0.375,-0.26562 -0.8984,-0.39843 -1.5703,-0.39843 -0.6719,0 -1.3398,0.10547 -2.0039,0.3164 -0.6563,0.21094 -1.3242,0.48828 -2.00391,0.83203 L 8.83984,7.58594 c 0.78125,-0.4375 1.62891,-0.78906 2.54296,-1.05469 0.9141,-0.27344 1.9141,-0.41016 3,-0.41016 1.6719,0 2.9649,0.40235 3.8789,1.20703 0.9219,0.79688 1.3828,1.8125 1.3828,3.04688 0,0.6562 -0.1054,1.2266 -0.3164,1.7109 -0.2031,0.4766 -0.5156,0.9219 -0.9375,1.336 -0.414,0.4062 -0.9336,0.8359 -1.5586,1.289 -0.4687,0.3438 -0.8281,0.6329 -1.0781,0.8672 -0.25,0.2344 -0.4219,0.4688 -0.5156,0.7031 -0.086,0.2266 -0.1289,0.504 -0.1289,0.8321 v 0.7031 z m -0.375,4.0078 c 0,-0.7344 0.1992,-1.25 0.5977,-1.5469 0.4062,-0.2968 0.8945,-0.4453 1.4648,-0.4453 0.5547,0 1.0313,0.1485 1.4297,0.4453 0.4062,0.2969 0.6094,0.8125 0.6094,1.5469 0,0.7031 -0.2032,1.211 -0.6094,1.5235 -0.3984,0.3125 -0.875,0.4687 -1.4297,0.4687 -0.5703,0 -1.0586,-0.1562 -1.4648,-0.4687 -0.3985,-0.3125 -0.5977,-0.8204 -0.5977,-1.5235 z" />
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -0,0 +1,8 @@
<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="inherit" fill="none" stroke-width="2"/>
<path d="M7 16L7 29" stroke="inherit" fill="none" stroke-width="2" stroke-linecap="round"/>
<path d="M12 16V29" stroke="inherit" fill="none" stroke-width="2" stroke-linecap="round"/>
<path d="M17 16V29" stroke="inherit" 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="inherit" 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="inherit" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 943 B

View File

@@ -0,0 +1,12 @@
<svg class="trash-can" width="35" height="30" viewBox="0 0 22 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- body -->
<path d="M2 7H20V27C20 28.1046 19.1046 29 18 29H4C2.89543 29 2 28.1046 2 27V7Z" stroke="inherit" fill="none" stroke-width="2"/>
<!-- body lines -->
<path d="M6 11L6 24" stroke="inherit" fill="none" stroke-width="2" stroke-linecap="round"/>
<path d="M11 11V24" stroke="inherit" fill="none" stroke-width="2" stroke-linecap="round"/>
<path d="M16 11V24" stroke="inherit" fill="none" stroke-width="2" stroke-linecap="round"/>
<!-- handle -->
<path class="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="inherit" fill="none"/>
<!-- cap -->
<path class="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="inherit" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 872 B

1
assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 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;
}

BIN
bun.lockb

Binary file not shown.

View File

@@ -0,0 +1,6 @@
.page {
transition:
transform 500ms,
opacity 300ms,
z-index 0ms 500ms
}

View File

@@ -0,0 +1,46 @@
import React, { FC, PropsWithChildren, ReactNode, useState } from "react";
import "./index.css";
interface IProps {
currentPage: number;
}
const AnimatedPageContainer: FC<PropsWithChildren<IProps>> = (
{ children, currentPage },
) => {
const [uuid] = useState(crypto.randomUUID());
const renderChild = (child: ReactNode, index: number) => {
const isActive = index === currentPage;
let position = "active";
switch ((index - currentPage) / Math.abs(index - currentPage)) {
case 1:
position = "right";
break;
case -1:
position = "left";
break;
default:
position = "active";
}
return (
<div
key={`page container ${uuid}: ${index}`}
data-active={isActive}
data-position={position}
className="data-[active=true]:opacity-100 data-[active=true]:static opacity-0 top-0 left-0 absolute page data-[position=left]:-translate-x-96 data-[position=right]:translate-x-96 translate-x-0"
>
{child}
</div>
);
};
return (
<div className="relative overflow-hidden p-2">
{React.Children.map(children, renderChild)}
</div>
);
};
export default AnimatedPageContainer;

184
components/Icon/index.tsx Normal file
View File

@@ -0,0 +1,184 @@
import { FC } from "react";
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;
icon: keyof typeof library;
}
export const Icon: FC<IProps> = ({ className, icon }) => {
const ICON = library[icon];
return (
<span className={className}>
<ICON />
</span>
);
};

View File

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

View File

@@ -0,0 +1,14 @@
import { FC, PropsWithChildren } from "react";
import { Poppable } from "@/lib/poppables/components/poppable";
export const Truncate: FC<PropsWithChildren> = ({ children }) => {
return (
<Poppable
content={children}
preferredAlign="centered"
preferredEdge="top"
>
<p className="truncate max-w-full underline">{children}</p>
</Poppable>
);
};

View File

@@ -0,0 +1,16 @@
"use client";
import { FC, PropsWithChildren, use, useEffect } from "react";
import { DevToolboxContext } from "./context";
export const DevTool: FC<PropsWithChildren<{ id: string }>> = (
{ children, id },
) => {
const { addTool, removeTool } = use(DevToolboxContext);
useEffect(() => {
addTool(id, children);
(() => removeTool(id));
}, [addTool, children, id, removeTool]);
return <></>;
};

View File

@@ -0,0 +1,40 @@
import { Portal } from "@/lib/portal/components";
import { FC, use, useState } from "react";
import { DevToolboxContext } from "./context";
import { WrenchScrewdriverIcon } from "@heroicons/react/24/solid";
import { XMarkIcon } from "@heroicons/react/16/solid";
export const DevToolbox: FC = () => {
const { tools, shouldShowDevTools } = use(DevToolboxContext);
const [open, setOpen] = useState(false);
return shouldShowDevTools
? (
<Portal>
<div className="dev-portal flex flex-col gap-2 fixed bottom-2 right-2 border rounded-lg bg-black/50">
{open
? (
<div className="relative p-2">
<button
className="p-1 absolute top-2 right-2"
onClick={() => setOpen(!open)}
>
<XMarkIcon className="w-3 h-3">
</XMarkIcon>
</button>
<p className="mb-4 mr-8">Dev Toolbox</p>
{Object.values(tools)}
</div>
)
: (
<div>
<button className="p-4" onClick={() => setOpen(!open)}>
<WrenchScrewdriverIcon className="w-4 h-4">
</WrenchScrewdriverIcon>
</button>
</div>
)}
</div>
</Portal>
)
: <></>;
};

View File

@@ -0,0 +1,65 @@
"use client";
import {
createContext,
Dispatch,
FC,
PropsWithChildren,
ReactNode,
SetStateAction,
useCallback,
useEffect,
useState,
} from "react";
import { DevToolbox } from "./Toolbox";
interface ContextProps {
tools: Record<string, ReactNode>;
addTool: (key: string, r: ReactNode) => void;
removeTool: (key: string) => void;
shouldShowDevTools: boolean;
setShouldShowDevTools: Dispatch<SetStateAction<boolean>>;
}
export const DevToolboxContext = createContext<ContextProps>({
tools: {},
addTool: () => {},
removeTool: () => {},
shouldShowDevTools: false,
setShouldShowDevTools: () => {},
});
export const DevToolboxContextProvider: FC<
PropsWithChildren<{ isDev: boolean }>
> = (
{ children, isDev },
) => {
const [tools, setTools] = useState<Record<string, ReactNode>>({});
const [shouldShowDevTools, setShouldShowDevTools] = useState(isDev);
const addTool = useCallback((key: string, r: ReactNode) => {
setTools((t) => ({ ...t, [key]: r }));
}, []);
const removeTool = useCallback((key: string) => {
setTools((t) => ({ ...t, [key]: undefined }));
}, []);
useEffect(() => {
if (localStorage.getItem("dev")) setShouldShowDevTools(true);
}, []);
return (
<DevToolboxContext.Provider
value={{
tools,
addTool,
removeTool,
shouldShowDevTools,
setShouldShowDevTools,
}}
>
{children}
<DevToolbox></DevToolbox>
</DevToolboxContext.Provider>
);
};

15
components/heading.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { FC } from "react";
interface Props {
strapline?: string;
title: string;
}
export const Heading: FC<Props> = ({ strapline, title }) => {
return (
<section className="heading">
{!!strapline && <h2 className="strapline">{strapline}</h2>}
<h1>{title}</h1>
</section>
);
};

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

@@ -72,7 +72,7 @@ export const MDSkeletonLoader: FC = () => {
i,
) => (
<li
key={t}
key={t + i}
className={"my-2 leading-8 text-black/20 " + indentation[
i === 0 ? 0 : Math.floor(Math.random() * indentation.length)
]}

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

@@ -0,0 +1,5 @@
"use client";
import { RecoilRoot } from "recoil";
export const RecoilRootClient = RecoilRoot;

View File

@@ -0,0 +1,138 @@
import { FC, useCallback, useEffect, useState } from "react";
import { useObjectStateWrapper } from "../../hooks/useObjectState";
import { ValueField } from "./value-field";
import { HelpPopper } from "../Poppables/help";
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;
field: FieldType;
fieldName: string;
deleteField: (arg: string) => void;
}
export const FieldEditor: FC<IProps> = ({
update,
field,
fieldName,
deleteField,
}) => {
const { bindProperty, bindPropertyCheck } = useObjectStateWrapper(
field,
(e) => update(typeof e === "function" ? e(field) : e),
);
const shouldShowValueField = useCallback(
() => fieldTypesWithValues.includes(field.type) || field.isConstant,
[field.isConstant, field.type],
);
const [reserved, setReserved] = useState<FieldType | undefined>(
RESERVED_FIELDS[fieldName],
);
useEffect(() => {
setReserved(RESERVED_FIELDS[fieldName]);
}, [fieldName]);
// useEffect(() => {
// console.log(field.value);
// }, [field])
return (
<li className="odd:bg-black/50">
<div className="flex gap-2 items-center">
<p>{fieldName}</p>
{reserved && (
<HelpPopper>
<p className="text-xs">
This is a reserved field name, these exist for internal purposes,
but are still useful when creating a type, and as such have
specific settings that you cannot override. If you need control
over the field properties, please use a different field name
</p>
</HelpPopper>
)}
</div>
{!reserved && (
<div className=" flex gap-x-4 items-center p-2 w-full">
<label className="w-min">
Field Type:&nbsp;
<select
className="capitalize"
{...bindProperty("type")}
disabled={!!reserved}
>
{fieldTypeOptions.map((o) => (
<option
key={"fieldtypes" + o}
className="capitalize"
value={FieldTypes[o]}
>
{o}
</option>
))}
</select>
</label>
{shouldShowValueField() && (
<ValueField type={field.type} bind={bindProperty("value")} />
)}
<span className="flex items-center gap-2">
<label>
<input type="checkbox" {...bindPropertyCheck("isConstant")} /> Is
constant
</label>
<HelpPopper>
<p className="text-sm">
Constant values can&apos;t be overwritten in publications. When
a dice field is set to a constant value, it instead rolls a dice
of that value whenever this field is displayed (unless
exported). This could be useful for a randomly generated
scenario or for cards being drawn as the dice value will
automatically be determined by the dice roll.
</p>
</HelpPopper>
</span>
<label className="w-min">
Minimum:
<input
className="w-12 min-w-min"
type="number"
{...bindProperty("minimum")}
/>
</label>
<label className="w-min">
Limit:
<input
className="w-12 min-w-min"
type="number"
{...bindProperty("limit")}
/>
</label>
<HelpPopper>
<p className="text-sm">
Minimum and Limit apply to the number of entries allowed for this
field, not the maximum and minimum value. Set the minimum to 0 to
make a field optional. Set the limit to 0 to allow for unlimited
entries.
</p>
</HelpPopper>
<button
className="no-default self-end ml-auto"
onClick={() => deleteField(fieldName)}
>
<TrashIcon className="w-6 h-6 fill-white" />
</button>
</div>
)}
</li>
);
};

View File

@@ -0,0 +1,32 @@
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] = useAtom(SchemaEditAtom);
return (
<label className="w-min">
Type:
<input type="text" {...bind} list="type-editor-type-list" />
<datalist id="type-editor-type-list">
{Object.keys(TEMPLATE_TYPES).map((k) => (
<option key={"templatetypes" + k} className="capitalize" value={k}>
{k}
</option>
))}
{Object.keys(schema.types).map((k) => (
<option key={"schematypes" + k} className="capitalize" value={k}>
{k}
</option>
))}
</datalist>
</label>
);
};

View File

@@ -0,0 +1,26 @@
export const fieldTypeOptions: (keyof typeof FieldTypes)[] = [
"number",
"text",
"long text",
"checkbox",
"type",
"dice",
"select",
"any",
];
export enum FieldTypes {
number = "number",
text = "text",
"long text" = "long text",
checkbox = "checkbox",
type = "@type",
dice = "dice",
any = "@select",
select = "select",
}
export const fieldTypesWithValues = [
FieldTypes.dice,
FieldTypes.type,
FieldTypes.select,
FieldTypes.any,
];

225
components/schema/index.tsx Normal file
View File

@@ -0,0 +1,225 @@
"use client";
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 { 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] = useAtom<Schema>(SchemaEditAtom);
// const resetSchema = useResetRecoilState(SchemaEditAtom);
const { createToast } = useToast();
const { update: updateSchema, bindProperty: bindSchemaProperty } =
useObjectStateWrapper<Schema>(schema, setSchema);
const { schemaId, id: gameSystemId } = useParams<{
schemaId: string;
id: string;
}>();
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 [selectedType, setSelectedType] = useState("");
const saveType = useCallback(
(name: string, type: TypeType) => {
updateSchema((e) => ({
types: {
...e.types,
[name]: type,
},
}));
resetTypeName();
setPageNumber(0);
setSelectedType("");
},
[resetTypeName, updateSchema],
);
const saveSchema = useCallback(async () => {
createToast({ msg: "Saving Schema", fading: true });
await saveSchemaDb(schema, schema.version);
}, [createToast, schema]);
const selectTypeForEdit = useCallback((typeKey: string) => {
setSelectedType(typeKey);
setPageNumber(1);
}, []);
const {
value: schemaFieldName,
bind: bindSchemaFieldName,
reset: resetSchemaFieldName,
} = useInput("", { disallowSpaces: true });
const addSchemaField = useCallback(() => {
updateSchema((s) => ({
fields: {
...s.fields,
[schemaFieldName]: FieldTypes.any,
},
}));
resetSchemaFieldName();
}, [resetSchemaFieldName, schemaFieldName, 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],
);
return (
<div className="flex gap-4 p-8">
<div className="panel w-2/3 h-full flex flex-col gap-4">
<div>
<input
type="text"
{...bindSchemaProperty("name")}
placeholder="Schema Name"
/>
</div>
<div>
<p className="subheader mb-2">Add Schema Field</p>
<div className="mb-2">
<input type="text" {...bindSchemaFieldName} />
<button onClick={addSchemaField} disabled={!schemaFieldName}>
Add
</button>
</div>
<ul className="rounded-lg overflow-hidden">
{Object.entries(schema.fields).map(
([schemaFieldKey, schemaField]) => (
<TemplateEditor
key={schemaFieldKey}
templateKey={schemaFieldKey}
fieldType={schemaField as FieldTypes}
update={updateSchemaField}
/>
),
)}
</ul>
</div>
<hr />
<div>
<AnimatedPageContainer currentPage={pageNumber}>
<div>
<p className="subheader mb-2">Add a type</p>
<input type="text" {...bindTypeName} />
<button
className="interactive"
disabled={!typeName}
onClick={() => setPageNumber(1)}
>
Configure
</button>
</div>
<TypeEditor
name={selectedType || typeName}
saveType={saveType}
type={
selectedType
? schema.types[selectedType as keyof typeof schema.types]
: undefined
}
/>
</AnimatedPageContainer>
<ul className="mt-3 w-96">
{Object.keys(schema.types).map((t) => (
<li
key={"type" + t}
className="odd:bg-black/50 flex justify-between p-2"
>
{t}
<div className="flex gap-3">
<button
title="Edit"
className="no-default"
onClick={() => selectTypeForEdit(t)}
>
<PencilSquareIcon className="w-6 h-6 fill-white" />
</button>
<button
title="Delete"
className="no-default"
onClick={() => deleteType(t)}
>
<TrashIcon className="w-6 h-6 fill-white" />
</button>
</div>
</li>
))}
</ul>
</div>
</div>
<div className="panel basis-1/3">
<div className="flex gap-2 mb-2">
<button
className="btn btn-small bg-green-800"
onClick={saveSchema}
disabled={lastSaved === schema}
>
Save Schema
</button>
<button
className="bg-red-800 btn btn-small"
onClick={() => setSchema(lastSaved)}
disabled={lastSaved === schema}
>
Discard Changes
</button>
</div>
<SchemaViewer schema={schema} onTypeClick={selectTypeForEdit} />
</div>
</div>
);
};

View File

@@ -0,0 +1,94 @@
import { FC, useCallback } from "react";
import { Truncate } from "@/components/Poppables/truncation";
import { Accordion, AccordionContent } from "../../lib/accordion";
import { FieldTypes, fieldTypesWithValues } from "./fieldtypes";
interface IProps {
schema: Schema;
onTypeClick?: (arg: string, arg1: TypeType) => void;
}
export const SchemaViewer: FC<IProps> = ({ schema, onTypeClick }) => {
const createValueLable = useCallback((field: FieldType) => {
if (field.isConstant) {
if (field.type === FieldTypes.dice) return "Auto-rolled";
return "Constant value:";
}
switch (field.type) {
case FieldTypes.type:
return "Type:";
case FieldTypes.dice:
return "Dice:";
case FieldTypes.select:
return "Options:";
default:
return "";
}
}, []);
return (
<>
{/* <div className="whitespace-pre-wrap">{JSON.stringify(schema, null, 2)}</div> */}
<div>
<p className="font-bold text-lg">{schema.name}</p>
<hr />
<p className="font-bold italic">Templates</p>
<ul>
{Object.entries(schema.fields).map(([templateKey, template]) => (
<li key={templateKey}>
<p className="font-bold">{templateKey}</p>
<p className="text-mixed-600 ml-2">Type: {template}</p>
</li>
))}
</ul>
<hr />
<p className="font-bold italic">Types</p>
<ul className="rounded-lg overflow-hidden grid">
{Object.entries(schema.types).map(([typeKey, type]) => (
<li
key={"type viewer" + typeKey}
// onClick={() => onTypeClick && onTypeClick(typeKey, type)}
data-clickable={!!onTypeClick}
className="odd:bg-black/50 p-2 group overflow-hidden"
>
<Accordion
title={
<p className="group-data-[expanded]/controlled:mb-2 transition-all font-bold">
{typeKey}
</p>
}
>
<AccordionContent>
<div className="grid grid-cols-2 gap-2">
{Object.entries(type).map(([fieldKey, field]) => (
<div
key={"field viewer" + fieldKey}
className="rounded-lg border border-olive-drab p-2"
>
<p className="font-bold">{fieldKey}</p>
<p className="font-thin capitalize text-xs">
{field.type}
</p>
<p className="font-thin capitalize text-xs">
Maximum entries:{" "}
{field.limit === 0 ? "unlimited " : field.limit}
</p>
{(field.isConstant ||
fieldTypesWithValues.includes(field.type)) && (
<p className="font-thin capitalize text-xs">
{createValueLable(field)}{" "}
<Truncate>{field.value}</Truncate>
</p>
)}
</div>
))}
</div>
</AccordionContent>
</Accordion>
</li>
))}
</ul>
</div>
</>
);
};

View File

@@ -0,0 +1,71 @@
import { FC, useCallback, useEffect } from "react";
import { TEMPLATE_TYPES } from "@/constants/TemplateTypes";
import { SchemaEditAtom } from "@/recoil/atoms/schema";
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: FieldTypes) => void;
fieldType: FieldTypes;
}
export const TemplateEditor: FC<IProps> = ({
templateKey,
update,
fieldType,
}) => {
const [schema, setSchema] = useAtom(SchemaEditAtom);
const { bind: bindFieldType, value } = useInput(fieldType);
useEffect(() => {
update(templateKey, value);
}, []);
const deleteField = useCallback(() => {
setSchema((s: Schema) => {
const fields = { ...s.fields };
delete fields[templateKey];
return {
...s,
schema: fields,
};
});
}, [setSchema, templateKey]);
return (
<li className="odd:bg-black/50 p-2">
<p className="font-bold">{templateKey}</p>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 pl-2">
<label className="w-min">
Type:
<input
type="text"
{...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>
))}
{Object.keys(schema.types).map((k) => (
<option key={"schematype" + k} value={k}>
{k}
</option>
))}
</datalist>
</label>
</div>
<button className="no-default" onClick={deleteField}>
<TrashIcon className="w-6 h-6 fill-white" />
</button>
</div>
</li>
);
};

View File

@@ -0,0 +1,114 @@
import {
FC,
FormEvent,
PropsWithChildren,
useCallback,
useEffect,
} from "react";
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;
saveType: (arg0: string, arg1: TypeType) => void;
type?: TypeType;
}
const constantProperties = ["metadata"];
export const TypeEditor: FC<PropsWithChildren<IProps>> = ({
saveType,
name,
type: passedType,
}) => {
const {
update: updateType,
reset: resetType,
state: type,
setState: setType,
} = useObjectState<TypeType>({});
const {
value: propertyName,
bind: bindPropertyName,
reset: resetPropertyName,
} = useInput("", { disallowSpaces: true });
const save = () => {
saveType(name, type);
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 updateField = useCallback(
(k: keyof typeof type) => (field: FieldType) => {
updateType({ [k]: field });
},
[updateType],
);
useEffect(() => {
passedType && setType(passedType);
}, [passedType, setType]);
const deleteField = useCallback(
(name: string) => {
setType((t) => {
const fields = { ...t };
delete fields[name];
return fields;
});
},
[setType],
);
return (
<div>
<p className="subheader">
{passedType ? "Editing" : "Creating"} type &quot;{name}&quot;
</p>
<form onSubmit={addField}>
<input type="text" {...bindPropertyName} />
<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}
/>
))}
</ul>
<div>
<button onClick={save} disabled={!Object.keys(type).length}>
Save Type
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,129 @@
import { ChangeEvent, FC, useRef } from "react";
import { FieldTypeInput } from "./field-type-input";
import { useInput } from "../../hooks/useInput";
import { HelpPopper } from "../Poppables/help";
import { FieldTypes } from "./fieldtypes";
interface IValueProps {
type: FieldTypes;
bind: InputBinder;
}
const DICE_SIDES = [3, 4, 6, 8, 10, 12, 20, 100];
export const ValueField: FC<IValueProps> = ({ type, bind }) => {
const { value: diceCount, bind: bindDiceCount } = useInput(1);
const { value: diceSides, bind: bindDiceSides } = useInput("");
const diceInputRef = useRef<HTMLInputElement>(null);
switch (type) {
case FieldTypes.dice: {
const onChange = (
handler: (
arg: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => void,
) =>
(e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
handler(e);
setTimeout(() => {
if (!diceInputRef.current) return;
e.target = diceInputRef.current;
bind.onChange(e);
}, 0);
};
return (
<>
<label className="w-min">
Count:&nbsp;
<input
className="w-12"
type="number"
{...bindDiceCount}
onChange={onChange(bindDiceCount.onChange)}
/>
</label>
<label className="w-min">
Sides:&nbsp;
<select
{...bindDiceSides}
onChange={onChange(bindDiceSides.onChange)}
>
<option value=""></option>
{DICE_SIDES.map((d) => (
<option key={"dice sides" + d} value={"d" + d}>{d}</option>
))}
</select>
</label>
<input
ref={diceInputRef}
className="hidden"
type="text"
name={bind.name}
value={diceCount + diceSides}
readOnly
/>
</>
);
}
case FieldTypes.type:
return <FieldTypeInput bind={bind} />;
case FieldTypes.number:
return (
<label className="w-min">
Value:<input className="w-16" type="number" {...bind} />
</label>
);
case FieldTypes.text:
return (
<label className="w-min">
Value:<input type="text" {...bind} />
</label>
);
case FieldTypes.select:
return (
<>
<label className="w-min">
<div className="flex gap-2 items-center">
Values:
<HelpPopper>
<p className="text-xs">
A comma separated list (no spaces, spaces are reserved for
values) of options that can be chosen while creating
publications. Ex: earthquake,wind storm,fire tornado,rainbow.
Alternatively, you can specify a display value and an actual
value separated with a colon. This is useful for when you want
to create a reference in a publication with a dropdown field.
Ex: Rapid Fire:^core.weaponAbilities[name=rapid
fire],Heavy:^core.weaponAbilities[name=heavy]
</p>
</HelpPopper>
</div>
<input type="text" {...bind} />
</label>
</>
);
case FieldTypes.any:
return (
<>
<label className="w-min">
<div className="flex gap-2 items-center">
Type options:
<HelpPopper>
<p className="text-xs">
A comma separated list (no spaces, spaces are reserved for
values) of options that are names of types that can be
selected when creating a publication, Ex: dice,number,text. Do
not leave this blank, allowing for any type to be selected
makes querying gross.
</p>
</HelpPopper>
</div>
<input type="text" {...bind} />
</label>
</>
);
default:
return <></>;
}
};

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

View File

@@ -1,13 +1,19 @@
"use client";
import { createElements } from "@/lib/tcmd";
import React, { FC, Suspense, useEffect, useMemo, useState } from "react";
import { MDSkeletonLoader } from "../loader";
import { DevTool } from "../devtools/DevTool";
export const TTCMD: FC<
{ body: string; escapeTOC?: (tokens: Token[]) => boolean }
> = ({ body, escapeTOC = () => false }) => {
interface Props {
body: string;
escapeTOC?: (tokens: Token[]) => boolean;
parserId: string;
title: string;
}
export const TTCMD: FC<Props> = (
{ body, parserId, escapeTOC = () => false, title },
) => {
const elements = useMemo(() => createElements(body), [body]);
const [toc, start, end] = useMemo(() => {
@@ -33,13 +39,15 @@ export const TTCMD: FC<
return (
<Suspense fallback={<MDSkeletonLoader />}>
<DevTool id={parserId}>
<button
className="btn-primary"
onClick={() =>
navigator.clipboard.writeText(JSON.stringify(elements, null, 2))}
>
copy ast
Copy AST for {title}
</button>
</DevTool>
{hasEscapedTOC !== undefined &&
(
<TTCMDRenderer

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

@@ -0,0 +1,25 @@
import { FieldTypes } from "@/components/schema/fieldtypes";
export const RESERVED_FIELDS: Record<string, FieldType> = {
maximum: {
isConstant: false,
limit: 1,
minimum: 1,
type: FieldTypes.number,
value: "",
},
minimum: {
isConstant: false,
limit: 1,
minimum: 1,
type: FieldTypes.number,
value: "",
},
relative: {
isConstant: true,
limit: 1,
minimum: 1,
type: FieldTypes.text,
value: "$",
},
};

View File

@@ -0,0 +1,95 @@
import { FieldTypes } from "@/components/schema/fieldtypes";
export const TEMPLATE_TYPES: Record<string, TypeType> = {
section: {
name: {
isConstant: false,
limit: 1,
minimum: 1,
type: FieldTypes.text,
value: "",
},
body: {
isConstant: false,
limit: 0,
minimum: 1,
type: FieldTypes["long text"],
value: "",
},
},
steps: {
steps: {
isConstant: false,
limit: 0,
minimum: 1,
type: FieldTypes.type,
value: "section",
},
},
image: {
name: {
isConstant: false,
limit: 1,
minimum: 1,
type: FieldTypes.text,
value: "",
},
link: {
isConstant: false,
limit: 1,
minimum: 1,
type: FieldTypes.text,
value: "",
},
},
list: {
items: {
isConstant: false,
limit: 0,
minimum: 1,
type: FieldTypes["long text"],
value: "",
},
},
table_column: {
name: {
isConstant: false,
limit: 0,
minimum: 1,
type: FieldTypes.any,
value: "",
},
value: {
isConstant: false,
limit: 0,
minimum: 1,
type: FieldTypes.any,
value: "",
},
},
table_row: {
columns: {
isConstant: false,
limit: 0,
minimum: 1,
type: FieldTypes.type,
value: "tableColumn",
},
},
table: {
rows: {
isConstant: false,
limit: 0,
minimum: 1,
type: FieldTypes.type,
value: "tableRow",
},
header: {
isConstant: false,
limit: 1,
minimum: 0,
type: FieldTypes.type,
value: "tableRow",
},
},
};

5
global.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
import { DHSecretClient } from "./lib/secret";
export declare global {
var Secrets: DHSecretClient;
}

7
hooks/types.d.ts vendored
View File

@@ -1,7 +0,0 @@
export type InputBinder = {
name: string;
value: string | number;
onChange: (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>,
) => void;
};

View File

@@ -1,9 +1,11 @@
import { ReactNode, useCallback, useRef } from 'react'
import { ReactNode, useCallback, useRef } from "react";
export const useRefCallback = <T = ReactNode>() : [T | null, (arg: T) => void] => {
export const useRefCallback = <T = ReactNode>(): [
T | null,
(arg: T) => void,
] => {
const ref = useRef<T | null>(null);
const setRef = useCallback((val: T) => {
console.log(val);
if (ref.current) {
// does something?
}
@@ -12,8 +14,8 @@ export const useRefCallback = <T = ReactNode>() : [T | null, (arg: T) => void] =
// also does something?
}
ref.current = val
}, [])
ref.current = val;
}, []);
return [ref.current, setRef]
}
return [ref.current, setRef];
};

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

@@ -4,7 +4,6 @@ import {
PropsWithChildren,
SetStateAction,
useCallback,
useEffect,
useState,
} from "react";
import { bulkRound } from "../../utils/bulkRound";
@@ -24,7 +23,7 @@ interface IProps {
setHover: Dispatch<SetStateAction<boolean>>;
}
type position = { top: number; left: number; width?: number };
type position = { top: number; left: number; width?: number; };
export const PoppableContent: FC<PropsWithChildren<IProps>> = (
{
@@ -35,7 +34,6 @@ export const PoppableContent: FC<PropsWithChildren<IProps>> = (
spacing = 10,
setHover,
isClosing,
isClosed,
},
) => {
const [popRef, setPopRef] = useState<HTMLDivElement>();
@@ -52,7 +50,7 @@ export const PoppableContent: FC<PropsWithChildren<IProps>> = (
relHeight: number,
popWidth: number,
popHeight: number,
edge: edge,
_edge: edge,
align: alignment,
): position => {
const pos = {

View File

@@ -5,7 +5,7 @@ import {
PropsWithChildren,
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { PoppableContent } from "./poppable-content";
@@ -19,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

@@ -9,20 +9,17 @@ interface IProps {
export const Portal: FC<PropsWithChildren<IProps>> = (
{ children, className = "root-portal", el = "div" },
) => {
const [container] = useState(() => {
// This will be executed only on the initial render
// https://reactjs.org/docs/hooks-reference.html#lazy-initial-state
return document.createElement(el);
});
const [container, setContainer] = useState<HTMLElement>();
// todo: this smells. appending the same element?
useEffect(() => {
const container = document.createElement(el);
container.classList.add(className);
document.body.appendChild(container);
setContainer(container);
return () => {
document.body.removeChild(container);
document.body.contains(container) && document.body.removeChild(container);
};
}, [className, container]);
}, [className, el]);
return createPortal(children, container);
return container && createPortal(children, container);
};

108
lib/secret/index.ts Normal file
View File

@@ -0,0 +1,108 @@
// import { mkdirSync, readFileSync, writeFileSync } from "fs";
import { writeFile } from "fs/promises";
import { mkdirSync, readFileSync } from "fs";
export class DHSecretClient {
private token!: Promise<string>; //Set by init
private headerName = "x-hoard-auth-token";
private cache: Record<string, { value: string; expires?: number }> = {};
private cacheLocation: string;
/**
* @param dhBaseUri uri for hosted Dragon's Hoard instance
* @param cacheDir path to cache dir
*/
constructor(
private dhBaseUri: string,
private cacheDir: string,
) {
this.cacheLocation = this.cacheDir.trim().replace(/\/^/, "") + "/.dh_cache";
mkdirSync(this.cacheDir, { recursive: true });
this.readDiskCache();
this.token = this.fetchToken();
}
private async fetchToken() {
const cacheKey = "token";
if (this.cache[cacheKey]) {
return this.cache[cacheKey].value;
}
const req = await fetch(this.dhBaseUri + "/api/access/token");
if (req.status !== 200) throw Error(await req.text());
const token = await req.text();
if (!token) throw Error("Token not included in response body");
this.writeCache(cacheKey, token);
return token;
}
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();
}
private readCache(key: string) {
const item = this.cache[key];
if (!item) return null;
if (item && item.expires && item.expires < Date.now()) {
delete this.cache[key];
// this.writeDiskCache();
return null;
}
return item.value;
}
async fetchSecret(secret_name: string, environment?: string) {
const uri =
this.dhBaseUri +
"/api/keys/" +
secret_name +
(environment ? "?env=" + environment : "");
const cached = this.readCache(secret_name);
if (cached !== null) return cached;
const req = await fetch(uri, {
headers: {
[this.headerName]: await this.token,
},
});
if (req.status !== 200) throw Error(await req.text());
const secret = await req.text();
if (!secret) throw Error("Secret not included in response body");
this.writeCache(secret_name, secret);
return secret;
}
}

12
lib/secret/init.ts Normal file
View File

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

100
lib/sticky/index.tsx Normal file
View File

@@ -0,0 +1,100 @@
"use client";
import {
FC,
MouseEventHandler,
PropsWithChildren,
useEffect,
useRef,
useState,
} from "react";
import { Portal } from "../portal/components";
export const Sticky: FC<
PropsWithChildren<{ sidedness: 1 | -1; initialX?: number; initialY?: number }>
> = (
{ children, sidedness, initialX, initialY },
) => {
const [position, setPosition] = useState({
x: initialX ?? 10,
y: initialY ?? 10,
});
const divRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState(false);
const [offset, setOffset] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (dragging) {
setPosition({
x: ((sidedness === -1 ? document.body.clientWidth : 0) -
(e.pageX - offset.x * sidedness)) * -sidedness,
y: e.pageY - offset.y,
});
}
};
const handleMouseUp = () => {
if (dragging) {
setDragging(false);
}
};
if (dragging) {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [dragging, offset, sidedness]);
const handleMouseDown: MouseEventHandler = (e) => {
e.preventDefault();
const rect = divRef.current!.getBoundingClientRect();
const offsetX = e.pageX - rect.left;
const offsetY = e.pageY - rect.top;
setOffset({ x: offsetX, y: offsetY });
setDragging(true);
};
return (
<Portal>
<div
className="fixed card p-0 overflow-clip"
style={{
top: position.y,
left: sidedness === 1 ? position.x : "unset",
right: sidedness === -1 ? position.x : "unset",
}}
>
<div
ref={divRef}
className="cursor-move p-1 bg-black/20 flex justify-center"
onMouseDown={handleMouseDown}
draggable="false"
style={{ position: "relative" }}
>
<svg width="70" height="30" xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="10" r="1" fill="grey" />
<circle cx="20" cy="10" r="1" fill="grey" />
<circle cx="30" cy="10" r="1" fill="grey" />
<circle cx="40" cy="10" r="1" fill="grey" />
<circle cx="50" cy="10" r="1" fill="grey" />
<circle cx="60" cy="10" r="1" fill="grey" />
<circle cx="10" cy="20" r="1" fill="grey" />
<circle cx="20" cy="20" r="1" fill="grey" />
<circle cx="30" cy="20" r="1" fill="grey" />
<circle cx="40" cy="20" r="1" fill="grey" />
<circle cx="50" cy="20" r="1" fill="grey" />
<circle cx="60" cy="20" r="1" fill="grey" />
</svg>
</div>
<div className="p-4">{children}</div>
</div>
</Portal>
);
};

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

@@ -1,69 +1,39 @@
import { sanitize } from "isomorphic-dompurify";
import Link from "next/link";
import { Fragment } from "react";
import React, { Fragment } from "react";
import { Poppable } from "../poppables/components/poppable";
import { Accordion, AccordionContent } from "../accordion";
import { OnDemandResolver, Resolver } from "./Resolver";
type SearchFunction = (s: string, start: number, end: number) => {
start: number;
end: number;
text: string;
lastIndex: number;
};
// import "crypto";
type TokenIdentifier = {
rx: RegExp;
parse: (s: string) => Token;
search?: SearchFunction;
};
type TokenIdentifierMap = Map<
string,
TokenIdentifier
>;
export const TokenRenderers = new Map<
string,
TokenRenderer
>();
type IdentifierRegistration = (
type: string,
match: RegExp,
parseFunction: (s: string, rx: RegExp) => IdentifiedToken,
renderFunction: TokenRenderer,
openTagRx?: RegExp,
closeTagRx?: RegExp,
) => void;
export const TokenRenderers = new Map<string, TokenRenderer<any>>();
export function buildIdentifierMap(): [
TokenIdentifierMap,
IdentifierRegistration,
] {
const TokenIdentifiers = new Map<
string,
TokenIdentifier
>();
const TokenIdentifiers = new Map<string, TokenIdentifier<any>>();
function registerIdentifier(
function registerIdentifier<M>(
type: string,
match: RegExp,
parseFunction: (s: string, rx: RegExp) => IdentifiedToken,
renderFunction: TokenRenderer,
parseFunction: (s: string, rx: RegExp) => IdentifiedToken<M>,
renderFunction: TokenRenderer<M>,
): void;
function registerIdentifier(
function registerIdentifier<M>(
type: string,
match: RegExp,
parseFunction: (s: string, rx: RegExp) => IdentifiedToken,
renderFunction: TokenRenderer,
parseFunction: (s: string, rx: RegExp) => IdentifiedToken<M>,
renderFunction: TokenRenderer<M>,
openTagRx: RegExp,
closeTagRx: RegExp,
): void;
function registerIdentifier(
function registerIdentifier<M = Record<string, string>>(
type: string,
match: RegExp,
parseFunction: (s: string, rx: RegExp) => IdentifiedToken,
renderFunction: TokenRenderer,
parseFunction: (s: string, rx: RegExp) => IdentifiedToken<M>,
renderFunction: TokenRenderer<M>,
openTagRx?: RegExp,
closeTagRx?: RegExp,
) {
@@ -76,9 +46,10 @@ export function buildIdentifierMap(): [
type,
};
return { ...token, ...identifiedToken };
return { ...token, ...identifiedToken } as Token<M>;
},
search: (openTagRx && closeTagRx)
search:
openTagRx && closeTagRx
? (s, start, end) => {
return search(
s,
@@ -99,11 +70,10 @@ export function buildIdentifierMap(): [
export const buildOnlyDefaultElements = () => {
const [TokenIdentifiers, registerIdentifier] = buildIdentifierMap();
TokenRenderers.set("text", (t) => {
debugger;
TokenRenderers.set("text", (t: Token<any>) => {
return (
<span className="whitespace-pre-wrap">
{t.content.replaceAll("\\n", "\n")}
{t.content.replaceAll(/\\n ?/g, "\n")}
</span>
);
});
@@ -114,13 +84,16 @@ export const buildOnlyDefaultElements = () => {
const rendersChildrenOnly = true;
// grid
registerIdentifier(
registerIdentifier<{ columns: string }>(
"grid",
/(?<!\/)(?:\[\])+\n+((?:.|\n)*?)\n+\/\[\]/g,
(s) => {
const rx = /((?:\[\])+)\n+([\s\S]*)\n+\/\[\]/;
const [_, columns, content] = s.match(rx) ||
["", "..", "Unable to parse grid"];
const [_, columns, content] = s.match(rx) || [
"",
"..",
"Unable to parse grid",
];
return {
content,
raw: s,
@@ -132,56 +105,68 @@ export const buildOnlyDefaultElements = () => {
};
},
(token) => {
const { content, children, metadata, uuid } = token;
const { children, metadata } = token;
return (
<div
style={{
style={
{
"--grid-cols": metadata.columns,
} as React.CSSProperties}
className="grid grid-cols-dynamic gap-x-8 gap-y-6 mb-6"
} as React.CSSProperties
}
className="grid grid-cols-dynamic gap-x-8 mb-6"
>
{children?.map((c, i) => (
<div key={c.uuid}>
{children?.map((c) => {
const Comp = c.metadata.span ? Fragment : "div";
return (
<Comp className="p" key={c.uuid}>
{c.render(c)}
</div>
))}
</Comp>
);
})}
</div>
);
},
/(?<!\/)(?:\[\])+/g,
/(?<![\/\?])(?:\[\])+/g,
/\/\[\]/g,
);
// card
registerIdentifier(
"card",
/\[{2}([\s\S]*?)\n+\]{2}/g,
/\[{2}[\s\S]*?\n+\]{2}/g,
(s) => {
const rx = /\[{2}(!?)\s*?\n+([\s\S]*)\n+\]{2}/;
const rx = /\[{2}((?:!?)(?:[0-9]?))\s*?\n+([\s\S]*)\n+\]{2}/;
const match = s.match(rx);
const [_, isBlock, content] = match || ["", "", s];
const [_, isBlockOrSpan, content] = match || ["", "", s];
const isBlock = isBlockOrSpan.includes("!");
const span = Number(isBlockOrSpan.replace("!", ""));
return {
content: content.trim(),
raw: s,
metadata: {
isBlock,
span,
},
uuid: crypto.randomUUID(),
rendersChildrenOnly,
};
},
(token) => {
const { children, metadata, uuid } = token;
const { children, metadata } = token;
return (
<div
data-block={!!metadata.isBlock}
className="data-[block=false]:card mb-6"
style={
{
"--v-span": metadata.span || 1,
} as React.CSSProperties
}
className="data-[block=false]:card data-[block=false]:mb-6 col-span-2"
>
{children?.map((e) => (
<Fragment key={e.uuid}>
{e.render(e)}
</Fragment>
<Fragment key={e.uuid}>{e.render(e)}</Fragment>
))}
</div>
);
@@ -191,43 +176,45 @@ export const buildOnlyDefaultElements = () => {
);
// fenced code block
registerIdentifier("code", /`{3}\n+((?:.|\n)*?)\n+`{3}/g, (s, rx) => {
registerIdentifier(
"code",
/`{3}\n+((?:.|\n)*?)\n+`{3}/g,
(s, rx) => {
return {
content: s.match(new RegExp(rx, ""))?.at(1) ||
"Unable to parse code",
content: s.match(new RegExp(rx, ""))?.at(1) || "Unable to parse code",
raw: s,
metadata: {},
uuid: crypto.randomUUID(),
rendersContentOnly,
};
}, (token) => {
},
(token) => {
return (
<pre className="whitespace-pre-wrap bg-black/20 p-2 rounded-md">
{token.content}
</pre>
);
});
},
);
// list
registerIdentifier(
"list",
/^\s*-\s([\s\S]*?)\n\n/gm,
/(?<=\n\n?|^) *-\s([\s\S]*?)(?=\n\n|$)/g,
(s, rx) => {
return {
content: s.match(new RegExp(rx, ""))?.at(1) ||
"Unable to parse list",
content: s.match(new RegExp(rx, ""))?.at(0) || "Unable to parse list",
raw: s,
metadata: {
initialDepth:
s.replace("\n", "").split("-").at(0)?.length.toString() ||
"1",
s.replace("\n", "").split("-").at(0)?.length.toString() || "1",
},
uuid: crypto.randomUUID(),
rendersChildrenOnly,
};
},
(token) => {
const { children, metadata, uuid } = token;
const { children, metadata } = token;
return (
<>
<ul
@@ -236,11 +223,8 @@ export const buildOnlyDefaultElements = () => {
>
{children?.map((c) => {
return (
<li
key={c.uuid}
data-depth={metadata.initialDepth}
>
{c.children?.map((c) => (
<li key={c.uuid} data-depth={metadata.initialDepth}>
{c.children?.map((c: Token<any>) => (
<Fragment key={c.uuid}>{c.render(c)}</Fragment>
))}
</li>
@@ -252,34 +236,69 @@ export const buildOnlyDefaultElements = () => {
},
);
// ordered-list
registerIdentifier(
"ordered-list",
/(?<=\n\n|^)\s*\d+\.\s([\s\S]*?)(?=\n\n|$)/g,
(s, rx) => {
return {
content:
s.match(new RegExp(rx, ""))?.at(0) || "Unable to parse ordered list",
raw: s,
metadata: {
// initialDepth:
// s.replace("\n", "").split(/\d+\./).at(0)?.length.toString() || "1",
},
uuid: crypto.randomUUID(),
rendersChildrenOnly,
};
},
(token) => {
const { children } = token;
return (
<>
<ol
// data-depth={(Number(metadata.initialDepth) / 2) % 3}
className="ml-6 list-decimal"
>
{children?.map((c) => {
return (
<li key={c.uuid}>
{c.children?.map((c: Token<any>) => (
<Fragment key={c.uuid}>{c.render(c)}</Fragment>
))}
</li>
);
})}
</ol>
</>
);
},
);
// ordered list-item
// list-item
registerIdentifier(
"list-item",
/^\s*-\s(.*?)$/gm,
/(?<=^|\n) *(?:-|\d+\.)\s(.*?)(?=\n|$)/g,
(s, rx) => {
return {
content: s.match(new RegExp(rx, ""))?.at(1) ||
"Unable to parse list-item",
content:
s.match(new RegExp(rx, ""))?.at(1) || "Unable to parse list-item",
raw: s,
metadata: {
initialDepth:
s.replace("\n", "").split("-").at(0)?.length.toString() ||
"1",
s.replace("\n", "").split("-").at(0)?.length.toString() || "1",
},
uuid: crypto.randomUUID(),
};
},
(token) => {
const { children, metadata, uuid } = token;
const { children, metadata } = token;
return (
<li
data-depth={metadata.initialDepth}
className="ml-2"
>
<li data-depth={metadata.initialDepth} className="ml-2">
{children?.map((c) => (
<Fragment key={c.uuid}>
(c.render(c))
</Fragment>
<Fragment key={c.uuid}>{c.render(c)}</Fragment>
))}
</li>
);
@@ -287,9 +306,12 @@ export const buildOnlyDefaultElements = () => {
);
// heading
registerIdentifier("heading", /^#+\s(.*?)$/gm, (s, rx) => {
const content = s.match(new RegExp(rx, ""))?.at(1) ||
"Unable to parse heading";
registerIdentifier(
"heading",
/^#+\s(.*?)$/gm,
(s, rx) => {
const content =
s.match(new RegExp(rx, ""))?.at(1) || "Unable to parse heading";
return {
content: content,
raw: s,
@@ -300,10 +322,11 @@ export const buildOnlyDefaultElements = () => {
uuid: crypto.randomUUID(),
rendersContentOnly,
};
}, (token) => {
},
(token) => {
return (
<div
// id={generateId(token.raw, usedIds)}
id={token.metadata.id}
data-strength={token.metadata.strength}
className={`
font-bold
@@ -315,10 +338,14 @@ export const buildOnlyDefaultElements = () => {
{token.content}
</div>
);
});
},
);
// image
registerIdentifier("image", /\!\[(.*?)\]\((.*?)\)/g, (s, rx) => {
registerIdentifier(
"image",
/\!\[(.*?)\]\((.*?)\)/g,
(s, rx) => {
const [_, title, src] = s.match(new RegExp(rx, ""))!;
return {
@@ -331,7 +358,8 @@ export const buildOnlyDefaultElements = () => {
uuid: crypto.randomUUID(),
rendersContentOnly,
};
}, (token) => {
},
(token) => {
const { metadata } = token;
metadata.src = metadata.src as string;
if (metadata.src.startsWith("<svg")) {
@@ -342,18 +370,22 @@ export const buildOnlyDefaultElements = () => {
USE_PROFILES: { svg: true },
}),
}}
>
</div>
></div>
);
}
// eslint-disable-next-line @next/next/no-img-element
return <img src={metadata.src} alt={token.content} />;
});
},
);
// anchor
registerIdentifier("anchor", /(?<![\!^])\[(.*?)\]\((.*?)\)/g, (s, rx) => {
let preset, [_, title, href] = s.match(new RegExp(rx, ""))!;
const match = title.match(/`{3}(cta|button)?(.*)/);
registerIdentifier(
"anchor",
/(?<![\!^])\[(.*?)\]\((.*?)\)/g,
(s, rx) => {
let preset,
[_, title, href] = s.match(new RegExp(rx, ""))!;
const match = title.match(/~{2}(cta|button)?(.*)/);
if (match) {
[_, preset, title] = match;
@@ -373,18 +405,22 @@ export const buildOnlyDefaultElements = () => {
uuid: crypto.randomUUID(),
rendersContentOnly,
};
}, (token) => {
},
(token) => {
const { metadata } = token;
return (
<Link
className={metadata.classes ||
"dark:text-primary-600 underline dark:no-underline"}
className={
metadata.classes ||
"dark:text-primary-600 underline dark:no-underline"
}
href={metadata.href}
>
{token.content}
</Link>
);
});
},
);
// inline-code
registerIdentifier(
@@ -392,8 +428,8 @@ export const buildOnlyDefaultElements = () => {
/(?<=\s|^)`(.*?)`(?=[\s,.!?)]|$)/gi,
(s, rx) => {
return {
content: s.match(new RegExp(rx, "i"))?.at(1) ||
"Unable to parse inline-code",
content:
s.match(new RegExp(rx, "i"))?.at(1) || "Unable to parse inline-code",
raw: s,
metadata: {},
uuid: crypto.randomUUID(),
@@ -410,43 +446,47 @@ export const buildOnlyDefaultElements = () => {
);
// bold
registerIdentifier("bold", /\*{2}(.*?)\*{2}/g, (s, rx) => {
registerIdentifier(
"bold",
/\*{2}(.*?)\*{2}/g,
(s, rx) => {
return {
content: s.match(new RegExp(rx, "i"))?.at(1) ||
"Unable to parse bold",
content: s.match(new RegExp(rx, "i"))?.at(1) || "Unable to parse bold",
raw: s,
metadata: {},
uuid: crypto.randomUUID(),
rendersContentOnly,
};
}, (token) => {
return (
<span className="font-bold">
{token.content}
</span>
},
(token) => {
return <span className="font-bold">{token.content}</span>;
},
);
});
// italic
registerIdentifier("italic", /(?<!\*)\*([^\*]+?)\*(?!\*)/g, (s, rx) => {
registerIdentifier(
"italic",
/(?<!\*)\*([^\*]+?)\*(?!\*)/g,
(s, rx) => {
return {
content: s.match(new RegExp(rx, "i"))?.at(1) ||
"Unable to parse italic",
content:
s.match(new RegExp(rx, "i"))?.at(1) || "Unable to parse italic",
raw: s,
metadata: {},
uuid: crypto.randomUUID(),
rendersContentOnly,
};
}, (token) => {
return (
<span className="italic">
{token.content}
</span>
},
(token) => {
return <span className="italic">{token.content}</span>;
},
);
});
// popover
registerIdentifier("popover", /\^\[(.*?)\]\<<(.*?)\>>/g, (s, rx) => {
registerIdentifier(
"popover",
/\^\[(.*?)\]\<<(.*?)\>>/g,
(s, rx) => {
const [_, title, content] = s.match(new RegExp(rx, ""))!;
return {
@@ -456,14 +496,16 @@ export const buildOnlyDefaultElements = () => {
uuid: crypto.randomUUID(),
rendersContentOnly,
};
}, (token) => {
},
(token) => {
const { children, metadata, uuid } = token;
return (
<Poppable
content={children?.map((c) => (
content={
children?.map((c) => (
<Fragment key={uuid}>{c.render(c)}</Fragment>
)) ||
metadata.content}
)) || token.content
}
preferredAlign="centered"
preferredEdge="bottom"
className="cursor-pointer mx-2"
@@ -473,8 +515,10 @@ export const buildOnlyDefaultElements = () => {
</span>
</Poppable>
);
});
},
);
// accordion
registerIdentifier(
"accordion",
/\[accordion(\s.*?)?]\n+((?:.|\n)*?)\n+\[\/accordion\]/g,
@@ -489,17 +533,13 @@ export const buildOnlyDefaultElements = () => {
};
},
(token) => {
const { children, metadata, uuid } = token;
const { children, metadata } = token;
return (
<div className="bg-black/20 p-1 accordion">
<Accordion
title={metadata.title || "Expand"}
>
<Accordion title={metadata.title || "Expand"}>
<AccordionContent>
{children?.map((e, i) => (
<Fragment key={e.uuid}>
{e.render(e)}
</Fragment>
{children?.map((e) => (
<Fragment key={e.uuid}>{e.render(e)}</Fragment>
))}
</AccordionContent>
</Accordion>
@@ -508,33 +548,37 @@ export const buildOnlyDefaultElements = () => {
},
);
registerIdentifier("p", /(?<=\n\n)([\s\S]*?)(?=\n\n)/g, (s, rx) => {
// paragraph
registerIdentifier(
"p",
// /(?<=\n\n|^)([\s\S]*?)(?=\n\n|$)/g,
/(?<=\n\n)([\s\S]*?)(?=\n\n)/g,
(s) => {
return {
content: s,
content: s.replace("\n", " "),
raw: s,
metadata: {},
uuid: crypto.randomUUID(),
};
}, (token) => {
const { children, uuid } = token;
debugger;
},
(token) => {
const { children } = token;
return (
<div className="p">
{children?.map((e) => {
console.log(e);
return (
<Fragment key={e.uuid}>
{e.render(e)}
</Fragment>
);
return <Fragment key={e.uuid}>{e.render(e)}</Fragment>;
})}
</div>
);
});
},
);
registerIdentifier("hr", /^-{3,}$/gm, (s, rx) => {
// horizontal rule
registerIdentifier(
"hr",
/^-{3,}$/gm,
(s) => {
return {
content: s,
raw: s,
@@ -542,11 +586,17 @@ export const buildOnlyDefaultElements = () => {
uuid: crypto.randomUUID(),
rendersContentOnly,
};
}, (token) => {
},
() => {
return <div className="w-full border-b border-mixed-500 my-3"></div>;
});
},
);
registerIdentifier("comment", /<!--[\s\S]+?-->/g, (s, rx) => {
// comment
registerIdentifier(
"comment",
/<!--[\s\S]+?-->/g,
(s) => {
return {
content: "",
metadata: { comment: s },
@@ -554,11 +604,17 @@ export const buildOnlyDefaultElements = () => {
uuid: crypto.randomUUID(),
rendersContentOnly,
};
}, (token) => {
},
() => {
return <></>;
});
},
);
registerIdentifier("frontmatter", /^---([\s\S]*?)---/g, (s, rx) => {
// frontmatter
registerIdentifier(
"frontmatter",
/^---([\s\S]*?)---/g,
(s, rx) => {
return {
content: "",
metadata: {
@@ -567,13 +623,42 @@ export const buildOnlyDefaultElements = () => {
raw: "",
uuid: "frontmatter",
};
}, (token) => {
},
(token) => {
return <>{token.raw}</>;
},
);
// table
registerIdentifier(
"table",
/(?<=\n|^)\| [\s\S]*? \|(?=(\n|$)(?!\|))/g,
(s) => {
const splitMarker = "{{^^}}";
const original = s;
let columnPattern: string[] = [];
let fullWidth = false;
s = s.replace(/^\| (<-)?[-|\s^]+(->)? \|$/gm, (e) => {
if (!columnPattern.length) {
columnPattern = e.split("|").filter((e) => e);
}
if (e.match(/^\| <-.*?-> \|/gm)) {
fullWidth = true;
}
return splitMarker;
});
registerIdentifier("table", /^\|\s[\s\S]*?\|(?=(\n\n)|$)/g, (s, rx) => {
const rowSections = s.split(/-/gm).map((s) =>
s.split("\n").map((r) => r.split(/\s?\|\s?/g))
const rowSections = s.split(splitMarker).map((s) =>
s
.split("\n")
.filter((r) => !!r)
.map((r) =>
r
.split("|")
.map((c) => c.trim())
.filter((c) => !!c),
),
);
let headerRows: string[][] = [],
@@ -600,19 +685,164 @@ export const buildOnlyDefaultElements = () => {
);
return {
content: s,
raw: s,
content: original,
raw: original,
metadata: {
headerRows: headerRows.join(" | "),
bodyRows: bodyRows.join(" | "),
footerRows: footerRows.join(" | "),
columns: maxColumns.toString(),
headerRows: headerRows,
bodyRows: bodyRows,
footerRows: footerRows,
columns: maxColumns,
columnPattern,
fullWidth,
},
uuid: crypto.randomUUID(),
};
}, (t) => {
return <>{t.raw}</>;
});
},
(t) => {
const {
headerRows,
bodyRows,
footerRows,
columns,
columnPattern,
fullWidth,
} = t.metadata;
return (
<table
data-full-width={fullWidth}
className="md-table data-[full-width=true]:w-full"
>
{!!headerRows && (
<thead>
{headerRows.map((r, i) => (
<tr key={r.join() + i}>
{r.concat(Array(columns - r.length).fill("")).map((c) => {
const child = t.children?.find((child) => child.raw === c);
return (
<th key={r.join() + i + c}>
{child?.render(child) || c}
</th>
);
})}
</tr>
))}
</thead>
)}
{!!bodyRows && (
<tbody>
{bodyRows.map((r, i) => (
<tr key={r.join() + i}>
{r.concat(Array(columns - r.length).fill("")).map((c, i) => {
const child = t.children?.find((child) => child.raw === c);
return (
<td
key={r.join() + i + c}
className="data-[center=true]:text-center"
data-center={
!!(
columnPattern?.at(i) &&
columnPattern.at(i)?.includes("^")
)
}
>
{child?.render(child) || c}
</td>
);
})}
</tr>
))}
</tbody>
)}
{!!footerRows && (
<tfoot>
{footerRows.map((r, i) => (
<tr key={r.join() + i}>
{r.concat(Array(columns - r.length).fill("")).map((c) => {
const child = t.children?.find((child) => child.raw === c);
return (
<td key={r.join() + i + c}>
{child?.render(child) || c}
</td>
);
})}
</tr>
))}
</tfoot>
)}
</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;
};
@@ -635,14 +865,16 @@ function findMatchingClosedParenthesis(
const openingMatch = openRegex.exec(str);
const closingMatch = closedRegex.exec(str);
if ((openingMatch && !closingMatch)) {
if (openingMatch && !closingMatch) {
throw Error("Things have gone horribly wrong");
}
// if ((!openingMatch && closingMatch) || (!openingMatch && !closingMatch)) break;
if (
openingMatch && closingMatch && openingMatch.index < closingMatch.index
openingMatch &&
closingMatch &&
openingMatch.index < closingMatch.index
) {
openings++;
lastOpeningSuccessIndex = openingMatch.index + openingMatch[0].length;
@@ -685,7 +917,8 @@ function search(
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;
@@ -699,10 +932,11 @@ function search(
// Finds a unique id for things like headings
function generateId(t: string, usedIds: string[]) {
let id = t.toLowerCase().replace(/[^a-z\s]/ig, "").trim().replaceAll(
" ",
"-",
);
let id = t
.toLowerCase()
.replace(/[^a-z\s-\d]/gi, "")
.trim()
.replaceAll(" ", "-");
let idNum = 1;
while (usedIds.includes(id)) {
id = id.replace(/-[0-9]+$/g, "");

View File

@@ -11,6 +11,11 @@ export const createElements = (body: string): Token[] => {
const tokenize = (body: string) => {
const tokenizedBody: TokenMarker[] = [];
body = body
.trim()
.replaceAll(/[ \t]+\n/g, "\n")
.replaceAll(/\n{3,}/g, "\n\n");
const addToken = (thing: TokenMarker) => {
tokenizedBody.push(thing);
};
@@ -107,7 +112,7 @@ type ParentChildMap = {
};
const parentChildMap: ParentChildMap = {
"list": ["list-item"],
list: ["list-item"],
// Add more mappings as needed...
};
@@ -116,7 +121,7 @@ function isAcceptableChild(parentType: string, childType: string): boolean {
return acceptableChildren ? acceptableChildren.includes(childType) : true;
}
// Occasionally, some P blocks start exactly at the same point as another block (a side effect of needing to exclude preceding linebreaks from the regex while also having the only clear delineation being those linebreaks) so we just remove those P blocks so that when searching for a parent, it doesn't need to figure out if the P block is valid or not. This doesn't cause issues during rendering since each block handles its own container element
// Occasionally, some P blocks start exactly at the same point as another block (a side effect of needing to exclude preceding line-breaks from the regex while also having the only clear delineation being those line-breaks) so we just remove those P blocks so that when searching for a parent, it doesn't need to figure out if the P block is valid or not. This doesn't cause issues during rendering since each block handles its own container element
function filterOverlappingPBlocks(blocks: TokenMarker[]): TokenMarker[] {
return blocks.filter((block) => {
if (block.type !== "p") {
@@ -126,10 +131,8 @@ function filterOverlappingPBlocks(blocks: TokenMarker[]): TokenMarker[] {
for (const otherBlock of blocks) {
if (
otherBlock !== block &&
(
otherBlock.start === block.start ||
(otherBlock.end === block.end && otherBlock.start < block.start)
)
(otherBlock.start === block.start ||
(otherBlock.end === block.end && otherBlock.start < block.start))
) {
return false;
}
@@ -153,18 +156,20 @@ const contentToChildren = (token: Token) => {
}
token.children = zipArrays(
content.split(splitMarker).map((c): Token => ({
content: c.replaceAll("\n", ""),
content.split(splitMarker).map(
(c): Token => ({
content: c.replaceAll("\n", " "),
metadata: {},
raw: c,
type: token.rendersChildrenOnly ? "p" : "text",
uuid: crypto.randomUUID(),
rendersContentOnly: token.rendersChildrenOnly ? false : true,
render: TokenRenderers.get(token.rendersChildrenOnly ? "p" : "text")!,
children: token.rendersChildrenOnly && c.replaceAll("\n", "")
children:
token.rendersChildrenOnly && c.replaceAll("\n", "")
? [
{
content: c.replaceAll("\n", ""),
content: c.replaceAll("\n", " "),
metadata: {},
raw: c,
type: "text",
@@ -174,7 +179,8 @@ const contentToChildren = (token: Token) => {
},
]
: undefined,
})),
}),
),
token.children || [],
).filter((c) => c.children?.length || (c.rendersContentOnly && c.content));
};
@@ -266,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

@@ -0,0 +1,149 @@
export class TTCQueryParser {
private data: QueryableObject;
private relativeMap: Map<QueryableObject, QueryableObject>;
constructor(data: QueryableObject) {
this.data = data;
this.relativeMap = new Map();
this.buildRelativeMap(this.data, null);
}
public search(
query: string,
currentObject: QueryableObject = this.data
): any[] {
// Normalize the query string by trimming whitespace
query = query.trim();
// Determine the base structure to search
let result: any[] = [];
// Perform initial parsing based on the query syntax
if (query.startsWith("^")) {
result = this.queryCurrentObject(query, currentObject);
} else if (query.startsWith("$")) {
result = this.queryRelativeObject(query, currentObject);
} else {
result = this.searchInObject(this.data, query);
}
return result;
}
private queryCurrentObject(
query: string,
currentObject: QueryableObject
): any[] {
// Example implementation for querying the current object
const subQuery = query.substring(1); // Remove '^'
return this.searchInObject(currentObject, subQuery);
}
private queryRelativeObject(
query: string,
currentObject: QueryableObject
): any[] {
const relativeObject = this.relativeMap.get(currentObject);
if (!relativeObject) {
throw new Error("Relative object not found.");
}
const subQuery = query.substring(1); // Remove '$'
return this.searchInObject(relativeObject, subQuery);
}
private buildRelativeMap(
obj: QueryableObject,
relative: QueryableObject | null
): void {
if (obj.relative) {
relative = obj;
}
this.relativeMap.set(obj, relative || obj);
for (const key in obj) {
if (typeof obj[key] === "object" && obj[key] !== null) {
this.buildRelativeMap(obj[key], relative);
}
}
}
private searchInObject(obj: any, subQuery?: string): any[] {
// Handle subqueries and search in the provided object
if (!subQuery) {
return [obj]; // Return the entire object if no subquery is present
}
// Split the subquery based on dot (.) to navigate through the object
const keys = subQuery.split(".");
let current: any = obj;
for (const key of keys) {
if (current && typeof current === "object") {
if (key.includes("[") && key.includes("]")) {
const [prop, selector] = key.split("[");
const index = selector.slice(0, -1);
if (Array.isArray(current[prop])) {
if (!isNaN(Number(index))) {
current = current[prop][Number(index)];
} else {
const [k, comparator, value] = this.parseSelector(index);
current = this.applySelector(current[prop], k, comparator, value);
}
} else {
return [];
}
} else {
current = Array.isArray(current)
? current.map((e: any) => e[key])
: current[key];
}
} else {
return [];
}
}
return Array.isArray(current) ? current : [current];
}
private parseSelector(selector: string): [string, string, string] {
const match = selector.match(/(.+)(=|==|>|<|>=|<=|!!|!|!=)(.+)/);
if (match) {
return [match[1], match[2], match[3]];
}
return ["", "=", selector];
}
private applySelector(
array: any[],
key: string,
comparator: string,
value: string
): any[] {
switch (comparator) {
case "=":
return array.filter((item) => (key ? item[key] : item).includes(value));
case "==":
return array.filter((item) => (key ? item[key] : item) === value);
case ">":
return array.filter((item) => (key ? item[key] : item) > value);
case "<":
return array.filter((item) => (key ? item[key] : item) < value);
case ">=":
return array.filter((item) => (key ? item[key] : item) >= value);
case "<=":
return array.filter((item) => (key ? item[key] : item) <= value);
case "!!":
return array.filter((item) => (key ? item[key] : item));
case "!=":
return array.filter(
(item) => !(key ? item[key] : item).includes(value)
);
case "!":
return array.filter((item) => !(key ? item[key] : item));
default:
return [];
}
}
}

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

@@ -1,47 +1,137 @@
---
title: How to use ttcMD
author: Emmaline Autumn
date: 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)
- [Custom Elements](#custom-elements)
- [Pop-outs](#pop-outs)
- [Block-level Elements](#block-level-elements)
- [Accordions](#accordions)
- [Card](#card)
- [Block](#block)
- [Grid](#grid)
- [Query Elements](#query-elements)
- [Resolver](#resolver)
- [On-demand Resolver](#on-demand-resolver)
- [Query Block Template](#query-block-template)
- [Query Block](#query-block)
---
# 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:**
**Primary Button:**
Prefix the link name with ````button` to create a button.
`[```button link name](#links)` produces:
Prefix the link name with `~~button` to create a button.
`[~~button link name](#links)` produces:
[```button link name](#links)
[~~button link name](#links)
**Call to Action:**
**Call to Action:**
Prefix the link name with ````cta` to create a modestly styled button/call to action.
`[```cta link name](#links)` produces:
Prefix the link name with `~~cta` to create a modestly styled button/call to action.
`[~~cta link name](#links)` produces:
[```cta link name](#links)
[~~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:
[][][]
With a header:
```
| Table | Header | Row |
| <---- | ------ | --> |
| Table | Body | Row 1 |
| Table | Body | Row 2 |
| Table | Body | Row 3 |
```
Without a header:
```
| <---- | ------ | --> |
| Table | Body | Row 1 |
| Table | Body | Row 2 |
| Table | Body | Row 3 |
```
As you can see, it makes use of the default separator line and uses `<` and `>` to denote that it should be full width. Note that the length of the dashes does not matter, all that matters is that the separator line starts with `| <-` and ends with `-> |`.
[[!2
Additionally, you can specify if a column is meant to be centered by using `^` inside the separator. For example, `| <--- | -^- | ---> |` will make the second column of all table rows centered. Its positioning within the column doesn't matter.
]]
/[]
There is also an additional feature of adding a table footer by using a second separator. This second separator does not have any affect on the width or centering of the footer columns.
**Examples:**
[][][]
Normal table:
| Table | Header | Row |
| ---- | ------ | -- |
| Table | Body | Row 1 |
| Table | Body | Row 2 |
| Table | Body | Row 3 |
Full width:
| <---- | ----- | --> |
| Table | Body | Row 1 |
| Table | Body | Row 2 |
| Table | Body | Row 3 |
Full width with a centered body column
| Table | Header | Row |
| <---- | ---^-- | --> |
| Table | Body | Row 1 |
| Table | Body | Row 2 |
| Table | Body | Row 3 |
/[]
---
## 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.
@@ -50,14 +140,18 @@ The syntax is thus: `^[pop-out title]<<pop-out content>>`. The pop-out title wil
Example:
This syntax `^[goofy!]<<This is my *favorite* picture: ![goofy](https://yt3.ggpht.com/a/AATXAJwbIW0TwEhqdT2ZPeSB1AtdtWD2ZXam80oijg=s900-c-k-c0xffffffff-no-rj-mo)>>` will produce this element: ^[goofy!]<<This is my *favorite* picture: ![goofy](https://yt3.ggpht.com/a/AATXAJwbIW0TwEhqdT2ZPeSB1AtdtWD2ZXam80oijg=s900-c-k-c0xffffffff-no-rj-mo)>>
This syntax `^[This is my favorite image]<<*Goofy!* ![goofy](https:///example.com/image.png)>>` will produce this element: ^[This is my favorite image]<<*Goofy!* ![goofy](https://yt3.ggpht.com/a/AATXAJwbIW0TwEhqdT2ZPeSB1AtdtWD2ZXam80oijg=s900-c-k-c0xffffffff-no-rj-mo)>>
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.
@@ -85,8 +179,47 @@ I can include a [link](#accordions), or *italic* and **bold** text.
I can even include a card, like this one
]]
[/accordion]
[[!
[accordion Accordions look great stacked!]
Conveniently group data together in stacked accordions
[/accordion]
[accordion It's great for saving a lot of space]
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Deserunt, nesciunt repellat ipsa unde, reiciendis nostrum molestiae necessitatibus tempore ea neque quae nihil veritatis autem recusandae nam eaque facilis rerum quos nisi quam temporibus! Quasi nostrum iste praesentium consequatur a itaque. Eaque temporibus aliquam ex tempore rem iste at in itaque?
Sint iure nam maiores dolorem dolor officia omnis eos. Odio, corporis sunt totam accusamus, ut necessitatibus molestias molestiae error numquam suscipit quas obcaecati aspernatur qui atque optio unde minima hic autem saepe, nihil debitis cumque vel. Placeat magnam nesciunt sequi suscipit provident dolore commodi corrupti, porro facere assumenda, distinctio magni?
Suscipit illum temporibus sequi! Nulla excepturi hic tempore nihil deleniti. Hic tempora debitis iure inventore, dolores facilis, sequi dolorem veritatis corrupti dignissimos voluptate consequuntur quaerat quidem totam, odio odit quisquam harum? Consequuntur, doloribus. Necessitatibus rem quidem, saepe eum nobis, tenetur nihil iusto debitis incidunt repudiandae molestiae, corrupti rerum deleniti. Numquam.
Recusandae iusto distinctio cupiditate quam in aliquid cum ipsa et cumque adipisci voluptatum fuga expedita voluptate assumenda error hic, doloribus beatae libero animi ratione aut modi? Ex reprehenderit facere explicabo quo iusto ad minima nihil. Minus magni quidem architecto at fugiat quo, ab ipsa tenetur facilis, optio iusto deleniti illo.
Eaque dicta iusto reiciendis natus qui excepturi nesciunt asperiores totam nam, reprehenderit perspiciatis amet beatae libero. Laudantium labore aliquam eveniet ab minima ut sunt laboriosam consequuntur alias impedit magnam voluptatem ea at, soluta nisi tenetur rem officia rerum facilis perferendis voluptas nulla incidunt. Sequi, facere vero doloremque saepe magni consequatur!
Veritatis quod eveniet expedita quae explicabo, nobis enim reiciendis repellat maiores accusantium id doloribus magni dolorem quis qui illum perferendis repudiandae nisi aut maxime doloremque nulla quam fuga aspernatur? Unde sapiente nisi hic, voluptatibus facilis rem omnis saepe asperiores numquam vitae perferendis eligendi nihil temporibus! Commodi voluptatibus eos perspiciatis quaerat?
Natus impedit quasi voluptates libero vel dicta totam vitae expedita nulla, saepe doloremque veniam, beatae molestiae quod! Dolores, doloribus est! Iste asperiores fuga, ab esse, aliquid enim laborum delectus rem doloribus earum voluptates? Quibusdam dolorum esse voluptatem quas nisi aspernatur? Accusamus est totam recusandae rerum nisi accusantium dignissimos reprehenderit sapiente!
Debitis ratione molestiae veniam iure quibusdam quae, eos voluptate adipisci! Tempore blanditiis illum optio ea debitis praesentium, iure pariatur neque nisi facere, unde velit eos expedita dolores fugiat! Accusamus iusto amet perferendis adipisci natus iure sunt assumenda aut, esse tempore provident, repudiandae officiis dolor deserunt cumque aspernatur unde voluptatum suscipit!
Asperiores maiores expedita qui officia laudantium eos molestiae iusto obcaecati, odio numquam cumque dolores laborum consequatur voluptatem dolore voluptatum corrupti repudiandae doloribus quisquam? Ratione maxime aut molestiae rem suscipit, ab animi corrupti ullam deleniti aliquam, doloremque nemo ducimus quisquam laboriosam nobis debitis! Corporis maxime atque cum nulla consequatur asperiores facere!
Veniam necessitatibus molestiae explicabo nam vel ipsam non sapiente tempora quo nesciunt ullam, accusamus illo! Voluptatem tempore, quos magnam vitae similique deleniti mollitia. Totam, eligendi corrupti. Commodi ullam tenetur, quo ipsum, quam reiciendis eaque assumenda voluptates consequatur, aliquam perferendis praesentium beatae esse dignissimos iste quidem voluptatibus molestiae qui culpa exercitationem?
[/accordion]
[accordion And can be very useful for secret items in games]
Let's just pretend that this accordion holds a poker hand instead of a random picture.
You'll never believe me when I tell you that this legitimately is one of my favorite images of all time.
![goofy](https://yt3.ggpht.com/a/AATXAJwbIW0TwEhqdT2ZPeSB1AtdtWD2ZXam80oijg=s900-c-k-c0xffffffff-no-rj-mo)
[/accordion]
]]
/[]
---
### Card
Cards are just neat boxes. They can hold any markdown within them, but not other cards (it looks bad).
@@ -103,8 +236,8 @@ super secret! I'll never tell!
[/accordion]
]]
```
[[
[[
Card text!
This is a real wild thing! Look, an accordion!
@@ -123,11 +256,46 @@ And hurt you.
[/accordion]
]]
[[2
Additionally, you can specify a number after the opening brackets (`[[2 ... ]]`) to specify how many grid columns the card should cover, just like this card!
]]
/[]
---
### Block
[][][]
An unstyled version of the card is the Block block. This is for grouping items in a grid that should be in the same cell. Same syntax, but you simply add an exclamation point after the opening brackets
```
[[!
Any markup :D
]]
```
[[!
Normally all of these paragraphs would end up in their own cell. However, since I put them in a block, they will be kept together.
And it's not just paragraphs that you can group together, you can use any other ttcMD element, like this picture!
![goofy](https://yt3.ggpht.com/a/AATXAJwbIW0TwEhqdT2ZPeSB1AtdtWD2ZXam80oijg=s900-c-k-c0xffffffff-no-rj-mo)
]]
[[!2
Additionally, you can specify a number after the opening brackets (`[[!2 ... ]]`) to specify how many grid columns the block should cover, just like this block!
]]
/[]
---
### Grid
Grid blocks give you access to basic grid layouts. You define the number of columns in the grid by using a number of matching brackets.
Grid blocks give you access to basic grid layouts. You define the number of columns in the grid by using a number of matching brackets. On smaller screens or containers, grids are ignored, turned into a single column.
[][][]
[[
@@ -169,3 +337,79 @@ 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
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 ??<<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>>` 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: `??[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: `??[Roll 2d6](Rolling $0, you got: $1)<<_.path.to.dice,$0.roll>>`
??[Roll 2d6](Rolling $0, you got: $1)<<_.path.to.dice,$0.roll>>
]]
[[
For drawing a card and having it show the card's display: `??[]()<<_.path.to.deck,$0.draw,$1.display>>`
]]
/[]
---
### Query Block Template
The Query Block Template is the keystone of the query elements. It allows you to query for an object and use that object as the root for rendering a block of markdown. It has not been fully implemented yet.
[][][]
Syntax:
```
??<<QueryValue>>[[
Any markdown.
]]
```
/[]
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
Unlike the Query Block Template, it does not change the `_` variable to the queried value. For resolvers to work with the queried data, you need to access it with the `$$` variable. `$$` will be iterated over and will act similar to the Query Block Template, but on a per-line basis, meaning that if a line contains the `$$` variable, it will render that line of markdown for each result of the query. This is primarily useful for something like a List or a Table.
Syntax:
```
Still working on the syntax, it's difficult coming up with all of this on the fly when it needs to be unique and easily parsed
```

View File

@@ -0,0 +1,82 @@
---
title: THe basics of schemas and objects
author: Emmaline Autumn
date: June 11th, 2024
updated: June 11th, 2024
---
# Table of Contents
- [Table of Contents](#table-of-contents)
- [What is a schema?](#what-is-a-schema)
- [Schema Templates](#schema-templates)
- [Schema and Type Fields](#schema-and-type-fields)
- [Schema Types](#schema-types)
- [Default Types](#default-types)
- [Special Types](#special-types)
- [How to make a schema](#how-to-make-a-schema)
---
# What is a schema?
A schema is a representation of the structure of a collection of data. In TTC, a schema is how we define the structure of game objects. Schemas have three parts: a template, fields, and types.
Schemas are effectively describing a relationship of parent objects and child objects as a collection of key-value pairs.
## Schema Templates
The template is how the object will be represented when rendered in markdown. It works in a similar way to Query Block Templates, with access to the `_` root variable.
By default, there is a simple template that renders them out as a key-value pair. The default template looks like this:
[][][]
```
??<<_.!!>>[[
# ??<<_.key>> $${{_.value}}
]]
```
/[]
## Schema and Type Fields
Schema fields are the description of the parent-child structure. Fields are named and can be left blank or filled in at any point of a publication's lifecycle.
If a field is rendered, it checks if it has a value in the publication or not. If it does, it will render the type's template. If it does not have a value, it will render an appropriate input for the type of the field. The exception is "required" fields. These require that the base (first) publication using that schema is required to have a value.
Fields by default are limited to a maximum of 1 entry, but you can increase the limit or set it to unlimited.
## Schema Types
Schema types are almost like mini-schemas that go inside of a schema. A schema holds all objects related to a discipline inside the rules (e.g. a Codex in Warhammer 40k, or an expansion), but a type is used to describe the fields inside of that schema (e.g. a datasheet for a unit in Warhammer 40k).
Types can be made up of other types as well. For example, the built in table type is made up of table rows which are made up of table columns.These compound types allow you to make better, more complex schemas that can do more.
Types also have a template. By default, it uses the same template as the schema to just render all of its contents, but you can customize it to your needs using ttcMD.
### Default Types
There are 5 default schema field types: section, steps, image, list, and table. These types have basic templates that you can't change that help build more complex types or even just build quick and dirty layouts.
There are also type field types, such as text, number, long text, and checkbox.
Each type has different limitations that you can enforce in the publications. Number has a minimum and a maximum, text has a maximum length.
### Special Types
Special types have specific programmed behaviors. These include decks of cards, dice, select and any. I am trying to figure out a good way to make it possible for you to make your own behaviors, but that is not top priority at the moment.
# How to make a schema
1. Inside of a game system, you will click "create new schema." This will take you to the schema editor. The first thing to do is to give your schema a name. This should be unique in the context of the game system.
2. You will then either create your custom types or add fields to the schema below. Let's start by creating our own type by giving it an name and clicking "configure." We will move into an area of the editor that allows us to add fields. Let's give this type two fields, name them count and description.
3. For count, let's select a number for the field type and set the minimum to 0. Since we only want 1 count, let's set the Limit field to 1 and the Minimum field to 1.
4. For the description, let's make it constant. You'll notice that there is now an additional field that you can use to input a constant value, let's describe this type as "A sample schema type that holds a count."
5. Now that we've finished with this type, click "save type." It will be added to the list of Types in the schema viewer on the side where you can review.
6. Go up to the schema fields and let's add a field by typing the name for the field and clicking add. Let's call ours "Population"
7. In the list below, our new field has been added. We can now change the type of that field by typing it into the Type field. It will populate suggestions as you type.
8. Now you can set the limits of the field.
9. Lastly, and most importantly, click "Save Schema" at the top right.

View File

@@ -1,4 +1,20 @@
| test | Table | header |
-------------------------
| test | table | row |
| look | another |
- hello
- everybody
- yes you
- my
- name
- is
- welcome
1. hello
2. everybody
3. my
4. name
5. is
6. welcome
- [-\_hello1234!@#$%^\&\*()-](#-_hello1234-)
# -_hello1234!@#$%^&*()-

View File

@@ -1,10 +1,13 @@
[[
Tabletop Commander (TC) is a rules-and-tools app for tabletop games - board, card, war, role-playing, you name it! It is the spiritual successor of Chapter Master by Emmaline Autumn, a Warhammer 40,000 9th Edition rules reference and game helper.
Tabletop Commander (TTC) is a rules-and-tools app for tabletop games - board, card, war, role-playing, you name it! It is the spiritual successor of Chapter Master by Emmaline Autumn, a Warhammer 40,000 9th Edition rules reference and game helper.
Emma decided to move on from Chapter Master as her interest in 40k was supplanted by the greater wargaming hobby after the release of 10th edition made clear that Chapter Master was too inflexible and tedious to work on.
Emma decided to move on from Chapter Master as her interest in Warhammer 40k waned, replaced by a broader enthusiasm for the wargaming hobby. The release of the 10th edition highlighted Chapter Master's inflexibility and tediousness, prompting her to seek new avenues.
See, Emma had a vision that anyone could contribute to making rules corrections so that anyone could have all of the rules as they currently exist. This ballooned into the idea that you could have all the rules as they existed at *any point in time.* As soon as she realized that every code change either needed to keep that backward compatibility in mind or would cause the data to no longer be usable, she decided to drop Chapter Master entirely.
It didn't sit right with her. A big project no longer being worked on and a dead dream. Enter Tabletop Commander. Inspired by the flexibility of Battlescribe and disappointed in its features and lack of updates, Emma started designing a new system, from the ground up, that can be used to build almost anything.
See, Emma had a vision that anyone could contribute to making rules corrections so that anyone could have all of the rules as they currently exist. This ballooned into the idea that you could have all the rules as they existed at *any time*
]]
[][][]
@@ -13,7 +16,7 @@ See, Emma had a vision that anyone could contribute to making rules corrections
### Game Systems
The basis of TC is called a Game System Package. This package
The basis of TTC is called a Game System. This package
includes everything needed for a game system, including schemas,
publications, and tools. Players can follow a Game System to get
consistently updated content publications, or fork it to
@@ -32,7 +35,7 @@ your approval and contributions carry.
If your score is high enough, and a contribution request has
enough approvals, you can even be the one to merge it in!
[```cta Learn More](/help/Game%20Systems.md)
[~~cta Learn More](/help/Game%20Systems.md)
]]
@@ -41,22 +44,22 @@ enough approvals, you can even be the one to merge it in!
### Schemas
Those who have studied English or databases, you would know that
a schema is a structural pattern. TC aims to provide a simple,
a schema is a structural pattern. TTC aims to provide a simple,
user-edited and maintained schema system for *any* game.
If that flew over your head, don&apos;t worry. Others can share
the schemas they&apos;ve made with everyone, which come as part
If that flew over your head, don't worry. Others can share
the schemas they've made with everyone, which come as part
of a Game System package that you can fork or follow to get both
content and schemas ready to use.
**For the techies:**
The schema system makes use of a powerful custom query language
(tcQuery) I designed. By writing queries directly into the
(ttcQuery) I designed. By writing queries directly into the
schema, we can reduce the amount of re-written content, while
maintaining the presence of data anywhere we need it.
[```cta Learn More](/help/Schemas.md)
[~~cta Learn More](/help/Schemas.md)
]]
@@ -65,7 +68,7 @@ maintaining the presence of data anywhere we need it.
### Publications
Publications are the actual content of the rules. They
don&apos;t just contain the content, but also the style in which
don't just contain the content, but also the style in which
the content is shown.
Content can include text, images, and even video (through
@@ -75,15 +78,23 @@ parts of the publication through context based pop-overs.
**For the techies (again):**
Publications use an enhanced markdown syntax (ttcMD) that
implements tcQuery, and adds a bit of custom syntax for things
implements ttcQuery, and adds a bit of custom syntax for things
like pop-overs and styling hints for rendering.
The styling aspect is similar to a very trimmed down CSS, but
can accomplish quite a lot. For example, this page is actually
built using ttcMD!
Though it uses markdown as its base syntax, ttcMD could more accurately be called a templating language as it contains a lot of custom elements to handle different layouts
[```cta Learn More](/help/Publications.md)
[~~cta Learn More](/help/Publications.md)
]]
[[2
Want to keep up with TTC? Join us over at the CyborgGrizzly Games Discord server! Come discuss tabletop gaming, stay updated on the latest developments with Tabletop Commander, and collaborate with fellow gamers to create and update game systems. It's the perfect spot to connect with like-minded enthusiasts and be part of shaping the future of tabletop gaming. Don't miss out hop on the Discord server today!
Disclaimer: *I'm so sorry, I had ChatGPT write that last paragraph. I tried to save it, but it's just so... corporate*
[~~cta Join the Discord](https://discord.gg/bePt7MQHQA)
]]
/[]

View File

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

1
notmiddleware.ts Normal file
View File

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

9432
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,24 +6,40 @@
"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.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",
"jotai": "^2.9.3",
"next": "^14.2.5",
"next-auth": "^5.0.0-beta.20",
"react": "^18",
"react-dom": "^18"
"react-dom": "^18",
"recoil": "^0.7.7",
"url-loader": "^4.1.1"
},
"devDependencies": {
"typescript": "^5",
"@svgr/webpack": "^8.1.0",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
"postcss": "^8",
"tailwindcss": "^3.3.0",
"eslint": "^8",
"eslint-config-next": "14.1.0"
"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

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

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

13
prisma/prismaClient.ts Normal file
View File

@@ -0,0 +1,13 @@
import { PrismaClient } from "@prisma/client";
const prismaClientSingleton = () => {
return new PrismaClient();
};
declare global {
var prismaGlobal: undefined | ReturnType<typeof prismaClientSingleton>;
}
export const prisma = globalThis.prismaGlobal ?? prismaClientSingleton();
if (process.env.NODE_ENV !== "production") globalThis.prismaGlobal = prisma;

175
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,175 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model GameSystem {
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())
isPublic Boolean @default(false)
thingUsers User[] @relation("currentGame")
}
model Schema {
id String @id @default(cuid())
gameSystem GameSystem? @relation(fields: [gameSystemId], references: [id])
gameSystemId String?
publications Publication[]
author User @relation(fields: [authorId], references: [id])
authorId String
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
schemaVersion Int
schemaRevision SchemaRevision @relation(fields: [schemaVersion, schemaId], references: [version, schemaId])
PublicationRevision PublicationRevision[]
tags Tag[]
name String
// 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())
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[]
accounts Account[]
sessions Session[]
PublicationRevision PublicationRevision[]
followedSystems GameSystemFollows[]
currentGameSystem GameSystem? @relation("currentGame", fields: [currentGameSystemId], references: [id])
currentGameSystemId String?
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" } } }),
);

17
recoil/atoms/schema.ts Normal file
View File

@@ -0,0 +1,17 @@
// 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>({
name: "",
id: "",
types: {},
fields: {},
version: 0,
});

View File

@@ -46,8 +46,11 @@ const config: Config = {
height: {
variable: "var(--v-height)",
},
gridColumn: {
variable: "span var(--v-span) / span var(--v-span)",
},
},
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,19 +18,11 @@
}
],
"paths": {
"@/*": [
"./*"
]
"@/*": ["./*"]
},
"target": "es2022"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"],
"files": ["global.d.ts", "types.d.ts", "./components/mdeditor/TextEditor.tsx"]
}

88
types.d.ts vendored
View File

@@ -1,5 +1,8 @@
type IdentifiedToken = {
metadata: Record<string, string>;
import { FieldTypes } from "./components/schema/fieldtypes";
// MD Parser
type IdentifiedToken<M> = {
metadata: M;
children?: Token[];
uuid: string;
raw: string;
@@ -8,21 +11,94 @@ type IdentifiedToken = {
rendersContentOnly?: boolean;
};
type TokenRenderer = (t: Token) => ReactNode;
type TokenRenderer<M> = (t: Token<M>) => ReactNode;
type TokenAttributes = {
type: string;
render: TokenRenderer;
};
type Token = IdentifiedToken & TokenAttributes;
type Token<M = Record<string, string>> = IdentifiedToken<M> & TokenAttributes;
type TokenMarker = {
type TokenMarker<M = Record<string, string>> = {
start: number;
end: number;
type: string;
parent?: TokenMarker;
token: Token;
token: Token<M>;
};
type FrontMatter = Record<string, string>;
type SearchFunction = (
s: string,
start: number,
end: number,
) => {
start: number;
end: number;
text: string;
lastIndex: number;
};
type TokenIdentifier<M> = {
rx: RegExp;
parse: (s: string) => Token<M>;
search?: SearchFunction;
};
type TokenIdentifierMap = Map<string, TokenIdentifier<any>>;
type IdentifierRegistration = <N = Record<string, string>>(
type: string,
match: RegExp,
parseFunction: (s: string, rx: RegExp) => IdentifiedToken<N>,
renderFunction: TokenRenderer<N>,
openTagRx?: RegExp,
closeTagRx?: RegExp,
) => void;
// Schema
type MetadataType = {
[key: string]: string;
};
type FieldType = {
type: FieldTypes;
value: string;
isConstant: boolean;
limit: number;
minimum: number;
};
type TypeType = Record<string, FieldType>;
type Template = {
type: string;
display: string;
};
type SchemaFields = Record<string, FieldTypes>;
type SchemaTypes = Record<string, TypeType>;
type Schema = {
id: string;
name: string;
fields: SchemaFields;
types: SchemaTypes;
version: number;
gameSystemId?: string | null;
};
// Input Binder
type InputBinder = {
name: string;
value: string | number;
onChange: (
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;
}
}