feat: initial import.

This commit is contained in:
mattia
2025-01-04 14:56:35 +01:00
commit 7751d37249
33 changed files with 6201 additions and 0 deletions

0
src/client/App.css Normal file
View File

38
src/client/App.tsx Normal file
View File

@@ -0,0 +1,38 @@
import "@fontsource/roboto/300.css";
import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css";
import { BrowserRouter, Route, Routes } from "react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Sessions } from "./Sessions";
import { StrictMode } from "react";
import { ThemeProvider } from "@emotion/react";
import { CssBaseline } from "@mui/material";
import theme from "./theme";
import { SessionDetails } from "./SessionDetails";
import { Root } from "./Root";
const queryClient = new QueryClient();
export function App() {
return (
<StrictMode>
<ThemeProvider theme={theme}>
<CssBaseline />
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
<Route path="/" element={<Root />}>
<Route index element={<Sessions />} />
<Route
path="/session/:sessionId"
element={<SessionDetails />}
/>
</Route>
</Routes>
</BrowserRouter>
</QueryClientProvider>
</ThemeProvider>
</StrictMode>
);
}

46
src/client/Root.tsx Normal file
View File

@@ -0,0 +1,46 @@
import {
AppBar,
Breadcrumbs,
Container,
Link,
Stack,
Toolbar,
} from "@mui/material";
import {
Outlet,
Link as RouterLink,
useLocation,
useParams,
} from "react-router";
import "./theme";
export function Root() {
const location = useLocation();
const params = useParams();
return (
<Container maxWidth="lg">
<Stack spacing={2}>
<AppBar position="static">
<Toolbar>
<Breadcrumbs aria-label="breadcrumb">
<Link variant="breadcrumb" component={RouterLink} to="/">
Sessions
</Link>
{location.pathname.includes("/session/") && (
<Link
variant="breadcrumb"
component={RouterLink}
to={`/session/${params.sessionId}`}
>
{`${params.sessionId}`}
</Link>
)}
</Breadcrumbs>
</Toolbar>
</AppBar>
<Outlet />
</Stack>
</Container>
);
}

View File

@@ -0,0 +1,102 @@
import { useParams } from "react-router";
import { client } from "./client";
import {
Card,
CardContent,
List,
ListItem,
ListItemText,
Stack,
Typography,
} from "@mui/material";
import { useMemo } from "react";
import {
MaterialReactTable,
MRT_ColumnDef,
useMaterialReactTable,
} from "material-react-table";
import { GetSessionResponse } from "./contract";
import { format } from "date-fns";
export function SessionDetails() {
const { sessionId } = useParams<{ sessionId: string }>();
if (sessionId === undefined) {
throw new Error("session id is required");
}
const getSessionQuery = client.getSession.useQuery(["session", sessionId], {
params: { id: sessionId },
});
const columns = useMemo<
MRT_ColumnDef<GetSessionResponse["log_entries"][0]>[]
>(
() => [
{
accessorKey: "message",
header: "Message",
},
{
accessorKey: "timestamp",
header: "Timestamp",
Cell: (x) => <span>{format(x.row.original.timestamp, "Pp")}</span>,
},
{
accessorKey: "metadata",
header: "Metadata",
Cell: (x) => {
const metadata = x.row.original.metadata;
return (
<List dense>
{Object.keys(metadata)
.sort()
.map((key) => (
<ListItem key={key}>
<ListItemText>{key}: {metadata[key]}</ListItemText>
</ListItem>
))}
</List>
);
},
},
],
[]
);
const table = useMaterialReactTable({
columns,
data:
getSessionQuery.status === "success"
? getSessionQuery.data.body.log_entries
: [],
});
return getSessionQuery.status === "error" ? (
<p>Error</p>
) : (
<Stack spacing={2}>
<Card>
<CardContent>
<Typography sx={{ color: "text.secondary" }}>
Session ID: {sessionId}
</Typography>
<Typography variant="h5" component="div">
Game:{" "}
{getSessionQuery.status === "loading"
? "..."
: getSessionQuery.data.body.game_name}
</Typography>
<Typography variant="body2">
Version{" "}
{getSessionQuery.status === "loading"
? "..."
: getSessionQuery.data.body.version}
</Typography>
</CardContent>
</Card>
{getSessionQuery.status === "success" && (
<MaterialReactTable table={table} />
)}
</Stack>
);
}

142
src/client/Sessions.tsx Normal file
View File

