init
This commit is contained in:
21
app/components/Copyright.tsx
Normal file
21
app/components/Copyright.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from 'react';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import MuiLink from '@mui/material/Link';
|
||||
|
||||
export default function Copyright() {
|
||||
return (
|
||||
<Typography
|
||||
variant="body2"
|
||||
align="center"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{'Copyright © '}
|
||||
<MuiLink color="inherit" href="https://mui.com/">
|
||||
Your Website
|
||||
</MuiLink>{' '}
|
||||
{new Date().getFullYear()}.
|
||||
</Typography>
|
||||
);
|
||||
}
|
23
app/components/ProTip.tsx
Normal file
23
app/components/ProTip.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react';
|
||||
import Link from '@mui/material/Link';
|
||||
import SvgIcon, { type SvgIconProps } from '@mui/material/SvgIcon';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
function LightBulbIcon(props: SvgIconProps) {
|
||||
return (
|
||||
<SvgIcon {...props}>
|
||||
<path d="M9 21c0 .55.45 1 1 1h4c.55 0 1-.45 1-1v-1H9v1zm3-19C8.14 2 5 5.14 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.86-3.14-7-7-7zm2.85 11.1l-.85.6V16h-4v-2.3l-.85-.6C7.8 12.16 7 10.63 7 9c0-2.76 2.24-5 5-5s5 2.24 5 5c0 1.63-.8 3.16-2.15 4.1z" />
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProTip() {
|
||||
return (
|
||||
<Typography sx={{ mt: 6, mb: 3, color: 'text.secondary' }}>
|
||||
<LightBulbIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
{'Pro tip: See more '}
|
||||
<Link href="https://mui.com/material-ui/getting-started/templates/">templates</Link>
|
||||
{' in the Material UI documentation.'}
|
||||
</Typography>
|
||||
);
|
||||
}
|
15
app/createCache.ts
Normal file
15
app/createCache.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import createCache from '@emotion/cache';
|
||||
|
||||
export default function createEmotionCache(options?: Parameters<typeof createCache>[0]) {
|
||||
const emotionCache = createCache({ key: 'mui', ...options });
|
||||
const prevInsert = emotionCache.insert;
|
||||
emotionCache.insert = (...args) => {
|
||||
// ignore styles that contain layer order (`@layer ...` without `{`)
|
||||
if (!args[1].styles.match(/^@layer\s+[^{]*$/)) {
|
||||
args[1].styles = `@layer mui {${args[1].styles}}`;
|
||||
}
|
||||
return prevInsert(...args);
|
||||
};
|
||||
|
||||
return emotionCache;
|
||||
}
|
12
app/entry.client.tsx
Normal file
12
app/entry.client.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import * as React from 'react';
|
||||
import * as ReactDOM from 'react-dom/client';
|
||||
import { HydratedRouter } from 'react-router/dom';
|
||||
|
||||
React.startTransition(() => {
|
||||
ReactDOM.hydrateRoot(
|
||||
document,
|
||||
<React.StrictMode>
|
||||
<HydratedRouter />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
});
|
101
app/entry.server.tsx
Normal file
101
app/entry.server.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Transform } from 'node:stream';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as ReactDOMServer from 'react-dom/server';
|
||||
import type { EntryContext } from 'react-router';
|
||||
import { ServerRouter } from 'react-router';
|
||||
import { createReadableStreamFromReadable } from '@react-router/node';
|
||||
import { isbot } from 'isbot';
|
||||
import createEmotionServer from '@emotion/server/create-instance';
|
||||
import { CacheProvider } from '@emotion/react';
|
||||
import createEmotionCache from './createCache';
|
||||
|
||||
export const streamTimeout = 5_000;
|
||||
|
||||
export default function handleRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
routerContext: EntryContext,
|
||||
) {
|
||||
const cache = createEmotionCache();
|
||||
const { extractCriticalToChunks, constructStyleTagsFromChunks } = createEmotionServer(cache);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false;
|
||||
const userAgent = request.headers.get('user-agent');
|
||||
|
||||
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
|
||||
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
|
||||
const readyOption: keyof ReactDOMServer.RenderToPipeableStreamOptions =
|
||||
(userAgent && isbot(userAgent)) || routerContext.isSpaMode ? 'onAllReady' : 'onShellReady';
|
||||
|
||||
const { pipe, abort } = ReactDOMServer.renderToPipeableStream(
|
||||
<CacheProvider value={cache}>
|
||||
<ServerRouter context={routerContext} url={request.url} />
|
||||
</CacheProvider>,
|
||||
{
|
||||
[readyOption]() {
|
||||
shellRendered = true;
|
||||
|
||||
// Collect the HTML chunks
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
// Create transform stream to collect HTML and inject styles
|
||||
const transformStream = new Transform({
|
||||
transform(chunk, _encoding, callback) {
|
||||
// Collect chunks, don't pass them through yet
|
||||
chunks.push(chunk);
|
||||
callback();
|
||||
},
|
||||
flush(callback) {
|
||||
// Combine all chunks into HTML string
|
||||
const html = Buffer.concat(chunks).toString();
|
||||
|
||||
// Extract emotion styles from the collected HTML
|
||||
const styles = constructStyleTagsFromChunks(extractCriticalToChunks(html));
|
||||
|
||||
if (styles) {
|
||||
const injectedHtml = html.replace('</head>', `${styles}</head>`);
|
||||
this.push(injectedHtml);
|
||||
} else {
|
||||
this.push(html);
|
||||
}
|
||||
|
||||
callback();
|
||||
},
|
||||
});
|
||||
|
||||
const stream = createReadableStreamFromReadable(transformStream);
|
||||
|
||||
responseHeaders.set('Content-Type', 'text/html');
|
||||
|
||||
resolve(
|
||||
new Response(stream, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
}),
|
||||
);
|
||||
|
||||
pipe(transformStream);
|
||||
},
|
||||
onShellError(error: unknown) {
|
||||
reject(error);
|
||||
},
|
||||
onError(error: unknown) {
|
||||
responseStatusCode = 500;
|
||||
// Log streaming rendering errors from inside the shell. Don't log
|
||||
// errors encountered during initial shell rendering since they'll
|
||||
// reject and get logged in handleDocumentRequest.
|
||||
if (shellRendered) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Abort the rendering stream after the `streamTimeout` so it has time to
|
||||
// flush down the rejected boundaries
|
||||
setTimeout(abort, streamTimeout + 1000);
|
||||
});
|
||||
}
|
92
app/root.tsx
Normal file
92
app/root.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
isRouteErrorResponse,
|
||||
Links,
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
} from 'react-router';
|
||||
import { CacheProvider } from '@emotion/react';
|
||||
import Box from '@mui/material/Box';
|
||||
import AppTheme from './theme';
|
||||
import createEmotionCache from './createCache';
|
||||
|
||||
import type { Route } from './+types/root';
|
||||
|
||||
export const links: Route.LinksFunction = () => [
|
||||
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
|
||||
{
|
||||
rel: 'preconnect',
|
||||
href: 'https://fonts.gstatic.com',
|
||||
crossOrigin: 'anonymous',
|
||||
},
|
||||
{
|
||||
rel: 'stylesheet',
|
||||
href: 'https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap',
|
||||
},
|
||||
];
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
const cache = createEmotionCache();
|
||||
|
||||
export default function App() {
|
||||
if (typeof window !== 'undefined') {
|
||||
return (
|
||||
<CacheProvider value={cache}>
|
||||
<AppTheme>
|
||||
<Outlet />
|
||||
</AppTheme>
|
||||
</CacheProvider>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AppTheme>
|
||||
<Outlet />
|
||||
</AppTheme>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
let message = 'Oops!';
|
||||
let details = 'An unexpected error occurred.';
|
||||
let stack: string | undefined;
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
message = error.status === 404 ? '404' : 'Error';
|
||||
details =
|
||||
error.status === 404 ? 'The requested page could not be found.' : error.statusText || details;
|
||||
} else if (import.meta.env.DEV && error && error instanceof Error) {
|
||||
details = error.message;
|
||||
stack = error.stack;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box component="main" sx={{ pt: 8, p: 2, maxWidth: 'lg', mx: 'auto' }}>
|
||||
<h1>{message}</h1>
|
||||
<p>{details}</p>
|
||||
{stack && (
|
||||
<Box component="pre" sx={{ width: '100%', p: 2, overflowX: 'auto' }}>
|
||||
<code>{stack}</code>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
6
app/routes.ts
Normal file
6
app/routes.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type RouteConfig, index, route } from '@react-router/dev/routes';
|
||||
|
||||
export default [
|
||||
index('routes/home.tsx'),
|
||||
route('/about', 'routes/about.tsx'),
|
||||
] satisfies RouteConfig;
|
45
app/routes/about.tsx
Normal file
45
app/routes/about.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import * as React from 'react';
|
||||
import Container from '@mui/material/Container';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import { Link as ReactRouterLink } from 'react-router';
|
||||
import ProTip from '~/components/ProTip';
|
||||
import Copyright from '~/components/Copyright';
|
||||
|
||||
export function meta() {
|
||||
return [
|
||||
{ title: 'About' },
|
||||
{
|
||||
name: 'description',
|
||||
content: 'About the project',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default function About() {
|
||||
return (
|
||||
<Container maxWidth="lg">
|
||||
<Box
|
||||
sx={{
|
||||
my: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4" component="h1" sx={{ mb: 2 }}>
|
||||
Material UI - Next.js example in TypeScript
|
||||
</Typography>
|
||||
<Box sx={{ maxWidth: 'sm' }}>
|
||||
<Button variant="contained" component={ReactRouterLink} to="/">
|
||||
Go to the home page
|
||||
</Button>
|
||||
</Box>
|
||||
<ProTip />
|
||||
<Copyright />
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
43
app/routes/home.tsx
Normal file
43
app/routes/home.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import * as React from 'react';
|
||||
import Container from '@mui/material/Container';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import Link from '@mui/material/Link';
|
||||
import { Link as ReactRouterLink } from 'react-router';
|
||||
import ProTip from '~/components/ProTip';
|
||||
import Copyright from '~/components/Copyright';
|
||||
|
||||
export function meta() {
|
||||
return [
|
||||
{ title: 'Material UI - React Router example in TypeScript' },
|
||||
{
|
||||
name: 'description',
|
||||
content: 'Welcome to Material UI - React Router example in TypeScript!',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<Container maxWidth="lg">
|
||||
<Box
|
||||
sx={{
|
||||
my: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4" component="h1" sx={{ mb: 2 }}>
|
||||
Material UI - Next.js App Router example in TypeScript
|
||||
</Typography>
|
||||
<Link to="/about" color="secondary" component={ReactRouterLink}>
|
||||
Go to the about page
|
||||
</Link>
|
||||
<ProTip />
|
||||
<Copyright />
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
24
app/theme.tsx
Normal file
24
app/theme.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from 'react';
|
||||
import { createTheme, ThemeProvider } from '@mui/material/styles';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
|
||||
const theme = createTheme({
|
||||
cssVariables: true,
|
||||
colorSchemes: {
|
||||
light: true,
|
||||
dark: true,
|
||||
},
|
||||
});
|
||||
|
||||
interface AppThemeProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function AppTheme({ children }: AppThemeProps) {
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user