Compare commits

...

4 Commits

Author SHA1 Message Date
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
27 changed files with 6143 additions and 31 deletions

3
.gitignore vendored
View File

@ -27,6 +27,7 @@ yarn-error.log*
# local env files # local env files
.env*.local .env*.local
.env
# vercel # vercel
.vercel .vercel
@ -40,3 +41,5 @@ next-env.d.ts
temp.json temp.json
temp.md temp.md
.dragonshoard/

View File

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

View File

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

View File

@ -0,0 +1,49 @@
import { Sticky } from "@/lib/sticky";
import { prisma } from "@/prisma/prismaClient";
export default async function GameSystem(
{ params: { id } }: { params: { id: string } },
) {
if (!id) throw "HOW DID YOU GET HERE?";
const gameSystem = await prisma.gameSystem.findFirst({
where: {
id,
},
select: {
id: true,
name: true,
schemas: {
select: {
name: true,
id: true,
publications: {
select: {
name: true,
id: true,
},
},
},
},
},
});
return (
<>
<section className="heading">
<h2 className="strapline">Game System</h2>
<h1>{gameSystem?.name}</h1>
</section>
<section>
<ul>
{gameSystem?.schemas.map((schema) => (
<li key={schema.id}>{schema.name}</li>
))}
</ul>
</section>
<Sticky sidedness={-1}>
<h1>HELLO!</h1>
</Sticky>
</>
);
}

View File

@ -0,0 +1,26 @@
"use client";
import { deleteAllGameSystems } from "@/actions/GameSystems/deleteAll";
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,35 @@
import { prisma } from "@/prisma/prismaClient";
import { redirect } from "next/navigation";
export default function CreateGameSystem() {
async function create(form: FormData) {
"use server";
const name = form.get("name")?.toString();
if (!name) return;
const { id } = await prisma.gameSystem.create({
data: {
name,
},
select: {
id: true,
},
});
redirect(`/game-systems/${id}`);
}
return (
<form action={create}>
<input
type="text"
// {...bind}
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>
);
}

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

@ -0,0 +1,33 @@
import { prisma } from "@/prisma/prismaClient";
import CreateGameSystem from "./create";
import { GameSystemsClient } from "./client";
import Link from "next/link";
export default async function GameSystems() {
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

@ -1,5 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Inter } from "next/font/google"; import { Roboto } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { import {
BookOpenIcon, BookOpenIcon,
@ -11,7 +11,7 @@ import {
import Link from "next/link"; import Link from "next/link";
import { DevToolboxContextProvider } from "@/components/devtools/context"; import { DevToolboxContextProvider } from "@/components/devtools/context";
const inter = Inter({ subsets: ["latin"] }); const roboto = Roboto({ subsets: ["latin"], weight: "400" });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Tabletop Commander", title: "Tabletop Commander",
@ -51,11 +51,9 @@ export default function RootLayout({
}, },
]; ];
console.log(process.env.NODE_ENV);
return ( return (
<html lang="en"> <html lang="en">
<body className={inter.className + " flex min-h-[100vh]"}> <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"> <nav className="h-[100vh] sticky top-0 left-0 bottom-0 p-8 rounded-r-3xl dark:bg-mixed-300 bg-primary-400 w-max shadow-2xl">
<h1 className="text-lg font-bold pb-6 border-b dark:border-dark-500 border-primary-600"> <h1 className="text-lg font-bold pb-6 border-b dark:border-dark-500 border-primary-600">
<Link href="/">Tabletop Commander</Link> <Link href="/">Tabletop Commander</Link>

BIN
bun.lockb

Binary file not shown.

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

@ -1,5 +1,5 @@
import { Portal } from "@/lib/portal/components"; import { Portal } from "@/lib/portal/components";
import { FC, PropsWithChildren, use, useEffect, useState } from "react"; import { FC, use, useState } from "react";
import { DevToolboxContext } from "./context"; import { DevToolboxContext } from "./context";
import { WrenchScrewdriverIcon } from "@heroicons/react/24/solid"; import { WrenchScrewdriverIcon } from "@heroicons/react/24/solid";
import { XMarkIcon } from "@heroicons/react/16/solid"; import { XMarkIcon } from "@heroicons/react/16/solid";
@ -38,15 +38,3 @@ export const DevToolbox: FC = () => {
) )
: <></>; : <></>;
}; };
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

