another step

This commit is contained in:
hamid
2026-06-18 01:42:14 +03:30
parent e135b0b919
commit 6294cb4248
14 changed files with 70 additions and 101 deletions
+22
View File
@@ -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
View File
@@ -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
View File
@@ -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 = {
+3 -5
View File
@@ -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>
-1
View File
@@ -8,7 +8,6 @@ html,
body {
max-width: 100vw;
overflow-x: hidden;
/* required for sticky elements: HeaderMobile, and so on */
max-height: 100vh;
}
+5 -29
View File
@@ -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>
);
+3
View File
@@ -0,0 +1,3 @@
export const HEADER_NAMES = {
LOCALE: 'x-app-locale',
} as const;
+1
View File
@@ -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">
+2 -1
View File
@@ -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
View File
@@ -1,2 +1,3 @@
export { ToastBridge } from './ToastBridge';
export { NotistackProvider } from './NotistackProvider';
export { dispatchToast, type ToastSeverity } from './dispatchToast';
-52
View File
@@ -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 }} />;
}
+1 -2
View File
@@ -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,
};