feat: initial import.
This commit is contained in:
0
src/client/App.css
Normal file
0
src/client/App.css
Normal file
38
src/client/App.tsx
Normal file
38
src/client/App.tsx
Normal 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
46
src/client/Root.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
src/client/SessionDetails.tsx
Normal file
102
src/client/SessionDetails.tsx
Normal 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
142
src/client/Sessions.tsx
Normal 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
10
src/client/api.ts
Normal 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
13
src/client/client.ts
Normal 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
12
src/client/index.html
Normal 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
10
src/client/index.tsx
Normal 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
50
src/client/theme.ts
Normal 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
24
src/client/tsconfig.json
Normal 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
11
src/client/vite.config.js
Normal 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
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user