feat: add app shell, file-based routing and bootstrap

This commit is contained in:
Arthur Bassi 2026-06-12 11:52:25 -03:00
parent 7967f7e4c5
commit 33a902034c
16 changed files with 316 additions and 0 deletions

5
src/app-root.tsx Normal file
View file

@ -0,0 +1,5 @@
import { Routes } from "@/app-routes";
export function AppRoot() {
return <Routes />;
}

1
src/app-routes.tsx Normal file
View file

@ -0,0 +1 @@
export { Routes } from "@/routing";

21
src/app/404.tsx Normal file
View file

@ -0,0 +1,21 @@
import { Link } from "react-router-dom";
import { Box, Button, Typography } from "@mui/material";
export default function NotFoundPage() {
return (
<Box
className="flex min-h-screen flex-col items-center justify-center gap-4 bg-(--color-bg-page) px-4"
role="main"
>
<Typography variant="h4" component="h1" className="font-display text-foreground">
Page not found
</Typography>
<Typography variant="body1" className="text-muted-foreground">
The page you are looking for does not exist or has been moved.
</Typography>
<Button component={Link} to="/" variant="contained">
Go to dashboard
</Button>
</Box>
);
}

2
src/app/_app.tsx Normal file
View file

@ -0,0 +1,2 @@
/** Generouted reserved filename — root shell for all routes. */
export { default } from "./_components/root-layout";

View file

@ -0,0 +1,11 @@
import { Outlet } from "react-router";
import { AuthProvider } from "@/providers/auth-provider";
export default function RootLayout() {
return (
<AuthProvider>
<Outlet />
</AuthProvider>
);
}

19
src/main.tsx Normal file
View file

@ -0,0 +1,19 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { AppRoot } from "@/app-root";
import { AppProviders } from "@/providers/AppProviders";
import "@/styles/globals.css";
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("Root element #root not found");
}
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<AppProviders>
<AppRoot />
</AppProviders>
</React.StrictMode>,
);

74
src/router.ts Normal file
View file

@ -0,0 +1,74 @@
// Generouted, changes to this file will be overridden
/* eslint-disable */
import { components, hooks, utils } from "@generouted/react-router/client";
export type Path =
| `///src/app`
| `///src/app/accounts`
| `///src/app/accounts/edit/:id`
| `///src/app/accounts/new`
| `///src/app/assets`
| `///src/app/assets/edit/:id`
| `///src/app/assets/new`
| `///src/app/calendar`
| `///src/app/calendar/edit/:id`
| `///src/app/calendar/new`
| `///src/app/contacts`
| `///src/app/contacts/edit/:id`
| `///src/app/contacts/new`
| `///src/app/dashboard`
| `///src/app/employees`
| `///src/app/employees/edit/:id`
| `///src/app/employees/new`
| `///src/app/followups`
| `///src/app/followups/edit/:id`
| `///src/app/followups/new`
| `///src/app/locations`
| `///src/app/locations/edit/:id`
| `///src/app/locations/new`
| `///src/app/login`
| `///src/app/pmschedules`
| `///src/app/pmschedules/edit/:id`
| `///src/app/pmschedules/new`
| `///src/app/settings/dropdowns`
| `///src/app/settings/task-templates`
| `///src/app/uplifts`
| `///src/app/v/:token`
| `///src/app/v/:token/dashboard`
| `///src/app/v/:token/dispatch/:id`
| `///src/app/v/:token/pos`
| `///src/app/vendor-pos`
| `///src/app/vendor-pos/:id`
| `///src/app/vendors`
| `///src/app/vendors/edit/:id`
| `///src/app/vendors/new`
| `///src/app/workorders`
| `///src/app/workorders/:id`
| `///src/app/workorders/edit/:id`
| `///src/app/workorders/new`;
export type Params = {
"///src/app/accounts/edit/:id": { id: string };
"///src/app/assets/edit/:id": { id: string };
"///src/app/calendar/edit/:id": { id: string };
"///src/app/contacts/edit/:id": { id: string };
"///src/app/employees/edit/:id": { id: string };
"///src/app/followups/edit/:id": { id: string };
"///src/app/locations/edit/:id": { id: string };
"///src/app/pmschedules/edit/:id": { id: string };
"///src/app/v/:token": { token: string };
"///src/app/v/:token/dashboard": { token: string };
"///src/app/v/:token/dispatch/:id": { token: string; id: string };
"///src/app/v/:token/pos": { token: string };
"///src/app/vendor-pos/:id": { id: string };
"///src/app/vendors/edit/:id": { id: string };
"///src/app/workorders/:id": { id: string };
"///src/app/workorders/edit/:id": { id: string };
};
export type ModalPath = never;
export const { Link, Navigate } = components<Path, Params>();
export const { useModals, useNavigate, useParams } = hooks<Path, Params, ModalPath>();
export const { redirect } = utils<Path, Params>();

52
src/routing/app-shell.tsx Normal file
View file

@ -0,0 +1,52 @@
import { ComponentType, Suspense } from "react";
import { Outlet, useLocation } from "react-router";
import type { RouteObject } from "react-router";
import type { RouteModule } from "@/routing/route-module";
function Modals({ modalRoutes }: { modalRoutes: Record<string, ComponentType> }) {
const modalPath = useLocation().state?.modal as string | undefined;
const Modal = modalPath ? modalRoutes[modalPath] : undefined;
if (!Modal) {
return null;
}
return <Modal />;
}
export function createAppShellRoute(
appLayout: RouteModule | undefined,
modalRoutes: Record<string, ComponentType>,
): Pick<RouteObject, "Component" | "ErrorBoundary" | "loader"> {
const AppLayout = appLayout?.default ?? Outlet;
function Layout() {
return (
<>
<AppLayout />
<Modals modalRoutes={modalRoutes} />
</>
);
}
function AppShell() {
const Pending = appLayout?.Pending;
if (Pending) {
return (
<Suspense fallback={<Pending />}>
<Layout />
</Suspense>
);
}
return <Layout />;
}
return {
Component: appLayout?.default ? AppShell : Layout,
ErrorBoundary: appLayout?.Catch,
loader: appLayout?.Loader,
};
}

