diff --git a/package.json b/package.json index ecfe418f..871036de 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "fs-extra": "^11.1.1", "lodash.merge": "^4.6.2", "next": "13.5.3", + "next-client-cookies": "^1.0.5", "next-intl": "^2.20.0", "next-safe-action": "^3.4.0", "pg": "^8.11.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53ad3fcd..e0df7f13 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: next: specifier: 13.5.3 version: 13.5.3(@babel/core@7.22.5)(react-dom@18.2.0)(react@18.2.0)(sass@1.63.6) + next-client-cookies: + specifier: ^1.0.5 + version: 1.0.5(next@13.5.3)(react@18.2.0) next-intl: specifier: ^2.20.0 version: 2.20.0(next@13.5.3)(react@18.2.0) @@ -8806,6 +8809,11 @@ packages: hasBin: true dev: true + /js-cookie@3.0.5: + resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} + engines: {node: '>=14'} + dev: false + /js-levenshtein@1.1.6: resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} engines: {node: '>=0.10.0'} @@ -10031,6 +10039,17 @@ packages: engines: {node: '>= 0.6'} dev: false + /next-client-cookies@1.0.5(next@13.5.3)(react@18.2.0): + resolution: {integrity: sha512-PqmJyJCZotR/Vg8meaqHKT2RjHoNuDkaew6JBj0gA1tlY7+6aK0Zb00KLnZSQnbGYg9OEQquk5UyWp1QACvXjQ==} + peerDependencies: + next: ^13.0.0 + react: '>= 16.8.0' + dependencies: + js-cookie: 3.0.5 + next: 13.5.3(@babel/core@7.22.5)(react-dom@18.2.0)(react@18.2.0)(sass@1.63.6) + react: 18.2.0 + dev: false + /next-intl@2.20.0(next@13.5.3)(react@18.2.0): resolution: {integrity: sha512-zeGod4WuS/j8dhDM1eLlt+GTCqqq1b09l/eHxUWQjGrK87BeN+crKasHjZWMNm38g0ddWpBW6i9KDZcTn8/p1A==} engines: {node: '>=10'} diff --git a/src/app/components/ClientProviders/ClientProviders.tsx b/src/app/components/ClientProviders/ClientProviders.tsx new file mode 100644 index 00000000..8cec881b --- /dev/null +++ b/src/app/components/ClientProviders/ClientProviders.tsx @@ -0,0 +1,21 @@ +'use client'; + +import React, { ComponentProps } from 'react'; +import { CookiesProvider } from 'next-client-cookies'; +import { ThemeProvider } from './ThemeProvider'; + +type Props = { + children: React.ReactNode; + cookies: ComponentProps['value']; + initialTheme?: string; +}; + +export const ClientProviders = ({ children, initialTheme, cookies }: Props) => { + return ( + + {children} + + ); +}; + +export const ClientCookiesProvider: typeof CookiesProvider = (props) => ; diff --git a/src/app/components/ClientProviders/ThemeProvider/ThemeProvider.tsx b/src/app/components/ClientProviders/ThemeProvider/ThemeProvider.tsx new file mode 100644 index 00000000..f2f3f3fa --- /dev/null +++ b/src/app/components/ClientProviders/ThemeProvider/ThemeProvider.tsx @@ -0,0 +1,25 @@ +'use client'; + +import { useUIStore } from '@/client/state/uiStore'; +import React, { useEffect } from 'react'; +import { useCookies } from 'next-client-cookies'; + +type Props = { + children: React.ReactNode; + initialTheme?: string; +}; + +export const ThemeProvider = (props: Props) => { + const { children, initialTheme } = props; + const cookies = useCookies(); + const { theme } = useUIStore(); + + useEffect(() => { + if (theme) { + cookies.set('theme', theme || initialTheme || 'light', { path: '/' }); + document.body.dataset.bsTheme = theme; + } + }, [cookies, initialTheme, theme]); + + return children; +}; diff --git a/src/app/components/ClientProviders/ThemeProvider/index.ts b/src/app/components/ClientProviders/ThemeProvider/index.ts new file mode 100644 index 00000000..2ff90cf4 --- /dev/null +++ b/src/app/components/ClientProviders/ThemeProvider/index.ts @@ -0,0 +1 @@ +export { ThemeProvider } from './ThemeProvider'; diff --git a/src/app/components/ClientProviders/index.ts b/src/app/components/ClientProviders/index.ts new file mode 100644 index 00000000..842d6fa9 --- /dev/null +++ b/src/app/components/ClientProviders/index.ts @@ -0,0 +1 @@ +export { ClientProviders } from './ClientProviders'; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e4948db7..e40367d5 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import React from 'react'; import type { Metadata } from 'next'; +import { cookies } from 'next/headers'; import { Inter } from 'next/font/google'; import merge from 'lodash.merge'; import { NextIntlClientProvider } from 'next-intl'; @@ -8,7 +9,9 @@ import { NextIntlClientProvider } from 'next-intl'; import './global.css'; import clsx from 'clsx'; import { Toaster } from 'react-hot-toast'; +import Head from 'next/head'; import { getCurrentLocale } from '../utils/getCurrentLocale'; +import { ClientProviders } from './components/ClientProviders'; const inter = Inter({ subsets: ['latin'], @@ -27,13 +30,26 @@ export default async function RootLayout({ children }: { children: React.ReactNo const messages = (await import(`../client/messages/${locale}.json`)).default; const mergedMessages = merge(englishMessages, messages); + const theme = cookies().get('theme'); + return ( + + + + + + + + + - - {children} - - + + + {children} + + + ); diff --git a/src/client/components/ui/Header/Header.test.tsx b/src/client/components/ui/Header/Header.test.tsx deleted file mode 100644 index a1c01e4c..00000000 --- a/src/client/components/ui/Header/Header.test.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React from 'react'; -import { fireEvent, render, renderHook, screen } from '../../../../../tests/test-utils'; -import { useUIStore } from '../../../state/uiStore'; -import { Header } from './Header'; - -const pushFn = jest.fn(); -jest.mock('next/router', () => { - const actualRouter = jest.requireActual('next-router-mock'); - - return { - ...actualRouter, - useRouter: () => ({ - ...actualRouter.useRouter(), - push: pushFn, - }), - }; -}); - -describe('Header', () => { - it('renders without crashing', () => { - render(
); - }); - - it('renders the brand logo', () => { - const { container } = render(
); - expect(container).toHaveTextContent('Tipi'); - expect(container).toContainElement(screen.getByAltText('Tipi logo')); - }); - - it('renders the dark mode toggle', () => { - render(
); - const darkModeToggle = screen.getByTestId('dark-mode-toggle'); - expect(darkModeToggle).toContainElement(screen.getByTestId('icon-moon')); - }); - - it('renders the light mode toggle', () => { - render(
); - const lightModeToggle = screen.getByTestId('light-mode-toggle'); - expect(lightModeToggle).toContainElement(screen.getByTestId('icon-sun')); - }); - - it('Should toggle the dark mode on click of the dark mode toggle', () => { - const { result } = renderHook(() => useUIStore()); - - render(
); - const darkModeToggle = screen.getByTestId('dark-mode-toggle'); - fireEvent.click(darkModeToggle as Element); - - expect(result.current.darkMode).toBe(true); - }); - - it('Should toggle the dark mode on click of the light mode toggle', () => { - const { result } = renderHook(() => useUIStore()); - - render(
); - const lightModeToggle = screen.getByTestId('light-mode-toggle'); - fireEvent.click(lightModeToggle as Element); - - expect(result.current.darkMode).toBe(false); - }); -}); diff --git a/src/client/components/ui/Header/Header.tsx b/src/client/components/ui/Header/Header.tsx deleted file mode 100644 index 1d8ecaa7..00000000 --- a/src/client/components/ui/Header/Header.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import React from 'react'; -import { IconBrandGithub, IconHeart, IconLogout, IconMoon, IconSun } from '@tabler/icons-react'; -import Image from 'next/image'; -import clsx from 'clsx'; -import Link from 'next/link'; -import { Tooltip } from 'react-tooltip'; -import { useTranslations } from 'next-intl'; -import { useUIStore } from '../../../state/uiStore'; -import { NavBar } from '../NavBar'; - -interface IProps { - isUpdateAvailable?: boolean; -} - -export const Header: React.FC = ({ isUpdateAvailable }) => { - const { setDarkMode } = useUIStore(); - const t = useTranslations('header'); - - return ( -
-
- - -

- Tipi logo - Tipi -

- -
- -
- {t('dark-mode')} -
setDarkMode(true)} role="button" aria-hidden="true" className="darkMode nav-link px-0 hide-theme-dark cursor-pointer" data-testid="dark-mode-toggle"> - -
- {t('light-mode')} -
setDarkMode(false)} aria-hidden="true" className="lightMode nav-link px-0 hide-theme-light cursor-pointer" data-testid="light-mode-toggle"> - -
- {t('logout')} -
- -
-
-
- -
-
- ); -}; diff --git a/src/client/components/ui/Header/index.ts b/src/client/components/ui/Header/index.ts deleted file mode 100644 index 29429dc9..00000000 --- a/src/client/components/ui/Header/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Header } from './Header'; diff --git a/src/client/state/uiStore.ts b/src/client/state/uiStore.ts index cc6f469e..e8549efa 100644 --- a/src/client/state/uiStore.ts +++ b/src/client/state/uiStore.ts @@ -7,6 +7,7 @@ const defaultTranslator = createTranslator({ locale: 'en', messages: englishMess type UIStore = { menuItem: string; darkMode: boolean; + theme?: string; translator: typeof defaultTranslator; setMenuItem: (menuItem: string) => void; setDarkMode: (darkMode: boolean) => void; @@ -17,17 +18,16 @@ export const useUIStore = create((set) => ({ menuItem: 'dashboard', darkMode: false, translator: defaultTranslator, + theme: undefined, setTranslator: (translator: typeof defaultTranslator) => { set({ translator }); }, setDarkMode: (darkMode: boolean) => { if (darkMode) { - localStorage.setItem('darkMode', darkMode.toString()); - document.body.dataset.bsTheme = 'dark'; + set({ theme: 'dark' }); } if (!darkMode) { - localStorage.setItem('darkMode', darkMode.toString()); - document.body.dataset.bsTheme = 'light'; + set({ theme: 'light' }); } set({ darkMode }); }, diff --git a/src/middleware.ts b/src/middleware.ts deleted file mode 100644 index 49b0c066..00000000 --- a/src/middleware.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { NextResponse } from 'next/server'; -import type { NextRequest } from 'next/server'; - -const COOKIE_MAX_AGE = 60 * 60 * 24; // 1 day - -/** - * Middleware to set session ID in request headers - * @param {NextRequest} request - Request object - */ -export async function middleware(request: NextRequest) { - let sessionId = ''; - - const cookie = request.cookies.get('tipi.sid')?.value; - - // Check if session ID exists in cookies - if (cookie) { - sessionId = cookie; - } - - const requestHeaders = new Headers(request.headers); - - if (sessionId) { - requestHeaders.set('x-session-id', sessionId); - } - - const response = NextResponse.next({ - request: { headers: requestHeaders }, - }); - - if (sessionId) { - response.headers.set('x-session-id', sessionId); - - response.cookies.set('tipi.sid', sessionId, { - maxAge: COOKIE_MAX_AGE, - httpOnly: true, - secure: false, - sameSite: false, - }); - } - - return response; -} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx deleted file mode 100644 index 42114858..00000000 --- a/src/pages/_app.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import React, { useEffect } from 'react'; -import type { AppProps } from 'next/app'; -import Head from 'next/head'; -import '../client/styles/global.css'; -import '../client/styles/global.scss'; -import 'react-tooltip/dist/react-tooltip.css'; -import { Toaster } from 'react-hot-toast'; -import { useUIStore } from '../client/state/uiStore'; -import { StatusProvider } from '../client/components/hoc/StatusProvider'; - -/** - * Next.js App component - * - * @param {AppProps} props - props passed to the app - * @returns {JSX.Element} - JSX element - */ -function MyApp({ Component, pageProps }: AppProps) { - const { setDarkMode } = useUIStore(); - - // check theme on component mount - useEffect(() => { - const themeCheck = () => { - if (localStorage.darkMode === 'true' || (!('darkMode' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { - document.body.dataset.bsTheme = 'dark'; - setDarkMode(true); - } else { - document.body.dataset.bsTheme = 'light'; - setDarkMode(false); - } - }; - themeCheck(); - }, [setDarkMode]); - - return ( -
- - Tipi - - - - - -
- ); -} - -export default MyApp; diff --git a/src/pages/_document.tsx b/src/pages/_document.tsx deleted file mode 100644 index 7633c220..00000000 --- a/src/pages/_document.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; -import { Html, Head, Main, NextScript } from 'next/document'; - -/** - * Next.js Document component - * - * @returns {JSX.Element} - JSX element - */ -export default function MyDocument() { - return ( - - - - - - - - - - - -