diff --git a/apps/web/features/theme/index.ts b/apps/web/features/theme/index.ts new file mode 100644 index 00000000..522aaae4 --- /dev/null +++ b/apps/web/features/theme/index.ts @@ -0,0 +1,3 @@ +export { useThemeStore } from './model/store' +export { ThemeToggle } from './ui/theme-toggle' +export type { Theme } from './types/theme' diff --git a/apps/web/features/theme/model/store.ts b/apps/web/features/theme/model/store.ts new file mode 100644 index 00000000..9036df5d --- /dev/null +++ b/apps/web/features/theme/model/store.ts @@ -0,0 +1,16 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' + +import type { Theme, ThemeStore } from '../types/theme' + +export const useThemeStore = create()( + persist( + (set) => ({ + theme: 'light', + setTheme: (theme: Theme) => set({ theme }), + toggleTheme: () => + set((state) => ({ theme: state.theme === 'light' ? 'dark' : 'light' })), + }), + { name: 'theme-storage' }, + ), +) diff --git a/apps/web/features/theme/types/theme.ts b/apps/web/features/theme/types/theme.ts new file mode 100644 index 00000000..de860e86 --- /dev/null +++ b/apps/web/features/theme/types/theme.ts @@ -0,0 +1,12 @@ +export type Theme = 'light' | 'dark' + +type ThemeStoreState = { + theme: Theme +} + +type ThemeStoreActions = { + setTheme: (theme: Theme) => void + toggleTheme: () => void +} + +export type ThemeStore = ThemeStoreState & ThemeStoreActions diff --git a/apps/web/features/theme/ui/theme-toggle.tsx b/apps/web/features/theme/ui/theme-toggle.tsx new file mode 100644 index 00000000..ba41f1aa --- /dev/null +++ b/apps/web/features/theme/ui/theme-toggle.tsx @@ -0,0 +1,26 @@ +'use client' + +import { Moon, Sun } from 'lucide-react' + +import { Button } from '@repo/ui' + +import { useHydratedStore } from '../../../shared/hooks' +import { useThemeStore } from '../model/store' + +export const ThemeToggle = () => { + const theme = useHydratedStore(useThemeStore, (state) => state.theme) + const toggleTheme = useThemeStore((state) => state.toggleTheme) + + if (!theme) { + // тут можно рисовать скелетон + return null + } + + const Icon = theme === 'light' ? Moon : Sun + + return ( + + ) +} diff --git a/apps/web/package.json b/apps/web/package.json index 7fbda3de..60e3fbb3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,11 +14,13 @@ "@hookform/resolvers": "^5.2.2", "@repo/ui": "workspace:*", "@tanstack/react-query": "^5.90.21", + "lucide-react": "^0.574.0", "next": "16.1.6", "react": "^19.2.4", "react-dom": "^19.2.4", "react-hook-form": "^7.71.1", - "zod": "^4.3.6" + "zod": "^4.3.6", + "zustand": "^5.0.11" }, "devDependencies": { "@repo/api": "workspace:*", diff --git a/apps/web/shared/hooks/index.ts b/apps/web/shared/hooks/index.ts new file mode 100644 index 00000000..6fbdc184 --- /dev/null +++ b/apps/web/shared/hooks/index.ts @@ -0,0 +1 @@ +export { useHydratedStore } from './use-hydrated-store' diff --git a/apps/web/shared/hooks/use-hydrated-store.ts b/apps/web/shared/hooks/use-hydrated-store.ts new file mode 100644 index 00000000..64db68d3 --- /dev/null +++ b/apps/web/shared/hooks/use-hydrated-store.ts @@ -0,0 +1,24 @@ +'use client' + +import { useEffect, useState } from 'react' + +export const useHydratedStore = ( + store: (callback: (state: T) => unknown) => unknown, + selector: (state: T) => F, +): F | undefined => { + // 1. Достаем сырое значение из стора + const result = store(selector) as F + + // 2. Флаг, который скажет нам: "браузер отрендерил компонент?" + const [isHydrated, setIsHydrated] = useState(false) + + // 3. useEffect работает ТОЛЬКО в браузере и ТОЛЬКО после первого рендера + useEffect(() => { + setIsHydrated(true) + }, []) + + // 4. Если мы на сервере или это самый первый render в браузере — + // отдаем undefined (чтобы верстка совпала с сервером). + // Как только отработает useEffect, произойдет ререндер с реальными данными. + return isHydrated ? result : undefined +} diff --git a/apps/web/widgets/sidebar/index.ts b/apps/web/widgets/sidebar/index.ts new file mode 100644 index 00000000..301de592 --- /dev/null +++ b/apps/web/widgets/sidebar/index.ts @@ -0,0 +1 @@ +export { useSideBarStore } from './model/store' diff --git a/apps/web/widgets/sidebar/model/store.ts b/apps/web/widgets/sidebar/model/store.ts new file mode 100644 index 00000000..bed900fa --- /dev/null +++ b/apps/web/widgets/sidebar/model/store.ts @@ -0,0 +1,10 @@ +import { create } from 'zustand' + +import type { SideBarStore } from '../types/sidebar' + +export const useSideBarStore = create((set) => ({ + isOpen: false, + open: () => set({ isOpen: true }), + close: () => set({ isOpen: false }), + toggle: () => set((state) => ({ isOpen: !state.isOpen })), +})) diff --git a/apps/web/widgets/sidebar/types/sidebar.ts b/apps/web/widgets/sidebar/types/sidebar.ts new file mode 100644 index 00000000..ed508512 --- /dev/null +++ b/apps/web/widgets/sidebar/types/sidebar.ts @@ -0,0 +1,11 @@ +type SideBarStoreState = { + isOpen: boolean +} + +type SideBarStoreActions = { + open: () => void + close: () => void + toggle: () => void +} + +export type SideBarStore = SideBarStoreState & SideBarStoreActions diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03c66214..4f8b2de8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -171,6 +171,9 @@ importers: '@tanstack/react-query': specifier: ^5.90.21 version: 5.90.21(react@19.2.4) + lucide-react: + specifier: ^0.574.0 + version: 0.574.0(react@19.2.4) next: specifier: 16.1.6 version: 16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -186,6 +189,9 @@ importers: zod: specifier: ^4.3.6 version: 4.3.6 + zustand: + specifier: ^5.0.11 + version: 5.0.11(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) devDependencies: '@repo/api': specifier: workspace:* @@ -6687,6 +6693,24 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zustand@5.0.11: + resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@adobe/css-tools@4.4.4': {} @@ -13710,3 +13734,9 @@ snapshots: zod@3.25.76: {} zod@4.3.6: {} + + zustand@5.0.11(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4)