diff --git a/apps/web/package.json b/apps/web/package.json index b783ab8..ec24129 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -35,6 +35,7 @@ "react-dom": "^18.3.1", "react-hook-form": "^7.53.1", "react-router-dom": "^6.27.0", + "superjson": "^2.2.1", "ts-pattern": "^5.5.0" }, "devDependencies": { diff --git a/apps/web/src/auth-test.tsx b/apps/web/src/auth-test.tsx deleted file mode 100644 index 77859c8..0000000 --- a/apps/web/src/auth-test.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { signIn, signOut, useSession } from "@manifold/auth/client"; - -export function AuthTest() { - const { data: session, status } = useSession(); - - if (status === "loading") { - return null; - } - - if (status === "unauthenticated" || !session?.user) { - return ( - - ); - } - - return ( - <> -

Welcome back, {session.user.name}

- - - ); -} diff --git a/apps/web/src/features/auth/components/auth-guard.tsx b/apps/web/src/features/auth/components/auth-guard.tsx index 3922f13..7ad4d43 100644 --- a/apps/web/src/features/auth/components/auth-guard.tsx +++ b/apps/web/src/features/auth/components/auth-guard.tsx @@ -1,12 +1,17 @@ import { useSession } from "@manifold/auth/client"; +import { LoadingIndicator } from "@manifold/ui/components/loading-indicator"; +import { FlexCol } from "@manifold/ui/components/ui/flex"; import { Navigate, Outlet } from "react-router-dom"; export function AuthGuard() { const { status } = useSession(); if (status === "loading") { - // @TODO: better loading state, suspense, router? - return null; + return ( + + + + ); } if (status === "authenticated") { diff --git a/apps/web/src/features/dashboard/components/table-list/index.tsx b/apps/web/src/features/dashboard/components/table-list/index.tsx index efb88f7..fe51bb0 100644 --- a/apps/web/src/features/dashboard/components/table-list/index.tsx +++ b/apps/web/src/features/dashboard/components/table-list/index.tsx @@ -5,6 +5,7 @@ import { CardHeader, CardTitle, } from "@manifold/ui/components/ui/card"; +import { cn } from "@manifold/ui/lib/utils"; import { formatRelative } from "date-fns"; import { Link } from "react-router-dom"; @@ -29,7 +30,14 @@ export function TableList() { Recently Edited: - + {data.map((table) => { return (
@@ -39,7 +47,10 @@ export function TableList() { variant="link" asChild > - +

{table.title}

diff --git a/apps/web/src/features/dashboard/pages/root/page.tsx b/apps/web/src/features/dashboard/pages/root/page.tsx index 6c3d03c..db1feeb 100644 --- a/apps/web/src/features/dashboard/pages/root/page.tsx +++ b/apps/web/src/features/dashboard/pages/root/page.tsx @@ -1,12 +1,14 @@ +import { FlexCol } from "@manifold/ui/components/ui/flex"; + import { DashboardHeader } from "~features/dashboard/components/dashboard-header"; import { TableList } from "~features/dashboard/components/table-list"; export function DashboardRoot() { return ( -
+ -
+ ); } diff --git a/apps/web/src/features/editor/editor.tsx b/apps/web/src/features/editor/editor.tsx index 6f41fb7..a1b8e8d 100644 --- a/apps/web/src/features/editor/editor.tsx +++ b/apps/web/src/features/editor/editor.tsx @@ -1,3 +1,4 @@ +import { FlexCol } from "@manifold/ui/components/ui/flex"; import { ResizableHandle, ResizablePanel, @@ -36,35 +37,34 @@ export function Editor({ }, []); return ( - - - } - name={name} - value={value ?? ""} - onChange={onChange} - onBlur={onBlur} - refCallback={refCallback} - onParseError={onParseError} - onParseSuccess={onParseSuccess} - /> - + + + + } + name={name} + value={value ?? ""} + onChange={onChange} + onBlur={onBlur} + refCallback={refCallback} + onParseError={onParseError} + onParseSuccess={onParseSuccess} + /> + - + - -
- - -
-
-
+ + + + + + +
+ ); } diff --git a/apps/web/src/features/editor/input-panel.tsx b/apps/web/src/features/editor/input-panel.tsx index 4fce77f..d4a357d 100644 --- a/apps/web/src/features/editor/input-panel.tsx +++ b/apps/web/src/features/editor/input-panel.tsx @@ -7,7 +7,7 @@ import { } from "react"; import type { RefCallBack } from "react-hook-form"; -import { currentTableHash, currentTableMetadata } from "./state"; +import { currentTableHash, currentTableMetadata, rollHistory } from "./state"; import { workerInstance } from "./worker"; type Props = { @@ -32,7 +32,7 @@ export function InputPanel({ onParseSuccess, }: Props) { const setTableHash = useSetAtom(currentTableHash); - + const setRollResults = useSetAtom(rollHistory); const setTableMetadata = useSetAtom(currentTableMetadata); const parseAndValidate = useCallback( @@ -43,11 +43,12 @@ export function InputPanel({ setTableHash(hash); setTableMetadata(metadata); - onParseSuccess(); } else { setTableHash(null); setTableMetadata([]); } + + onParseSuccess(); } catch (e: unknown) { console.error(e); @@ -70,6 +71,13 @@ export function InputPanel({ if (value) { parseAndValidate(value); } + + return () => { + // oof, maybe I can put the jotai atoms into a context? + setRollResults([]); + setTableHash(null); + setTableMetadata([]); + }; }, []); // eslint-disable-line react-hooks/exhaustive-deps return ( diff --git a/apps/web/src/features/routing/root/layout.tsx b/apps/web/src/features/routing/root/layout.tsx index 0c133e7..2f0cf75 100644 --- a/apps/web/src/features/routing/root/layout.tsx +++ b/apps/web/src/features/routing/root/layout.tsx @@ -55,12 +55,25 @@ export function RootLayout() { { + onCreateTable={async () => { closeCommandPalette(); /** * @NOTE: Give the dialog a chance to close before navigating so that * `autoFocus` works as expected on the subsequent page. + * + * This raises a warning in the console related to + * `@radix-ui/react-dialog` calling `hideOthers` from the + * `aria-hidden` package which sets `aria-hidden` on the body element. + * + * > Blocked aria-hidden on an element because its descendant retained + * > focus. The focus must not be hidden from assistive technology + * > users. Avoid using aria-hidden on a focused element or its + * > ancestor. Consider using the inert attribute instead, which will + * > also prevent focus. + * + * That package supports `inert`, but it hasn't been adopted by Radix + * as of right now. */ requestAnimationFrame(() => { navigate("/table/new"); diff --git a/apps/web/src/features/table/components/table-create-form/index.tsx b/apps/web/src/features/table/components/table-create-form/index.tsx index ac2f323..6872c9c 100644 --- a/apps/web/src/features/table/components/table-create-form/index.tsx +++ b/apps/web/src/features/table/components/table-create-form/index.tsx @@ -1,3 +1,4 @@ +import type { TableModel } from "@manifold/db/schema/table"; import { Form, FormControl, @@ -20,7 +21,7 @@ type FormData = z.infer; export function TableCreateForm({ onCreate, }: { - onCreate: (id: string) => void; + onCreate: (table: TableModel) => void; }) { const form = useZodForm({ schema: tableCreateInput, @@ -34,7 +35,7 @@ export function TableCreateForm({ const onSubmit: SubmitHandler = async (data) => { const table = await createTableMutation.mutateAsync(data); - onCreate(table.id); + onCreate(table); }; return ( diff --git a/apps/web/src/features/table/components/table-update-form/index.tsx b/apps/web/src/features/table/components/table-update-form/index.tsx index 6e7dd5a..ba83740 100644 --- a/apps/web/src/features/table/components/table-update-form/index.tsx +++ b/apps/web/src/features/table/components/table-update-form/index.tsx @@ -1,3 +1,5 @@ +import type { TableModel } from "@manifold/db/schema/table"; +import { FlexCol } from "@manifold/ui/components/ui/flex"; import { Form, FormControl, @@ -19,15 +21,13 @@ import { Header } from "./header"; type FormData = z.infer; export function TableUpdateForm({ - id, - title, - initialDefinition, + table, onUpdate, + isDisabled = false, }: { - id: string; - title: string; - initialDefinition: string; + table: TableModel; onUpdate?: (id: string) => void | Promise; + isDisabled?: boolean; }) { const form = useZodForm({ mode: "onChange", @@ -55,16 +55,19 @@ export function TableUpdateForm({ }), }), defaultValues: { - id, - definition: initialDefinition, + id: table.id, + definition: table.definition, }, }); const updateTableMutation = trpc.table.update.useMutation(); useEffect(() => { - form.reset({ id, definition: initialDefinition }); - }, [form, id, initialDefinition]); + form.reset({ + id: table.id, + definition: table.definition, + }); + }, [form, table.id, table.definition]); const handleSubmit: SubmitHandler = async (data) => { try { @@ -88,42 +91,45 @@ export function TableUpdateForm({ return (
- -
-
- Save Changes -
+ + + +
+
+ Save Changes +
- { - const { ref, ...props } = field; + { + const { ref, ...props } = field; - return ( - - - - + return ( + + + + + - - - ); - }} - /> -
- + + +
+ ); + }} + /> +
+ + + ); } diff --git a/apps/web/src/features/table/pages/edit/page.tsx b/apps/web/src/features/table/pages/edit/page.tsx index 9efc2c8..9d19856 100644 --- a/apps/web/src/features/table/pages/edit/page.tsx +++ b/apps/web/src/features/table/pages/edit/page.tsx @@ -1,4 +1,6 @@ -import { useParams } from "react-router-dom"; +import { LoadingIndicator } from "@manifold/ui/components/loading-indicator"; +import { FlexCol } from "@manifold/ui/components/ui/flex"; +import { useLocation, useParams } from "react-router-dom"; import { TableUpdateForm } from "~features/table/components/table-update-form"; import { RoutingError } from "~utils/errors"; @@ -6,35 +8,43 @@ import { trpc } from "~utils/trpc"; export function TableEdit() { const { id } = useParams(); + const location = useLocation(); if (!id) { throw new RoutingError("No ID provided"); } - const query = trpc.table.get.useQuery(id); + const query = trpc.table.get.useQuery(id, { + placeholderData: location.state?.table, + }); + const trpcUtils = trpc.useUtils(); if (query.isLoading) { - return
Loading...
; + // @TODO: replace with skeleton + return ( + + + + ); } if (query.isSuccess && query.data) { const table = query.data; return ( -
+ Promise.all([ - trpcUtils.table.list.invalidate(), - trpcUtils.table.get.invalidate(id), + trpcUtils.table.list.refetch(), + trpcUtils.table.get.refetch(id), ]) } /> -
+ ); } diff --git a/apps/web/src/features/table/pages/new/page.tsx b/apps/web/src/features/table/pages/new/page.tsx index 6459b2a..22284a8 100644 --- a/apps/web/src/features/table/pages/new/page.tsx +++ b/apps/web/src/features/table/pages/new/page.tsx @@ -19,7 +19,11 @@ export function TableNew() { - navigate(`/table/${id}/edit`)} /> + + navigate(`/table/${table.id}/edit`, { state: { table } }) + } + />
diff --git a/apps/web/src/utils/trpc.ts b/apps/web/src/utils/trpc.ts index 0cd1c4e..35d4a62 100644 --- a/apps/web/src/utils/trpc.ts +++ b/apps/web/src/utils/trpc.ts @@ -1,6 +1,7 @@ import type { AppRouter } from "@manifold/router"; import { httpBatchLink } from "@trpc/client"; import { createTRPCReact } from "@trpc/react-query"; +import superjson from "superjson"; /** * This type annotation feels unnecessary, but `tsc` chokes without it. @@ -12,6 +13,7 @@ export const trpc: ReturnType> = createTRPCReact(); export const trpcClient = trpc.createClient({ + transformer: superjson, links: [ httpBatchLink({ url: "http://localhost:5173/api/trpc", diff --git a/package-lock.json b/package-lock.json index 4038012..9c89f7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,6 +60,7 @@ "react-dom": "^18.3.1", "react-hook-form": "^7.53.1", "react-router-dom": "^6.27.0", + "superjson": "^2.2.1", "ts-pattern": "^5.5.0" }, "devDependencies": { @@ -87,6 +88,17 @@ "node": "20 || >=22" } }, + "apps/web/node_modules/superjson": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.1.tgz", + "integrity": "sha512-8iGv75BYOa0xRJHK5vRLEjE2H/i4lulTjzpUXic3Eg8akftYjkmQDa8JARQ42rlczXyFR3IeRoeFCc7RxHsYZA==", + "dependencies": { + "copy-anything": "^3.0.2" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -12317,6 +12329,7 @@ "@manifold/db": "*", "@manifold/validators": "*", "@trpc/server": "^10.45.2", + "superjson": "^2.2.1", "ts-deepmerge": "^7.0.1", "zod": "^3.23.8" }, @@ -12327,6 +12340,17 @@ "eslint-plugin-drizzle": "^0.2.3" } }, + "packages/router/node_modules/superjson": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.1.tgz", + "integrity": "sha512-8iGv75BYOa0xRJHK5vRLEjE2H/i4lulTjzpUXic3Eg8akftYjkmQDa8JARQ42rlczXyFR3IeRoeFCc7RxHsYZA==", + "dependencies": { + "copy-anything": "^3.0.2" + }, + "engines": { + "node": ">=16" + } + }, "packages/tabol-core": { "name": "@manifold/tabol-core", "extraneous": true diff --git a/packages/router/package.json b/packages/router/package.json index a0c0026..7a630b1 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -25,6 +25,7 @@ "@manifold/db": "*", "@manifold/validators": "*", "@trpc/server": "^10.45.2", + "superjson": "^2.2.1", "ts-deepmerge": "^7.0.1", "zod": "^3.23.8" } diff --git a/packages/router/src/trpc.ts b/packages/router/src/trpc.ts index 1be2944..5b15ef1 100644 --- a/packages/router/src/trpc.ts +++ b/packages/router/src/trpc.ts @@ -1,10 +1,12 @@ import { initTRPC, TRPCError } from "@trpc/server"; +import superjson from "superjson"; import { merge } from "ts-deepmerge"; import { ZodError } from "zod"; import type { Context } from "#types.ts"; export const t = initTRPC.context().create({ + transformer: superjson, errorFormatter({ shape, error }) { return merge(shape, { data: { diff --git a/packages/ui/src/components/command-palette.tsx b/packages/ui/src/components/command-palette.tsx index fee654a..cd39dd1 100644 --- a/packages/ui/src/components/command-palette.tsx +++ b/packages/ui/src/components/command-palette.tsx @@ -8,6 +8,7 @@ import { CommandItem, CommandList, } from "#components/ui/command.tsx"; +import { DialogDescription, DialogTitle } from "#components/ui/dialog.tsx"; // @TODO: Make this more generic, pass in a list of command groups, etc. export function CommandPalette({ @@ -28,6 +29,11 @@ export function CommandPalette({ } }} > + + Command Launcher + Quickly find common actions + + No results found. diff --git a/packages/ui/src/components/loading-indicator.tsx b/packages/ui/src/components/loading-indicator.tsx new file mode 100644 index 0000000..9183f1d --- /dev/null +++ b/packages/ui/src/components/loading-indicator.tsx @@ -0,0 +1,15 @@ +import { RiLoader3Line } from "react-icons/ri"; + +type Props = { + className?: string; + children?: string; +}; + +export function LoadingIndicator({ className, children }: Props) { + return ( +
+ + {children || "Loading"} +
+ ); +} diff --git a/packages/ui/src/components/ui/avatar.tsx b/packages/ui/src/components/ui/avatar.tsx index 178e32e..55fa44a 100644 --- a/packages/ui/src/components/ui/avatar.tsx +++ b/packages/ui/src/components/ui/avatar.tsx @@ -37,7 +37,7 @@ const AvatarFallback = React.forwardRef< { return ( - + {children} diff --git a/packages/ui/src/components/ui/flex.tsx b/packages/ui/src/components/ui/flex.tsx new file mode 100644 index 0000000..6280b79 --- /dev/null +++ b/packages/ui/src/components/ui/flex.tsx @@ -0,0 +1,24 @@ +import { Slot } from "@radix-ui/react-slot"; +import React from "react"; + +import { cn } from "#lib/utils.js"; + +export interface FlexColProps extends React.HTMLAttributes { + asChild?: boolean; +} + +const FlexCol = React.forwardRef( + ({ className, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "div"; + return ( + + ); + }, +); +FlexCol.displayName = "FlexCol"; + +export { FlexCol }; diff --git a/packages/ui/src/hooks/use-command-palette.ts b/packages/ui/src/hooks/use-command-palette.ts index 31e341b..14de8eb 100644 --- a/packages/ui/src/hooks/use-command-palette.ts +++ b/packages/ui/src/hooks/use-command-palette.ts @@ -16,7 +16,13 @@ export function useCommandPalette() { return () => document.removeEventListener("keydown", handleKeyDown); }, []); - const close = useCallback(() => setIsOpen(false), []); + const close = useCallback(() => { + setIsOpen(false); + + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); + }, []); return [isOpen, close] as const; }