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
|
## Server / Client Component Boundaries
|
||||||
|
|
||||||
**Root layout** (`src/app/layout.tsx`) is a **lean Server Component** (RSC). It:
|
**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).
|
- Calls `getThemeMode()` from `@/lib/cookies/server` (only `colorScheme` is used).
|
||||||
- Renders `<html>` with `data-mui-color-scheme`, `lang`, and `dir` from the locale/cookie.
|
- 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.
|
- 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 { type NextRequest, NextResponse } from 'next/server';
|
||||||
import { routing } from './src/i18n/routing';
|
import { routing } from './src/i18n/routing';
|
||||||
import { COOKIE_NAMES } from './src/lib/cookies';
|
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);
|
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 = {
|
export const config = {
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { setRequestLocale, getMessages } from 'next-intl/server';
|
import { setRequestLocale, getMessages } from 'next-intl/server';
|
||||||
import { NextIntlClientProvider } from 'next-intl';
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
import { SnackbarProvider } from 'notistack';
|
|
||||||
import { getThemeMode } from '@/lib/cookies/server';
|
import { getThemeMode } from '@/lib/cookies/server';
|
||||||
import { AppStoreProvider } from '@/store';
|
import { AppStoreProvider } from '@/store';
|
||||||
import { ThemeProvider, getDirection } from '@/theme';
|
import { ThemeProvider, getDirection } from '@/theme';
|
||||||
import { ToastBridge } from '@/lib/toast';
|
import { NotistackProvider } from '@/lib/toast';
|
||||||
import { QueryProvider } from '@/lib/query/QueryProvider';
|
import { QueryProvider } from '@/lib/query/QueryProvider';
|
||||||
import { routing } from '@/i18n/routing';
|
import { routing } from '@/i18n/routing';
|
||||||
|
|
||||||
@@ -46,10 +45,9 @@ export default async function LocaleLayout({
|
|||||||
<AppStoreProvider>
|
<AppStoreProvider>
|
||||||
<ThemeProvider dir={dir} defaultMode={defaultMode}>
|
<ThemeProvider dir={dir} defaultMode={defaultMode}>
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<SnackbarProvider maxSnack={3} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}>
|
<NotistackProvider>
|
||||||
<ToastBridge />
|
|
||||||
{children}
|
{children}
|
||||||
</SnackbarProvider>
|
</NotistackProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</AppStoreProvider>
|
</AppStoreProvider>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ html,
|
|||||||
body {
|
body {
|
||||||
max-width: 100vw;
|
max-width: 100vw;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
/* required for sticky elements: HeaderMobile, and so on */
|
|
||||||
max-height: 100vh;
|
max-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,15 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Metadata, Viewport } from 'next';
|
import { Metadata, Viewport } from 'next';
|
||||||
import { Space_Grotesk } from 'next/font/google';
|
|
||||||
import localFont from 'next/font/local';
|
import localFont from 'next/font/local';
|
||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
import { getThemeMode } from '@/lib/cookies/server';
|
import { getThemeMode } from '@/lib/cookies/server';
|
||||||
import { ColorSchemeScript, getDirection } from '@/theme';
|
import { getDirection } from '@/theme';
|
||||||
import { BRAND } from '@/theme/colors';
|
import { BRAND } from '@/theme/colors';
|
||||||
import { routing } from '@/i18n/routing';
|
import { routing } from '@/i18n/routing';
|
||||||
|
import { HEADER_NAMES } from '@/constants';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
import '@/theme/tokens.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
|
// FA brand font — Mikhak, a free Persian typeface
|
||||||
const mikhak = localFont({
|
const mikhak = localFont({
|
||||||
src: [
|
src: [
|
||||||
@@ -40,23 +32,11 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function RootLayout({ children }: { children: ReactNode }) {
|
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;
|
let locale: string = routing.defaultLocale;
|
||||||
try {
|
try {
|
||||||
const hdrs = await headers();
|
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])) {
|
if (headerLocale && routing.locales.includes(headerLocale as (typeof routing.locales)[number])) {
|
||||||
locale = headerLocale;
|
locale = headerLocale;
|
||||||
}
|
}
|
||||||
@@ -71,13 +51,9 @@ export default async function RootLayout({ children }: { children: ReactNode })
|
|||||||
<html
|
<html
|
||||||
lang={locale}
|
lang={locale}
|
||||||
dir={dir}
|
dir={dir}
|
||||||
className={`${spaceGrotesk.variable} ${mikhak.variable}`}
|
className={`${mikhak.variable}`}
|
||||||
data-mui-color-scheme={colorScheme}
|
data-mui-color-scheme={colorScheme}
|
||||||
suppressHydrationWarning
|
|
||||||
>
|
>
|
||||||
<head>
|
|
||||||
<ColorSchemeScript />
|
|
||||||
</head>
|
|
||||||
<body>{children}</body>
|
<body>{children}</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const HEADER_NAMES = {
|
||||||
|
LOCALE: 'x-app-locale',
|
||||||
|
} as const;
|
||||||
@@ -1 +1,2 @@
|
|||||||
|
export * from './headers';
|
||||||
export * from './routes';
|
export * from './routes';
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { FunctionComponent, useMemo, useState } from 'react';
|
import { FunctionComponent, useMemo, useState } from 'react';
|
||||||
import { Stack, StackProps } from '@mui/material';
|
import { Stack, StackProps } from '@mui/material';
|
||||||
import { IS_DEBUG } from '@/config';
|
|
||||||
import { AppIconButton, ErrorBoundary } from '@/components';
|
import { AppIconButton, ErrorBoundary } from '@/components';
|
||||||
import { LinkToPage } from '@/utils';
|
import { LinkToPage } from '@/utils';
|
||||||
import { useIsMobile } from '@/hooks';
|
import { useIsMobile } from '@/hooks';
|
||||||
@@ -22,11 +21,6 @@ interface Props extends StackProps {
|
|||||||
variant: 'sidebarAlwaysTemporary' | 'sidebarPersistentOnDesktop' | 'sidebarAlwaysPersistent';
|
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 TopBarAndSideBarLayout: FunctionComponent<Props> = ({ children, sidebarItems, title, variant }) => {
|
||||||
const [sidebarVisible, setSidebarVisible] = useState(false);
|
const [sidebarVisible, setSidebarVisible] = useState(false);
|
||||||
const onMobile = useIsMobile();
|
const onMobile = useIsMobile();
|
||||||
@@ -87,8 +81,6 @@ const TopBarAndSideBarLayout: FunctionComponent<Props> = ({ children, sidebarIte
|
|||||||
? { startNode: LogoButton, endNode: <DarkModeToggleButton /> }
|
? { startNode: LogoButton, endNode: <DarkModeToggleButton /> }
|
||||||
: { startNode: <DarkModeToggleButton />, endNode: LogoButton };
|
: { startNode: <DarkModeToggleButton />, endNode: LogoButton };
|
||||||
|
|
||||||
IS_DEBUG && console.log('Render <TopbarAndSidebarLayout/>', { onMobile, sidebarProps });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack sx={stackStyles}>
|
<Stack sx={stackStyles}>
|
||||||
<Stack component="header">
|
<Stack component="header">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { headers } from 'next/headers';
|
|||||||
|
|
||||||
import { API_URL } from '@/config';
|
import { API_URL } from '@/config';
|
||||||
import { COOKIE_NAMES } from '@/lib/cookies';
|
import { COOKIE_NAMES } from '@/lib/cookies';
|
||||||
|
import { HEADER_NAMES } from '@/constants';
|
||||||
import { getServerCookie } from '@/lib/cookies/server';
|
import { getServerCookie } from '@/lib/cookies/server';
|
||||||
import { ApiError } from './errors';
|
import { ApiError } from './errors';
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ export async function serverFetch<T>(path: string, options?: RequestInit): Promi
|
|||||||
let locale = 'fa';
|
let locale = 'fa';
|
||||||
try {
|
try {
|
||||||
const reqHeaders = await headers();
|
const reqHeaders = await headers();
|
||||||
locale = reqHeaders.get('x-next-intl-locale') ?? 'fa';
|
locale = reqHeaders.get(HEADER_NAMES.LOCALE) ?? 'fa';
|
||||||
} catch {
|
} catch {
|
||||||
// Outside request context (build-time prerendering) — use default locale
|
// 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 { ToastBridge } from './ToastBridge';
|
||||||
|
export { NotistackProvider } from './NotistackProvider';
|
||||||
export { dispatchToast, type ToastSeverity } from './dispatchToast';
|
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 { DARK_THEME } from './dark';
|
||||||
import APP_THEME, { APP_THEME_LTR, APP_THEME_RTL } from './theme';
|
import APP_THEME, { APP_THEME_LTR, APP_THEME_RTL } from './theme';
|
||||||
import { getDirection } from './direction';
|
import { getDirection } from './direction';
|
||||||
import { ColorSchemeScript } from './ColorSchemeScript';
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
APP_THEME,
|
APP_THEME,
|
||||||
@@ -14,5 +14,4 @@ export {
|
|||||||
LIGHT_THEME as default,
|
LIGHT_THEME as default,
|
||||||
AppThemeProvider as ThemeProvider,
|
AppThemeProvider as ThemeProvider,
|
||||||
getDirection,
|
getDirection,
|
||||||
ColorSchemeScript,
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user