Skip to content

Commit 1a17aaa

Browse files
🏗️ chore: add basic UI scaffolding
1 parent c7e7111 commit 1a17aaa

31 files changed

+1114
-100
lines changed

frontend/bin/create_page.sh

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ function create_css_file() {
3939
function create_page_file() {
4040
cat > "$2/$capitalized_page_name.tsx" <<EOF
4141
import React from "react";
42-
import { useLoaderData } from "react-router";
42+
import { Outlet, useLoaderData } from "react-router";
43+
import { useCurrentMatch } from "~/api/hooks/useCurrentMatch.ts";
4344
4445
import "./$capitalized_page_name.css";
4546
import { ${capitalized_page_name}LoaderData } from "./${page_name}.loader.tsx";
@@ -52,12 +53,14 @@ export type ${component_name}Props = React.ComponentProps<"main"> & {
5253
* ${capitalized_page_name} page
5354
*/
5455
export function ${component_name}({ children, ...props }: ${component_name}Props) {
56+
const activeMatch = useCurrentMatch();
5557
const loaderData = useLoaderData<${capitalized_page_name}LoaderData>();
5658
5759
return (
5860
<main className="${component_name}" {...props}>
59-
<pre>children: {children}</pre>
61+
<pre>match: {JSON.stringify(activeMatch)}</pre>
6062
<pre>data: {JSON.stringify(loaderData)}</pre>
63+
<Outlet />
6164
</main>
6265
);
6366
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
#!/bin/bash
2+
3+
# Get the directory of the script
4+
SCRIPT_DIR=`dirname $0`;
5+
FRONTEND_DIR=`dirname $SCRIPT_DIR`
6+
7+
cd $FRONTEND_DIR;
8+
9+
CONFLICTS=("@maykin-ui/admin-ui/node_modules/react" "@maykin-ui/admin-ui/node_modules/react-dom")
10+
11+
# Function to check if a directory exists
12+
function directory_exists() {
13+
[ -d $1 ]
14+
}
15+
16+
count=0;
17+
for dir in ${CONFLICTS[@]}; do
18+
fulldir="node_modules/${dir}"
19+
dirname=`basename ${fulldir}`
20+
symlink_target="${FRONTEND_DIR}/node_modules/${dirname}"
21+
22+
if directory_exists $fulldir; then
23+
real_source=`realpath ${fulldir}`
24+
real_target=`realpath ${symlink_target}`
25+
echo "removing conflicting dependency: $real_source"
26+
rm -rf $real_source;
27+
28+
echo "creating symlink: $dirname"
29+
ln -s $real_target $real_source
30+
31+
let count++
32+
else
33+
echo "conflicting dependency not found: $fulldir"
34+
fi
35+
done
36+
37+
echo ""
38+
echo "$count conflicting dependencies linked"
39+
40+
if [ $count -gt 0 ]; then
41+
echo "you may need to restart your server"
42+
fi
43+

frontend/eslint.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const config = [
1616
},
1717
rules: {
1818
"tsdoc/syntax": "error",
19+
"react-hooks/exhaustive-deps": "off",
1920
},
2021
},
2122
];

frontend/package-lock.json

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"build-storybook": "storybook build"
1818
},
1919
"dependencies": {
20-
"@maykin-ui/admin-ui": "^0.0.53",
20+
"@maykin-ui/admin-ui": "alpha",
2121
"@maykin-ui/client-common": "^0.0.1",
2222
"react": "^19.0.0",
2323
"react-dom": "^19.0.0",

frontend/src/App.test.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import { render } from "@testing-library/react";
2+
import { RouterProvider, createBrowserRouter } from "react-router";
23
import { describe, it } from "vitest";
34

45
import App from "./App";
56

67
describe("App", () => {
78
it("renders without crashing", () => {
8-
render(<App />);
9+
render(
10+
<RouterProvider
11+
router={createBrowserRouter([{ path: "/", element: <App /> }])}
12+
></RouterProvider>,
13+
);
914
});
1015
});

frontend/src/App.tsx

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,106 @@
1-
// @ts-expect-error Style is an alias, and ts isn't able to resolve this
1+
import {
2+
BaseTemplate,
3+
ButtonProps,
4+
ConfigContext,
5+
Logo,
6+
Outline,
7+
ucFirst,
8+
} from "@maykin-ui/admin-ui";
9+
// @ts-expect-error - no ts modules
210
import "@maykin-ui/admin-ui/style";
3-
import { Outlet } from "react-router";
11+
import { useMemo } from "react";
12+
import { Outlet, RouteObject, useLocation, useNavigate } from "react-router";
13+
import { useChildRoutes } from "~/hooks/useChildRoutes.ts";
14+
import { useCurrentMatch } from "~/hooks/useCurrentMatch.ts";
15+
16+
import "./main.css";
417

518
/**
619
* This component serves as the entry point for the React app and renders the main UI structure.
720
*/
821
function App() {
9-
return <Outlet />;
22+
const location = useLocation();
23+
const navigate = useNavigate();
24+
const currentMatch = useCurrentMatch();
25+
const childRoutes = useChildRoutes();
26+
27+
const currentMatchHandle = currentMatch.handle as
28+
| Record<string, unknown>
29+
| undefined;
30+
const hideUi = currentMatchHandle?.hideUi;
31+
32+
/**
33+
* The primary navigation items.
34+
*/
35+
const primaryNavigationItems = useMemo(() => {
36+
// Login page should not show primary navigation.
37+
if (hideUi) {
38+
return [];
39+
}
40+
41+
const buttons = [
42+
{
43+
children: (
44+
<>
45+
<Outline.Squares2X2Icon />
46+
</>
47+
),
48+
title: "Catalogi",
49+
onClick: () => navigate("/"),
50+
},
51+
].map<ButtonProps>((props) => ({
52+
...props,
53+
// eslint-disable-next-line react/prop-types
54+
key: props.title,
55+
align: "start",
56+
pad: true,
57+
}));
58+
return [
59+
<Logo key="logo" abbreviated variant="contrast" />,
60+
...buttons,
61+
"spacer",
62+
];
63+
}, [location]);
64+
65+
/**
66+
* The sidebar navigation items.
67+
*/
68+
const sidebarItems = useMemo(() => {
69+
// Page with no child routes should not show sidebar.
70+
if (!childRoutes.length) {
71+
return [];
72+
}
73+
74+
return childRoutes
75+
.filter((route) => route.path)
76+
.map(({ path, id }: RouteObject): ButtonProps => {
77+
return {
78+
active: currentMatch.id === id,
79+
align: "start",
80+
children: ucFirst(path?.split("/").pop()?.trim() || ""),
81+
onClick: () =>
82+
navigate(
83+
currentMatch.id === id
84+
? currentMatch.pathname || ""
85+
: [currentMatch.pathname, path!].join("/"),
86+
),
87+
};
88+
});
89+
}, [primaryNavigationItems]);
90+
91+
return (
92+
<BaseTemplate
93+
primaryNavigationItems={primaryNavigationItems}
94+
sidebarItems={sidebarItems}
95+
grid={!hideUi}
96+
>
97+
<ConfigContext.Provider
98+
value={{ templatesContentOnly: true, templatesGrid: false }}
99+
>
100+
<Outlet />
101+
</ConfigContext.Provider>
102+
</BaseTemplate>
103+
);
10104
}
11105

12106
export default App;
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { UIMatch, useMatches } from "react-router";
2+
import { routes } from "~/routes";
3+
4+
/**
5+
* Returns the child routes for a given route ID.
6+
*
7+
* @param id - The ID of the parent route to look up, must be present in `ROUTE_IDS`.
8+
* @returns An array of child route objects, or an empty array if none are found.
9+
*/
10+
export function useChildRoutes(id?: string) {
11+
const matches = useMatches();
12+
13+
for (const currentMatch of matches.reverse()) {
14+
const childRoutes = _resolveChildRoutes(id, currentMatch);
15+
if (childRoutes.length) {
16+
return childRoutes;
17+
}
18+
}
19+
return [];
20+
}
21+
22+
/**
23+
* Returns the child routes for a given route ID.
24+
*
25+
* @param id - The ID of the parent route to look up, must be present in `ROUTE_IDS`.
26+
* @param currentMatch - The current React Router match.
27+
* @param haystack - Used internally when this hooks recurses to find a nested route.
28+
* @returns An array of child route objects, or an empty array if none are found.
29+
*/
30+
function _resolveChildRoutes(
31+
id: string | undefined,
32+
currentMatch: UIMatch | undefined,
33+
haystack = routes,
34+
) {
35+
const _id = typeof id === "undefined" ? currentMatch?.id || "" : id;
36+
37+
// if (Object.values(ROUTE_IDS).includes(_id)) {
38+
const currentRoute = haystack.find((route) => route.id === _id);
39+
if (currentRoute) {
40+
return currentRoute.children || [];
41+
}
42+
43+
// Recurse through child routes.
44+
const _haystack = haystack
45+
.filter((route) => route.children?.length)
46+
.flatMap((route) => route.children || []);
47+
48+
if (_haystack.length) {
49+
return _resolveChildRoutes(_id, currentMatch, _haystack);
50+
}
51+
// }
52+
return [];
53+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { useMatches } from "react-router";
2+
3+
/**
4+
* Returns the deepest matched route from the current route hierarchy.
5+
*
6+
* Useful for accessing metadata such as `id`, `params`, `handle`, or route-specific data
7+
* defined on the most specific (child) route in the current match.
8+
*
9+
* @returns The last matched route object, or `undefined` if no matches exist.
10+
*/
11+
export function useCurrentMatch() {
12+
const matches = useMatches();
13+
return [...matches].pop() as (typeof matches)[number];
14+
}

frontend/src/loaders/loginRequired.loader.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { redirect } from "react-router";
22
import { vi } from "vitest";
33
import { loginRequired } from "~/loaders/loginRequired.loader.ts";
44

5-
vi.mock("react-router-dom", () => ({
5+
vi.mock("react-router", () => ({
66
redirect: vi.fn(),
77
}));
88

0 commit comments

Comments
 (0)