View file

@ -0,0 +1,27 @@
import { Fragment, type ComponentType } from "react";
import type { RouteObject } from "react-router";
import {
generateModalRoutes,
generatePreservedRoutes,
generateRegularRoutes,
} from "@generouted/react-router/core";
import { MODALS, PRESERVED, ROUTES } from "@/routing/glob-imports";
import { remapAppToPages } from "@/routing/remap-routes";
import { createLazyRoute, type RouteModule } from "@/routing/route-module";
import { createAppShellRoute } from "@/routing/app-shell";
const preservedRoutes = generatePreservedRoutes(remapAppToPages(PRESERVED));
const modalRoutes = generateModalRoutes(remapAppToPages(MODALS)) as Record<string, ComponentType>;
const regularRoutes = generateRegularRoutes(remapAppToPages(ROUTES), (moduleLoader, key) =>
createLazyRoute(moduleLoader as () => Promise<RouteModule>, key),
);
const appLayout = preservedRoutes?.["_app"] as RouteModule | undefined;
const notFound = preservedRoutes?.["404"] as RouteModule | undefined;
const appShell = createAppShellRoute(appLayout, modalRoutes);
const fallback: RouteObject = { path: "*", Component: notFound?.default ?? Fragment };
export const routes: RouteObject[] = [{ ...appShell, children: [...regularRoutes, fallback] }];

8
src/routing/constants.ts Normal file
View file

@ -0,0 +1,8 @@
/** Vite glob root for file-based routes (must match import.meta.glob literals in glob-imports.ts). */
export const ROUTES_ROOT = "/src/app";
/**
* Generouted core derives URL paths assuming this prefix.
* See remap-routes.ts — we scan `/src/app/` but remap keys to `/src/pages/` at runtime.
*/
export const GENEROUTED_ROOT = "/src/pages";

View file

@ -0,0 +1,13 @@
import type { RouteModule } from "@/routing/route-module";
/** Paths must stay literal — Vite analyzes import.meta.glob at build time. */
const PRESERVED = import.meta.glob<RouteModule>("/src/app/{_app,404}.{jsx,tsx}", { eager: true });
const MODALS = import.meta.glob<RouteModule>("/src/app/**/[+]*.{jsx,tsx}", { eager: true });
const ROUTES = import.meta.glob<RouteModule>([
"/src/app/**/[A-Za-z0-9[-]*.{jsx,tsx}",
"/src/app/**/_layout.{jsx,tsx}",
"!/src/app/404.{jsx,tsx}",
"!/src/app/**/_*/**",
]);
export { MODALS, PRESERVED, ROUTES };

2
src/routing/index.ts Normal file
View file

@ -0,0 +1,2 @@
export { Routes } from "@/routing/router";
export { routes } from "@/routing/build-route-tree";

View file

@ -0,0 +1,17 @@
import { GENEROUTED_ROOT, ROUTES_ROOT } from "@/routing/constants";
/**
* Generouted's `generateRegularRoutes` / `generatePreservedRoutes` strip `/src/pages/`
* when building URL segments. Our views live under `/src/app/` (architecture convention),
* so we remap glob keys before passing them to the core.
*
* Ref: https://github.com/oedotme/generouted/tree/main/examples/react-router-custom-path
*/
export function remapAppToPages<T>(files: Record<string, T>): Record<string, T> {
return Object.fromEntries(
Object.entries(files).map(([key, value]) => [
key.replace(`${ROUTES_ROOT}/`, `${GENEROUTED_ROOT}/`),
value,
]),
);
}

View file

@ -0,0 +1,45 @@
import { ComponentType, Fragment, Suspense } from "react";
import type { RouteObject } from "react-router";
export type RouteModule = {
default?: ComponentType;
Pending?: ComponentType;
Catch?: ComponentType;
Loader?: RouteObject["loader"];
Action?: RouteObject["action"];
};
type ModuleLoader = () => Promise<RouteModule>;
export function createLazyRoute(
moduleLoader: ModuleLoader,
key: string,
): Pick<RouteObject, "index" | "lazy"> {
const index =
/index\.(jsx|tsx)$/.test(key) && !key.includes("pages/index") ? { index: true as const } : {};
return {
...index,
lazy: async () => {
const loaded = await moduleLoader();
const PageComponent = loaded?.default ?? Fragment;
const Pending = loaded?.Pending;
const Page = () =>
Pending ? (
<Suspense fallback={<Pending />}>
<PageComponent />
</Suspense>
) : (
<PageComponent />
);
return {
Component: Page,
ErrorBoundary: loaded?.Catch,
loader: loaded?.Loader,
action: loaded?.Action,
};
},
};
}

9
src/routing/router.tsx Normal file
View file

@ -0,0 +1,9 @@
import { createBrowserRouter, RouterProvider } from "react-router";
import { routes } from "@/routing/build-route-tree";
const router = createBrowserRouter(routes);
export function Routes() {
return <RouterProvider router={router} />;
}

10
src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_API_TARGET: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}