@ -34,7 +34,6 @@ export const DevToolboxContextProvider: FC<
> = ( > = (
{ children, isDev }, { children, isDev },
) => { ) => {
console.log(isDev);
const [tools, setTools] = useState<Record<string, ReactNode>>({}); const [tools, setTools] = useState<Record<string, ReactNode>>({});
const [shouldShowDevTools, setShouldShowDevTools] = useState(isDev); const [shouldShowDevTools, setShouldShowDevTools] = useState(isDev);

View File

@ -37,8 +37,6 @@ export const TTCMD: FC<Props> = (
setHasEscapedTOC(escapeTOC(toc)); setHasEscapedTOC(escapeTOC(toc));
}, [escapeTOC, toc]); }, [escapeTOC, toc]);
console.log("mdId", parserId);
return ( return (
<Suspense fallback={<MDSkeletonLoader />}> <Suspense fallback={<MDSkeletonLoader />}>
<DevTool id={parserId}> <DevTool id={parserId}>

5
global.d.ts vendored Normal file
View File

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

View File

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

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

@ -0,0 +1,99 @@
// import { mkdirSync, readFileSync, writeFileSync } from "fs";
// import { writeFile } from "fs/promises";
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 });
// writeFileSync(this.cacheLocation, "{}", { encoding: "utf-8", flag: "wx" });
// 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() {
// const cache = readFileSync(this.cacheLocation, "utf-8");
// this.cache = JSON.parse(cache || "{}");
// }
// private async writeDiskCache() {
// await writeFile(this.cacheLocation, JSON.stringify(this.cache), "utf-8");
// }
private 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 = () => 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>
);
};

View File

@ -1,4 +1,6 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = {}; const nextConfig = {
output: "standalone",
};
export default nextConfig; export default nextConfig;

5606
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -10,8 +10,10 @@
}, },
"dependencies": { "dependencies": {
"@heroicons/react": "^2.1.1", "@heroicons/react": "^2.1.1",
"@prisma/client": "^5.11.0",
"isomorphic-dompurify": "^2.4.0", "isomorphic-dompurify": "^2.4.0",
"next": "14.1.0", "next": "14.1.0",
"prisma": "^5.11.0",
"react": "^18", "react": "^18",
"react-dom": "^18" "react-dom": "^18"
}, },

View File

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

View File

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

View File

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

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;

49
prisma/schema.prisma Normal file
View File

@ -0,0 +1,49 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model GameSystem {
id String @id @default(cuid())
schemas Schema[]
name String @unique
created DateTime @default(now())
}
model Schema {
id String @id @default(cuid())
gameSystem GameSystem @relation(fields: [gameSystemId], references: [id])
gameSystemId String
publications Publication[]
name String
schema Json
version Int
}
model Publication {
id String @id @default(cuid())
schema Schema @relation(fields: [schemaId], references: [id])
schemaId String
tags Tag[]
name String
data Json
}
model Tag {
id String @id @default(cuid())
publication Publication @relation(fields: [publicationId], references: [id])
publicationId String
}

View File

@ -32,9 +32,12 @@
"next-env.d.ts", "next-env.d.ts",
"**/*.ts", "**/*.ts",
"**/*.tsx", "**/*.tsx",
".next/types/**/*.ts" ".next/types/**/*.ts",
], ],
"exclude": [ "exclude": [
"node_modules" "node_modules"
],
"files": [
"global.d.ts"
] ]
} }