another step
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
# Environment similar to NODE_ENV.
|
||||
# Analytics and public resources are enabled only in "production"
|
||||
# How to use: set value to "production" to get fully functional application.
|
||||
NEXT_PUBLIC_ENV = development
|
||||
# NEXT_PUBLIC_ENV = preview
|
||||
# NEXT_PUBLIC_ENV = production
|
||||
|
||||
# Enables additional debug features, no additional debug information if the variable is not set
|
||||
# How to use: set value to "true" to get more debugging information, but don't do it on production.
|
||||
NEXT_PUBLIC_DEBUG = true
|
||||
|
||||
# Public URL of the application/website.
|
||||
# How to use: Do not set any value until you need custom domain for your application.
|
||||
# NEXT_PUBLIC_PUBLIC_URL = https://xxx.com
|
||||
# NEXT_PUBLIC_PUBLIC_URL = https://xxx.web.app
|
||||
NEXT_PUBLIC_PUBLIC_URL = http://localhost:3000
|
||||
|
||||
|
||||
# API/Backend basic URL (the Baya server)
|
||||
NEXT_PUBLIC_API_URL = https://localhost:5002
|
||||
# NEXT_PUBLIC_API_URL = https://dev-api.domain.com
|
||||
# NEXT_PUBLIC_API_URL = https://api.domain.com
|
||||
+1
-1
@@ -81,7 +81,7 @@ client/
|
||||
## Server / Client Component Boundaries
|
||||
|
||||
**Root layout** (`src/app/layout.tsx`) is a **lean Server Component** (RSC). It:
|
||||
- Reads locale from the **`x-next-intl-locale` request header** set by the middleware (with fallback to `defaultLocale` for build-time pre-rendering where no request context exists).
|
||||
- Reads locale from the **`x-app-locale` request header** (`HEADER_NAMES.LOCALE` from `@/constants`) set by the middleware (with fallback to `defaultLocale` for build-time pre-rendering where no request context exists).
|
||||
- Calls `getThemeMode()` from `@/lib/cookies/server` (only `colorScheme` is used).
|
||||
- Renders `<html>` with `data-mui-color-scheme`, `lang`, and `dir` from the locale/cookie.
|
||||
- Does **NOT** wrap the tree with `NextIntlClientProvider`, `ThemeProvider`, or `AppStoreProvider` — those live in `[locale]/layout.tsx` where the locale is reliably sourced from URL params.
|
||||
|
||||
+17
-2
@@ -2,7 +2,7 @@ import createMiddleware from 'next-intl/middleware';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { routing } from './src/i18n/routing';
|
||||
import { COOKIE_NAMES } from './src/lib/cookies';
|
||||
import { PUBLIC_PATHS, ROUTES } from './src/constants';
|
||||
import { HEADER_NAMES, PUBLIC_PATHS, ROUTES } from './src/constants';
|
||||
|
||||
const intlMiddleware = createMiddleware(routing);
|
||||
|
||||
@@ -39,7 +39,22 @@ export default function middleware(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
return i18nResponse;
|
||||
// Detect locale from the normalized URL (next-intl always puts it at position 1)
|
||||
const locale = routing.locales.find(
|
||||
(l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`),
|
||||
) ?? routing.defaultLocale;
|
||||
|
||||
const requestHeaders = new Headers(request.headers);
|
||||
requestHeaders.set(HEADER_NAMES.LOCALE, locale);
|
||||
|
||||
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
||||
|
||||
// Preserve response headers set by next-intl (e.g. Link: alternate hreflang)
|
||||
i18nResponse.headers.forEach((value, key) => {
|
||||
response.headers.set(key, value);
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { setRequestLocale, getMessages } from 'next-intl/server';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { SnackbarProvider } from 'notistack';
|
||||
import { getThemeMode } from '@/lib/cookies/server';
|
||||
import { AppStoreProvider } from '@/store';
|
||||
import { ThemeProvider, getDirection } from '@/theme';
|
||||
import { ToastBridge } from '@/lib/toast';
|
||||
import { NotistackProvider } from '@/lib/toast';
|
||||
import { QueryProvider } from '@/lib/query/QueryProvider';
|
||||
import { routing } from '@/i18n/routing';
|
||||
|
||||
@@ -46,10 +45,9 @@ export default async function LocaleLayout({
|
||||
<AppStoreProvider>
|
||||
<ThemeProvider dir={dir} defaultMode={defaultMode}>
|
||||
<QueryProvider>
|
||||
<SnackbarProvider maxSnack={3} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}>
|
||||
<ToastBridge />
|
||||
<NotistackProvider>
|
||||
{children}
|
||||
</SnackbarProvider>
|
||||
</NotistackProvider>
|
||||
</QueryProvider>
|
||||
</ThemeProvider>
|
||||
</AppStoreProvider>
|
||||
|
||||
@@ -8,7 +8,6 @@ html,
|
||||
body {
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
/* required for sticky elements: HeaderMobile, and so on */
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Metadata, Viewport } from 'next';
|
||||
import { Space_Grotesk } from 'next/font/google';
|
||||
import localFont from 'next/font/local';
|
||||
import { headers } from 'next/headers';
|
||||
import { getThemeMode } from '@/lib/cookies/server';
|
||||
import { ColorSchemeScript, getDirection } from '@/theme';
|
||||
import { getDirection } from '@/theme';
|
||||
import { BRAND } from '@/theme/colors';
|
||||
import { routing } from '@/i18n/routing';
|
||||
import { HEADER_NAMES } from '@/constants';
|
||||
import './globals.css';
|
||||
import '@/theme/tokens.css';
|
||||
|
||||
// EN brand font — loaded for all locales so the CSS variable is always defined
|
||||
const spaceGrotesk = Space_Grotesk({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '500', '600', '700'],
|
||||
display: 'swap',
|
||||
variable: '--font-space-grotesk',
|
||||
});
|
||||
|
||||
// FA brand font — Mikhak, a free Persian typeface
|
||||
const mikhak = localFont({
|
||||
src: [
|
||||
@@ -40,23 +32,11 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default async function RootLayout({ children }: { children: ReactNode }) {
|
||||
/*
|
||||
* The root layout is intentionally kept as a lean HTML shell.
|
||||
* NextIntlClientProvider, ThemeProvider, and AppStoreProvider live in
|
||||
* [locale]/layout.tsx so they always receive the correct locale from URL
|
||||
* params — independent of whether this root layout is rendered statically
|
||||
* or dynamically.
|
||||
*
|
||||
* We still read x-next-intl-locale here for lang/dir on <html> so that
|
||||
* screen readers and CSS direction are correct on real requests.
|
||||
* During build-time pre-rendering (no request context) we fall back to
|
||||
* defaultLocale — this only affects the static HTML shell; the live
|
||||
* request always re-renders with the correct locale header.
|
||||
*/
|
||||
|
||||
let locale: string = routing.defaultLocale;
|
||||
try {
|
||||
const hdrs = await headers();
|
||||
const headerLocale = hdrs.get('x-next-intl-locale');
|
||||
const headerLocale = hdrs.get(HEADER_NAMES.LOCALE);
|
||||
if (headerLocale && routing.locales.includes(headerLocale as (typeof routing.locales)[number])) {
|
||||
locale = headerLocale;
|
||||
}
|
||||
@@ -71,13 +51,9 @@ export default async function RootLayout({ children }: { children: ReactNode })
|
||||
<html
|
||||
lang={locale}
|
||||
dir={dir}
|
||||
className={`${spaceGrotesk.variable} ${mikhak.variable}`}
|
||||
className={`${mikhak.variable}`}
|
||||
data-mui-color-scheme={colorScheme}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<ColorSchemeScript />
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const HEADER_NAMES = {
|
||||
LOCALE: 'x-app-locale',
|
||||
} as const;
|
||||
@@ -1 +1,2 @@
|
||||
export * from './headers';
|
||||
export * from './routes';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useMemo, useState } from 'react';
|
||||
import { Stack, StackProps } from '@mui/material';
|
||||
import { IS_DEBUG } from '@/config';
|
||||
import { AppIconButton, ErrorBoundary } from '@/components';
|
||||
import { LinkToPage } from '@/utils';
|
||||
import { useIsMobile } from '@/hooks';
|
||||
@@ -22,11 +21,6 @@ interface Props extends StackProps {
|
||||
variant: 'sidebarAlwaysTemporary' | 'sidebarPersistentOnDesktop' | 'sidebarAlwaysPersistent';
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders "TopBar and SideBar" composition.
|
||||
* Does NOT subscribe to the color scheme — only <DarkModeToggleButton> does.
|
||||
* @layout TopBarAndSideBarLayout
|
||||
*/
|
||||
const TopBarAndSideBarLayout: FunctionComponent<Props> = ({ children, sidebarItems, title, variant }) => {
|
||||
const [sidebarVisible, setSidebarVisible] = useState(false);
|
||||
const onMobile = useIsMobile();
|
||||
@@ -87,8 +81,6 @@ const TopBarAndSideBarLayout: FunctionComponent<Props> = ({ children, sidebarIte
|
||||
? { startNode: LogoButton, endNode: <DarkModeToggleButton /> }
|
||||
: { startNode: <DarkModeToggleButton />, endNode: LogoButton };
|
||||
|
||||
IS_DEBUG && console.log('Render <TopbarAndSidebarLayout/>', { onMobile, sidebarProps });
|
||||
|
||||
return (
|
||||
<Stack sx={stackStyles}>
|
||||
<Stack component="header">
|
||||
|
||||
@@ -9,6 +9,7 @@ import { headers } from 'next/headers';
|
||||
|
||||
import { API_URL } from '@/config';
|
||||
import { COOKIE_NAMES } from '@/lib/cookies';
|
||||
import { HEADER_NAMES } from '@/constants';
|
||||
import { getServerCookie } from '@/lib/cookies/server';
|
||||
import { ApiError } from './errors';
|
||||
|
||||
@@ -26,7 +27,7 @@ export async function serverFetch<T>(path: string, options?: RequestInit): Promi
|
||||
let locale = 'fa';
|
||||
try {
|
||||
const reqHeaders = await headers();
|
||||
locale = reqHeaders.get('x-next-intl-locale') ?? 'fa';
|
||||
locale = reqHeaders.get(HEADER_NAMES.LOCALE) ?? 'fa';
|
||||
} catch {
|
||||
// Outside request context (build-time prerendering) — use default locale
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { SnackbarProvider } from 'notistack';
|
||||
import { ToastBridge } from './ToastBridge';
|
||||
|
||||
export function NotistackProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<SnackbarProvider maxSnack={3} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}>
|
||||
<ToastBridge />
|
||||
{children}
|
||||
</SnackbarProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export { ToastBridge } from './ToastBridge';
|
||||
export { NotistackProvider } from './NotistackProvider';
|
||||
export { dispatchToast, type ToastSeverity } from './dispatchToast';
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { COOKIE_NAMES } from '@/lib/cookies';
|
||||
|
||||
/**
|
||||
* Inline synchronous script placed in <head> — runs before any paint.
|
||||
*
|
||||
* Two jobs:
|
||||
* 1. Set data-mui-color-scheme on <html> from our cookie, so the server-set
|
||||
* attribute and the first-paint attribute always agree (no flash).
|
||||
* 2. Patch Storage.prototype so MUI v9's internal localStorage reads for key
|
||||
* 'mode' return null (forcing MUI to use the defaultMode prop we derive
|
||||
* from the cookie on the server) and writes to 'mode' are routed to our
|
||||
* cookie instead of localStorage.
|
||||
*
|
||||
* Why patch Storage.prototype instead of storageWindow={null}?
|
||||
* In MUI v9 localStorageManager: `if (!storageWindow && typeof window !== 'undefined') {
|
||||
* storageWindow = window; }` — null is falsy, so storageWindow={null} is silently
|
||||
* overridden to window in the browser. The prototype patch is the only reliable way.
|
||||
*/
|
||||
export function ColorSchemeScript() {
|
||||
const cookieName = COOKIE_NAMES.COLOR_SCHEME; // 'color-scheme'
|
||||
const maxAge = 60 * 60 * 24 * 365;
|
||||
// MUI v9 default modeStorageKey (InitColorSchemeScript.mjs: DEFAULT_MODE_STORAGE_KEY = 'mode')
|
||||
const muiModeKey = 'mode';
|
||||
|
||||
const script =
|
||||
`(function(){` +
|
||||
// ── 1. Set attribute from cookie before first paint ─────────────────────
|
||||
`var m=document.cookie.match(/(^|;)\\s*${cookieName}=([^;]*)/);` +
|
||||
`var s=m?decodeURIComponent(m[2]):(window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light');` +
|
||||
`document.documentElement.setAttribute('data-mui-color-scheme',s==='dark'?'dark':'light');` +
|
||||
// ── 2. Intercept Storage so MUI never touches localStorage ───────────────
|
||||
`try{` +
|
||||
`var _s=Storage.prototype.setItem,_g=Storage.prototype.getItem,_r=Storage.prototype.removeItem;` +
|
||||
`Storage.prototype.setItem=function(k,v){` +
|
||||
`if(this===window.localStorage&&k==='${muiModeKey}'){` +
|
||||
`if(v==='dark'||v==='light')document.cookie='${cookieName}='+v+';path=/;max-age=${maxAge};samesite=lax';` +
|
||||
`else document.cookie='${cookieName}=;path=/;max-age=0';` +
|
||||
`return;}` +
|
||||
`_s.call(this,k,v);};` +
|
||||
`Storage.prototype.getItem=function(k){` +
|
||||
`if(this===window.localStorage&&k==='${muiModeKey}')return null;` +
|
||||
`return _g.call(this,k);};` +
|
||||
`Storage.prototype.removeItem=function(k){` +
|
||||
`if(this===window.localStorage&&k==='${muiModeKey}'){` +
|
||||
`document.cookie='${cookieName}=;path=/;max-age=0';return;}` +
|
||||
`_r.call(this,k);};` +
|
||||
`}catch(e){}` +
|
||||
`})();`;
|
||||
|
||||
// eslint-disable-next-line react/no-danger
|
||||
return <script dangerouslySetInnerHTML={{ __html: script }} />;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { LIGHT_THEME } from './light';
|
||||
import { DARK_THEME } from './dark';
|
||||
import APP_THEME, { APP_THEME_LTR, APP_THEME_RTL } from './theme';
|
||||
import { getDirection } from './direction';
|
||||
import { ColorSchemeScript } from './ColorSchemeScript';
|
||||
|
||||
|
||||
export {
|
||||
APP_THEME,
|
||||
@@ -14,5 +14,4 @@ export {
|
||||
LIGHT_THEME as default,
|
||||
AppThemeProvider as ThemeProvider,
|
||||
getDirection,
|
||||
ColorSchemeScript,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user