@@ -0,0 +1,142 @@
import { client } from "./client";
import {
MaterialReactTable,
MRT_ColumnFiltersState,
MRT_Updater,
useMaterialReactTable,
type MRT_ColumnDef,
type MRT_PaginationState,
} from "material-react-table";
import { GetSessionsResponse } from "../common/contract";
import { format, formatDuration, interval, intervalToDuration } from "date-fns";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
export function Sessions() {
const [paginationState, setPaginationState] = useState<MRT_PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [columnFilters, setColumnFilters] = useState<MRT_ColumnFiltersState>(
[]
);
const getSessionsQuery = client.getSessions.useQuery(
["sessions", { columnFilters, paginationState }],
{
query: {
skip: paginationState.pageIndex * paginationState.pageSize,
count: paginationState.pageSize,
id: columnFilters.find((f) => f.id === "id")?.value as
| string
| undefined,
},
},
{
keepPreviousData: true,
}
);
return getSessionsQuery.status === "error" ? (
<p>Error</p>
) : (
<SessionsTable
data={
getSessionsQuery.status === "loading"
? { total: 0, sessions: [] }
: getSessionsQuery.data.body
}
paginationState={paginationState}
setPaginationState={setPaginationState}
isLoading={getSessionsQuery.status === "loading"}
isRefetching={getSessionsQuery.isRefetching}
columnFilters={columnFilters}
setColumnFilters={setColumnFilters}
/>
);
}
function SessionsTable({
data: { sessions, total },
paginationState,
columnFilters,
setPaginationState,
setColumnFilters,
isLoading,
isRefetching,
}: {
data: GetSessionsResponse;
paginationState: MRT_PaginationState;
setPaginationState: (updater: MRT_Updater<MRT_PaginationState>) => void;
setColumnFilters: (updater: MRT_Updater<MRT_ColumnFiltersState>) => void;
isLoading: boolean;
isRefetching: boolean;
columnFilters: MRT_ColumnFiltersState;
}) {
const columns = useMemo<MRT_ColumnDef<GetSessionsResponse["sessions"][0]>[]>(
() => [
{
accessorKey: "id",
header: "ID",
},
{
accessorKey: "game_name",
header: "Game Name",
},
{
accessorKey: "version",
header: "Version",
},
{
accessorKey: "time",
header: "Time",
Cell: ({ row }) => {
const rangeInterval = interval(
row.original.start_time,
row.original.end_time
);
const duration = intervalToDuration(rangeInterval);
const formattedStartTime = format(row.original.start_time, "Pp");
const formattedDuration = formatDuration(duration);
return (
<span>
{formattedStartTime}
<br />({formattedDuration || "very quick"})
</span>
);
},
},
],
[]
);
const navigate = useNavigate();
const table = useMaterialReactTable({
data: sessions,
columns,
muiTableBodyRowProps: ({ row }) => ({
onClick: (_) => {
navigate(`/session/${row.original.id}`);
},
sx: {
cursor: "pointer",
},
}),
manualPagination: true,
rowCount: total,
onPaginationChange: setPaginationState,
onColumnFiltersChange: setColumnFilters,
state: {
pagination: paginationState,
isLoading,
showProgressBars: isRefetching,
columnFilters,
},
enableSorting: false,
enableGlobalFilter: false,
manualFiltering: true,
});
return <MaterialReactTable table={table} />;
}

10
src/client/api.ts Normal file
View File

@@ -0,0 +1,10 @@
const serverRoot =
import.meta.env.MODE === "development"
? "http://localhost:9283"
: window.location.protocol + "//" + window.location.host;
const getSessions = () =>
fetch(`${serverRoot}/api/get-sessions`)
.then((x) => x.json())
.then(console.log)
.catch(console.error);

13
src/client/client.ts Normal file
View File

@@ -0,0 +1,13 @@
// client.ts
import { initQueryClient } from "@ts-rest/react-query";
import { contract } from "../common/contract";
const serverRoot =
import.meta.env.MODE === "development"
? "http://localhost:9283"
: window.location.protocol + "//" + window.location.host;
export const client = initQueryClient(contract, {
baseUrl: serverRoot,
baseHeaders: {},
});

12
src/client/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="initial-scale=1, width=device-width" />
<title>Game Logger</title>
<script type="module" src="./index.tsx"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>

10
src/client/index.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { createRoot } from "react-dom/client";
import { App } from "./App";
const domRoot = document.getElementById("root");
if (!domRoot) {
alert("Cannot find note root");
} else {
const root = createRoot(domRoot);
root.render(<App />);
}

50
src/client/theme.ts Normal file
View File

@@ -0,0 +1,50 @@
import { createTheme } from "@mui/material/styles";
import { red } from "@mui/material/colors";
import { TypographyOwnProps, TypographyPropsVariantOverrides } from "@mui/material";
import { OverridableStringUnion} from "@mui/types"
import { Variant } from "@mui/material/styles/createTypography";
declare module "@mui/material/Typography" {
// interface ButtonPropsVariantOverrides {
// dashed: true;
// }
interface TypographyPropsVariantOverrides {
breadcrumb: true;
}
}
// const x: LinkPropsVariantOverrides;
// A custom theme for this app
const theme = createTheme({
cssVariables: true,
palette: {
primary: {
main: "#556cd6",
},
secondary: {
main: "#19857b",
},
error: {
main: red.A400,
},
},
components: {
MuiLink: {
styleOverrides: {
root: {
variants: [
{
props: { variant: "breadcrumb" },
style: {
color: "#fff",
},
},
],
},
},
},
},
});
export default theme;

24
src/client/tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"include": [
"**/*"
],
"exclude": [
"../../node_modules"
],
"compilerOptions": {
"isolatedModules": true,
"strict": true,
"allowSyntheticDefaultImports": true,
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node",
"types": [
"vite/client"
],
"jsx": "react-jsx",
"rootDirs": [
".",
"../common"
]
}
}

11
src/client/vite.config.js Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
clearScreen: false,
build: {
outDir: "../../dist/client",
emptyOutDir: true
}
});