Tabletop Commander (TC) is a rules-and-tools app for tabletop games
@@ -135,13 +137,11 @@ export default function Home() {
will be done. If this makes it to production, tell Emma she forgot to
turn the home page into magic
-
+ */
+ }
{
- "use server";
- return await readFile("./test.md", "utf-8");
- })()}
+ body={readMD()}
/>
>
diff --git a/components/tcmd/index.tsx b/components/tcmd/index.tsx
index 3ac6992..5f5413e 100644
--- a/components/tcmd/index.tsx
+++ b/components/tcmd/index.tsx
@@ -1,5 +1,7 @@
"use client";
+import { Accordion, AccordionContent } from "@/lib/accordion";
+import { Poppable } from "@/lib/poppables/components/poppable";
import { createElements } from "@/lib/tcmd";
import Link from "next/link";
import React, { FC, Fragment, ReactNode, use, useMemo } from "react";
@@ -12,7 +14,9 @@ export const TCMD: FC<{ body: Promise }> = ({ body }) => {
// {JSON.stringify(elements,null,2)}
//
}> = ({ body }) => {
const renderBlock = (block: BlockChildren): ReactNode => {
switch (block.type) {
case "block":
- return block.children.map(renderBlock);
+ return block.children.map((e, i) => (
+ {renderBlock(e)}
+ ));
case "grid":
return (
{
className="grid grid-cols-dynamic gap-x-8 gap-y-6 mb-6"
>
{block.children.map((c, i) => (
-
+
{renderBlock(c)}
-
+
))}
);
case "card":
- return {block.children.map(renderBlock)}
;
+ return (
+
+ {block.children.map((e, i) => (
+
+ {renderBlock(e)}
+
+ ))}
+
+ );
+ case "accordion":
+ return (
+
+
+ {block.children.map((e, i) => (
+
+ {renderBlock(e)}
+
+ ))}
+
+
+ );
default:
return (
renderParagraph(block as ParagraphToken)
@@ -48,7 +76,15 @@ const renderBlock = (block: BlockChildren): ReactNode => {
const renderParagraph = (p: ParagraphToken) => {
switch (p.type) {
case "p":
- return {p.content.map(renderToken)}
;
+ return (
+
+ {p.content.map((e, i) => (
+
+ {renderToken(e)}
+
+ ))}
+
+ );
case "code":
return (
@@ -57,9 +93,9 @@ const renderParagraph = (p: ParagraphToken) => {
);
default:
return (
-
+
Block or paragraph missing implementation: {p.type}
-
+
);
}
};
@@ -68,45 +104,49 @@ const renderToken = (t: Token) => {
switch (t.type) {
case "h1":
return (
-
{renderInlineToken(t.line)}
-
+
);
case "h2":
return (
-
{renderInlineToken(t.line)}
-
+
);
case "h3":
return (
-
{renderInlineToken(t.line)}
-
+
);
case "p":
return (
-
- {t.lines.map(renderToken)}
-
+
+ {t.lines.map((e, i) => (
+
+ {renderInlineToken(e.line)}
+
+ ))}
+
);
case "text":
return (
@@ -121,10 +161,10 @@ const renderToken = (t: Token) => {
return
{renderInlineToken(t.line)};
default:
return (
-
+
Missing implementation for tcMD element `{(t as { type: string })
.type}`
-
+
);
}
};
@@ -132,27 +172,44 @@ const renderToken = (t: Token) => {
const renderInlineToken = (l: Line) => {
if (typeof l === "string") return l;
- return l.map((token) => {
- switch (token.type) {
- case "text":
- return {token.content};
- case "bold":
- return {token.content};
- case "anchor":
- return (
-
- {token.content}
-
- );
- case "image":
- // eslint-disable-next-line @next/next/no-img-element
- return
;
- default:
- return (
-
- Inline element not implemented: {token.type}
-
- );
- }
- });
+ return l.map((token) => (
+
+ {(() => {
+ switch (token.type) {
+ case "text":
+ return {token.content};
+ case "bold":
+ return {token.content};
+ case "anchor":
+ return (
+
+ {token.content}
+
+ );
+ case "image":
+ // eslint-disable-next-line @next/next/no-img-element
+ return
;
+ case "popover":
+ return (
+
+
+ {token.content}
+
+
+ );
+ default:
+ return (
+
+ Inline element not implemented: {token.type}
+
+ );
+ }
+ })()}
+
+ ));
};
diff --git a/hooks/useDebounce.ts b/hooks/useDebounce.ts
new file mode 100644
index 0000000..cd4646a
--- /dev/null
+++ b/hooks/useDebounce.ts
@@ -0,0 +1,12 @@
+import { useEffect, useState } from 'react';
+
+export const useDebounce = (value: any, delay: number) => {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ const handler = setTimeout(() => setDebouncedValue(value), delay);
+ return () => clearTimeout(handler);
+ }, [value, delay]);
+
+ return debouncedValue;
+};
\ No newline at end of file
diff --git a/hooks/useInput.ts b/hooks/useInput.ts
new file mode 100644
index 0000000..8df66e9
--- /dev/null
+++ b/hooks/useInput.ts
@@ -0,0 +1,40 @@
+import { useState, ChangeEvent } from 'react';
+
+type InputHookConfig = {
+ disallowSpaces?: boolean;
+ spaceReplacer?: string;
+}
+
+export const useInput = (initialValue: T, config?: InputHookConfig) => {
+ const [value, setValue] = useState(initialValue);
+
+ return {
+ value,
+ setValue,
+ reset: () => setValue(initialValue),
+ bind: {
+ value: value,
+ onChange: (event: ChangeEvent) => {
+ const changed: string | number = typeof initialValue === 'number' ? parseInt(event.target.value) : config?.disallowSpaces ? event.target.value.replace(' ', config.spaceReplacer || '_') : event.target.value;
+ setValue(changed as T);
+ }
+ }
+};
+};
+
+export const useCheckbox = (initial: boolean) => {
+ const [value, setValue] = useState(initial);
+
+ return {
+ value,
+ setValue,
+ reset: () => setValue(initial),
+ bind: {
+ checked: value,
+ onClick: () => {
+ setValue(v => !v);
+ },
+ readOnly: true
+ }
+ };
+};
\ No newline at end of file
diff --git a/hooks/useObjectState.ts b/hooks/useObjectState.ts
new file mode 100644
index 0000000..08363cd
--- /dev/null
+++ b/hooks/useObjectState.ts
@@ -0,0 +1,126 @@
+import React, { ChangeEvent, useCallback, useState } from "react";
+import { InputBinder } from "../types/inputBinder";
+
+type ObjectStateHookConfig = {
+ disallowSpaces?: boolean;
+ spaceReplacer?: string;
+};
+
+export const useObjectState = (initial: T) => {
+ const [state, setState] = useState(initial || {} as T);
+
+ const bindProperty = useCallback(
+ (property: K, config?: ObjectStateHookConfig) => ({
+ value: state[property] ?? "",
+ name: property,
+ onChange: (
+ event: ChangeEvent<
+ HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
+ >,
+ ) =>
+ setState((value) => (
+ {
+ ...value,
+ [event.target.name]: (
+ (typeof value[property] === "number")
+ ? Number(event.target.value) || 0
+ : config?.disallowSpaces
+ ? event.target.value.replace(" ", config.spaceReplacer || "_")
+ : event.target.value
+ ),
+ }
+ )),
+ }),
+ [state],
+ );
+
+ const bindPropertyCheck = useCallback((property: K) => ({
+ checked: !!state[property],
+ name: property,
+ onChange: (event: ChangeEvent) =>
+ setState((value) => ({
+ ...value,
+ [event.target.name]: (event.target.checked),
+ })),
+ readOnly: true,
+ }), [state]);
+
+ const update = useCallback(
+ (updates: Partial) => setState((s) => ({ ...s, ...updates })),
+ [],
+ );
+
+ const reset = useCallback(() => {
+ setState(initial);
+ }, [initial]);
+
+ return {
+ bindProperty,
+ bindPropertyCheck,
+ update,
+ state,
+ setState,
+ reset,
+ };
+};
+
+export const useObjectStateWrapper = (
+ state: T,
+ setState: React.Dispatch>,
+) => {
+ const bindProperty = useCallback(
+ (
+ property: K,
+ config?: ObjectStateHookConfig,
+ ): InputBinder => ({
+ value: state[property]?.toString() ?? "",
+ name: property.toString(),
+ onChange: (
+ event: ChangeEvent<
+ HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
+ >,
+ ) =>
+ setState((value) => (
+ {
+ ...value,
+ [event.target.name]: (
+ (typeof value[property] === "number")
+ ? Number(event.target.value) || 0
+ : config?.disallowSpaces
+ ? event.target.value.replace(" ", config.spaceReplacer || "_")
+ : event.target.value
+ ),
+ }
+ )),
+ }),
+ [setState, state],
+ );
+
+ const bindPropertyCheck = useCallback((property: K) => ({
+ checked: !!state[property],
+ name: property,
+ onChange: (event: ChangeEvent) =>
+ setState((value) => ({
+ ...value,
+ [event.target.name]: (event.target.checked),
+ })),
+ readOnly: true,
+ }), [setState, state]);
+
+ const update = useCallback(
+ (updates: Partial | ((arg: T) => Partial)) =>
+ setState((s) => ({
+ ...s,
+ ...(typeof updates === "function" ? updates(s) : updates),
+ })),
+ [setState],
+ );
+
+ return {
+ bindProperty,
+ bindPropertyCheck,
+ update,
+ state,
+ setState,
+ };
+};
diff --git a/hooks/useQueryParams.ts b/hooks/useQueryParams.ts
new file mode 100644
index 0000000..c167a48
--- /dev/null
+++ b/hooks/useQueryParams.ts
@@ -0,0 +1,10 @@
+import { useEffect, useRef } from 'react'
+import { QueryParams } from '../utils/queryParams'
+
+export const useQueryParams = (options?: {clean: boolean}) => {
+ const queryParams = useRef(new QueryParams(options))
+
+ useEffect(() => {
+ console.log(queryParams.current.get('test'))
+ }, [queryParams.current])
+}
\ No newline at end of file
diff --git a/hooks/useRefCallback.ts b/hooks/useRefCallback.ts
new file mode 100644
index 0000000..7685c68
--- /dev/null
+++ b/hooks/useRefCallback.ts
@@ -0,0 +1,19 @@
+import { ReactNode, useCallback, useRef } from 'react'
+
+export const useRefCallback = () : [T | null, (arg: T) => void] => {
+ const ref = useRef(null);
+ const setRef = useCallback((val: T) => {
+ console.log(val);
+ if (ref.current) {
+ // does something?
+ }
+
+ if (val) {
+ // also does something?
+ }
+
+ ref.current = val
+ }, [])
+
+ return [ref.current, setRef]
+}
\ No newline at end of file
diff --git a/lib/accordion/index.tsx b/lib/accordion/index.tsx
new file mode 100644
index 0000000..14b0908
--- /dev/null
+++ b/lib/accordion/index.tsx
@@ -0,0 +1,80 @@
+import { FC, PropsWithChildren, ReactNode, useCallback, useState } from "react";
+
+interface IProps {
+ expandOnHover?: boolean;
+ expanded?: boolean;
+ title?: ReactNode;
+}
+
+export const Accordion: FC> = (
+ { children, expandOnHover, expanded, title },
+) => {
+ const [open, setOpen] = useState(false);
+
+ return (
+ !title && !expandOnHover && setOpen(!open)}
+ >
+ {!!title && (
+
!expandOnHover && setOpen(!open)}
+ >
+ {title}
+
+
+
+
+
+
+
+ )}
+ {children}
+
+ );
+};
+
+export const AccordionContent: FC = ({ children }) => {
+ const [height, setHeight] = useState(0);
+
+ const updateRef = useCallback((node: HTMLDivElement | null) => {
+ if (node) {
+ setHeight(node.clientHeight);
+ } else {
+ setHeight(0);
+ }
+ }, []);
+
+ const Child = () => (
+
+ {children}
+
+ );
+
+ return (
+
+ {}
+
+
+ );
+};
diff --git a/lib/poppables/components/poppable-content.tsx b/lib/poppables/components/poppable-content.tsx
new file mode 100644
index 0000000..e507caf
--- /dev/null
+++ b/lib/poppables/components/poppable-content.tsx
@@ -0,0 +1,170 @@
+import {
+ Dispatch,
+ FC,
+ PropsWithChildren,
+ SetStateAction,
+ useCallback,
+ useEffect,
+ useState,
+} from "react";
+import { bulkRound } from "../../utils/bulkRound";
+import { clamp } from "../../utils/clamp";
+import { Portal } from "../../portal/components";
+
+type edge = "top" | "bottom" | "left" | "right";
+type alignment = edge | "centered";
+
+interface IProps {
+ preferredEdge: edge;
+ preferredAlign: alignment;
+ relativeElement: HTMLElement;
+ spacing?: number;
+ isClosing: boolean;
+ isClosed: boolean;
+ setHover: Dispatch>;
+}
+
+type position = { top: number; left: number; width?: number };
+
+export const PoppableContent: FC> = (
+ {
+ preferredAlign,
+ preferredEdge,
+ children,
+ relativeElement,
+ spacing = 10,
+ setHover,
+ isClosing,
+ isClosed,
+ },
+) => {
+ const [popRef, setPopRef] = useState();
+ const updateRef = useCallback((node: HTMLDivElement) => {
+ if (!node) return;
+ setPopRef(node);
+ }, []);
+
+ const getAlignment = useCallback(
+ (
+ relX: number,
+ relY: number,
+ relWidth: number,
+ relHeight: number,
+ popWidth: number,
+ popHeight: number,
+ edge: edge,
+ align: alignment,
+ ): position => {
+ const pos = {
+ top: relY,
+ left: relX,
+ };
+
+ switch (align) {
+ case "centered":
+ pos.top = relY + (relHeight / 2) - (popHeight / 2);
+ pos.left = relX + (relWidth / 2) - (popWidth / 2);
+ break;
+ case "top":
+ pos.top = relY;
+ break;
+ case "bottom":
+ pos.top = relY + relHeight - popHeight;
+ break;
+ case "left":
+ pos.left = relX;
+ break;
+ case "right":
+ pos.left = relX + relWidth - popWidth;
+ break;
+ }
+
+ return pos;
+ },
+ [],
+ );
+
+ const getPosition = useCallback(
+ (popWidth: number, popHeight: number, edge: edge, align: alignment) => {
+ const rel = relativeElement.getBoundingClientRect();
+ const [relX, relY, relWidth, relHeight] = bulkRound(
+ rel.x,
+ rel.y,
+ rel.width,
+ rel.height,
+ );
+
+ const pos: position = { top: 100, left: 100 };
+ const alignment = getAlignment(
+ relX,
+ relY,
+ relWidth,
+ relHeight,
+ popWidth,
+ popHeight,
+ edge,
+ align,
+ );
+
+ switch (edge) {
+ case "top":
+ pos.top = relY - popHeight - spacing +
+ document.documentElement.scrollTop;
+ pos.left = alignment.left;
+ break;
+ case "bottom":
+ pos.top = relY + relHeight + spacing +
+ document.documentElement.scrollTop;
+ pos.left = alignment.left;
+ break;
+ case "left":
+ pos.left = relX - popWidth - spacing;
+ pos.top = alignment.top + document.documentElement.scrollTop;
+ break;
+ case "right":
+ pos.left = relX + relWidth + spacing;
+ pos.top = alignment.top + document.documentElement.scrollTop;
+ break;
+ }
+
+ return pos;
+ },
+ [getAlignment, relativeElement, spacing],
+ );
+
+ const getClampedPosition = useCallback(() => {
+ if (!popRef) return { opacity: 0 };
+
+ const pop = popRef.getBoundingClientRect();
+ const [popWidth, popHeight] = bulkRound(pop.width, pop.height);
+
+ const pos = getPosition(popWidth, popHeight, preferredEdge, preferredAlign);
+ const { innerHeight, innerWidth } = window;
+
+ pos.top = ["left", "right"].includes(preferredEdge)
+ ? clamp(pos.top, spacing, innerHeight - popHeight - spacing)
+ : pos.top;
+ pos.left = ["top", "bottom"].includes(preferredEdge)
+ ? clamp(pos.left, spacing, innerWidth - popWidth - spacing)
+ : pos.left;
+
+ return pos;
+ }, [popRef, getPosition, preferredEdge, preferredAlign, spacing]);
+
+ return (
+
+ setHover(true)}
+ onMouseLeave={() => setHover(false)}
+ // className="absolute w-[400px] border"
+ className="poppable"
+ >
+ {children}
+
+
+ );
+};
diff --git a/lib/poppables/components/poppable.tsx b/lib/poppables/components/poppable.tsx
new file mode 100644
index 0000000..8e84bb1
--- /dev/null
+++ b/lib/poppables/components/poppable.tsx
@@ -0,0 +1,59 @@
+import {
+ FC,
+ PropsWithChildren,
+ ReactNode,
+ useCallback,
+ useEffect,
+ useState,
+} from "react";
+import { PoppableContent } from "./poppable-content";
+import { useDebounce } from "../../../hooks/useDebounce";
+
+interface IProps {
+ content: ReactNode;
+ className?: string;
+ preferredEdge: "top" | "bottom" | "left" | "right";
+ preferredAlign: "centered" | "top" | "bottom" | "left" | "right";
+ spacing?: number;
+}
+
+export const Poppable: FC> = (
+ { 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();
+
+ const updateRef = useCallback((node: HTMLElement) => {
+ if (!node) return;
+ setRef(node);
+ }, []);
+
+ return (
+ <>
+ setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
+ >
+ {children}
+
+ {!!ref && (
+
+ {content}
+
+ )}
+ >
+ );
+};
diff --git a/lib/portal/components/index.ts b/lib/portal/components/index.ts
new file mode 100644
index 0000000..7a2287e
--- /dev/null
+++ b/lib/portal/components/index.ts
@@ -0,0 +1,27 @@
+import { FC, PropsWithChildren, useEffect, useState } from "react";
+import { createPortal } from "react-dom";
+
+interface IProps {
+ className?: string;
+ el?: string;
+}
+
+export const Portal: FC> = (
+ { 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.getElementById("root-portal")!;
+ });
+
+ useEffect(() => {
+ container.classList.add(className);
+ document.body.appendChild(container);
+ return () => {
+ document.body.removeChild(container);
+ };
+ }, [className, container]);
+
+ return createPortal(children, container);
+};
diff --git a/lib/tcmd/index.ts b/lib/tcmd/index.ts
index c8cc216..714ca52 100644
--- a/lib/tcmd/index.ts
+++ b/lib/tcmd/index.ts
@@ -21,7 +21,6 @@ const tokenize = (body: string) => {
let openBT = blockTokens.findLast((bt) => !bt.closed);
if (block) {
if (typeof block === "string") {
- console.log(block);
if (openBT) {
openBT.closed = true;
}
diff --git a/lib/tcmd/inlineTokens.ts b/lib/tcmd/inlineTokens.ts
index a6543fe..9f9c2e2 100644
--- a/lib/tcmd/inlineTokens.ts
+++ b/lib/tcmd/inlineTokens.ts
@@ -24,7 +24,7 @@ export const inlineTokens: {
},
},
{
- rx: /(? args.map(n => Math.round(n));
\ No newline at end of file
diff --git a/lib/utils/clamp.ts b/lib/utils/clamp.ts
new file mode 100644
index 0000000..a03d7d1
--- /dev/null
+++ b/lib/utils/clamp.ts
@@ -0,0 +1,2 @@
+export const clamp = (value: number, min: number, max: number) =>
+ Math.max(Math.min(value, max), min)
\ No newline at end of file
diff --git a/tailwind.config.ts b/tailwind.config.ts
index 927e016..1a868f0 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -4,8 +4,10 @@ const config: Config = {
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
+ "./lib/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
+
theme: {
extend: {
backgroundImage: {
@@ -42,6 +44,9 @@ const config: Config = {
gridTemplateColumns: {
dynamic: "repeat(var(--grid-cols), minmax(0,1fr))",
},
+ height: {
+ variable: "var(--v-height)",
+ },
},
},
plugins: [],
diff --git a/test.md b/test.md
index 0618200..aebefb2 100644
--- a/test.md
+++ b/test.md
@@ -4,16 +4,17 @@
[[
-Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
-incididunt ut labore et dolore magna aliqua. Sollicitudin tempor id eu nisl nunc
-mi ipsum faucibus vitae. Lobortis elementum nibh tellus molestie nunc. Purus non
-enim praesent elementum facilisis leo vel. Orci nulla pellentesque dignissim
-enim sit amet venenatis. Eu feugiat pretium nibh ipsum. Gravida dictum fusce ut
-placerat orci nulla pellentesque. Tincidunt vitae semper quis lectus nulla at
-volutpat diam ut. Proin sed libero enim sed faucibus turpis in eu mi. Dui sapien
-eget mi proin sed libero enim sed faucibus. Felis donec et odio pellentesque
-diam volutpat commodo sed egestas. Massa tincidunt dui ut ornare lectus sit amet
-est placerat. Auctor urna nunc id cursus metus aliquam eleifend.
+Lorem ^[ipsum](This is a popover test) dolor sit amet, consectetur adipiscing
+elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
+Sollicitudin tempor id eu nisl nunc mi ipsum faucibus vitae. Lobortis elementum
+nibh tellus molestie nunc. Purus non enim praesent elementum facilisis leo vel.
+Orci nulla pellentesque dignissim enim sit amet venenatis. Eu feugiat pretium
+nibh ipsum. Gravida dictum fusce ut placerat orci nulla pellentesque. Tincidunt
+vitae semper quis lectus nulla at volutpat diam ut. Proin sed libero enim sed
+faucibus turpis in eu mi. Dui sapien eget mi proin sed libero enim sed faucibus.
+Felis donec et odio pellentesque diam volutpat commodo sed egestas. Massa
+tincidunt dui ut ornare lectus sit amet est placerat. Auctor urna nunc id cursus
+metus aliquam eleifend.
- Lorem ipsum dolor sit amet,
- consectetur adipiscing elit, bananana banana ban anana anaba bananananana
@@ -74,6 +75,16 @@ const blockTokens: {
[[
+[accordion this is the title]
+
+this is the test of a single accordion
+
+[/accordion]
+
+]]
+
+[[
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
incididunt ut labore et dolore magna aliqua. Sollicitudin tempor id eu nisl nunc
mi ipsum faucibus vitae. Lobortis elementum nibh tellus molestie nunc. Purus non
diff --git a/types.d.ts b/types.d.ts
index ed0fd86..21f524b 100644
--- a/types.d.ts
+++ b/types.d.ts
@@ -1,5 +1,5 @@
type InlineToken = {
- type: "text" | "bold" | "anchor" | "image";
+ type: "text" | "bold" | "anchor" | "image" | "popover";
content: string;
data?: any;
};
@@ -42,7 +42,7 @@ type MultilineCfg = {
// -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
type BlockToken = {
- type: "block" | "grid" | "card";
+ type: "block" | "grid" | "card" | "accordion";
metadata: any;
children: BlockChildren[];
parent?: string;