Add dynamic breadcrumbs to Header via React Context

Replace hardcoded "Coffee Project / Projects" with a BreadcrumbsContext
that each page registers into via useBreadcrumbs(). The Header reads
breadcrumb items dynamically, renders links for items with href, and
makes "Coffee Project" clickable to open the navigation drawer. Also
removes the unused currentScreenName from Redux appState.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniil
2026-02-19 22:25:27 +03:00
parent 674d5d735b
commit 42ce5fa0fe
10 changed files with 307 additions and 34 deletions
+5
View File
@@ -1,8 +1,13 @@
"use client"
import { useBreadcrumbs } from "@shared/context/BreadcrumbsContext"
import { Button } from "@shared/ui"
import cls from "./HomePage.module.scss"
const HomePage = () => {
useBreadcrumbs([{ label: "Home" }])
return (
<div className={cls.homepage}>
<p className={cls.title}>Coffee Project Starter</p>
+101
View File
@@ -0,0 +1,101 @@
"use client"
import type { JSX } from "react"
import moment from "moment"
import { FunctionComponent } from "react"
import {
AvatarUpload,
ChangePasswordForm,
EditProfileForm,
LogoutButton,
} from "@features/profile"
import api from "@shared/api"
import { useBreadcrumbs } from "@shared/context/BreadcrumbsContext"
import { StaticLoader } from "@shared/ui/Loader"
import { Card } from "@shared/ui"
import { IProfilePageProps } from "./ProfilePage.d"
import styles from "./ProfilePage.module.scss"
export const ProfilePage: FunctionComponent<
IProfilePageProps
> = (): JSX.Element => {
useBreadcrumbs([{ label: "Profile" }])
const {
data: user,
isLoading,
refetch,
} = api.useQuery("get", "/api/users/me/")
const { mutate: updateUser } = api.useMutation(
"patch",
"/api/users/{user_id}/",
)
const handleAvatarChange = (url: string) => {
if (!user) return
updateUser(
{
params: { path: { user_id: user.id } },
body: { avatar: url },
},
{ onSuccess: () => refetch() },
)
}
if (isLoading) return <StaticLoader fullscreen />
if (!user) {
return (
<div className={styles.root} data-testid="ProfilePage">
<p>Не удалось загрузить данные пользователя</p>
</div>
)
}
return (
<div className={styles.root} data-testid="ProfilePage">
<div className={styles.container}>
<AvatarUpload
currentAvatarUrl={user.avatar}
onAvatarChange={handleAvatarChange}
/>
<Card className={styles.section}>
<EditProfileForm user={user} />
</Card>
<Card className={styles.section}>
<ChangePasswordForm />
</Card>
<Card className={styles.section}>
<h3 className={styles.sectionTitle}>Аккаунт</h3>
<div className={styles.infoList}>
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Логин</span>
<span className={styles.infoValue}>{user.username}</span>
</div>
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Дата регистрации</span>
<span className={styles.infoValue}>
{moment(user.date_joined).format("DD.MM.YYYY")}
</span>
</div>
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Статус</span>
<span className={styles.infoValue}>
{user.is_active ? "Активен" : "Неактивен"}
</span>
</div>
</div>
</Card>
<LogoutButton />
</div>
</div>
)
}
@@ -1,13 +1,26 @@
"use client"
import type { JSX } from "react"
import { useParams } from "next/navigation"
import { FunctionComponent } from "react"
import { useBreadcrumbs } from "@shared/context/BreadcrumbsContext"
import { IProjectWorkspacePageProps } from "./ProjectWorkspacePage.d"
import styles from "./ProjectWorkspacePage.module.scss"
export const ProjectWorkspacePage: FunctionComponent<
IProjectWorkspacePageProps
> = (): JSX.Element => {
const params = useParams<{ project_id: string }>()
const projectId = params?.project_id ?? ""
useBreadcrumbs([
{ label: "Projects", href: "/projects" },
{ label: `Project ${projectId}` },
])
return (
<div className={styles.root} data-testid="ProjectWorkspacePage">
ProjectWorkspacePage Component
+59 -4
View File
@@ -1,12 +1,21 @@
"use client"
import type { components } from "@shared/api/__generated__/openapi.types"
import type { JSX } from "react"
import { PlusIcon } from "lucide-react"
import { FunctionComponent, useState } from "react"
import { useRouter } from "next/navigation"
import { ProjectCard } from "@entities/ProjectCard"
import { CreateProjectModal } from "@features/CreateProjectModal"
import { useBreadcrumbs } from "@shared/context/BreadcrumbsContext"
import {
CreateProjectModal,
DeleteProjectModal,
EditProjectModal,
RenameProjectModal,
} from "@features/project"
import api from "@shared/api"
import { Button } from "@shared/ui"
import { StaticLoader } from "@shared/ui/Loader"
@@ -14,13 +23,18 @@ import { ProjectsHeader } from "@widgets/Projects/ProjectsHeader"
import { IProjectsPageProps } from "./ProjectsPage.d"
import styles from "./ProjectsPage.module.scss"
import { useRouter } from "next/navigation"
type ProjectRead = components["schemas"]["ProjectRead"]
export const ProjectsPage: FunctionComponent<
IProjectsPageProps
> = (): JSX.Element => {
const router = useRouter();
useBreadcrumbs([{ label: "Projects", href: "/projects" }])
const router = useRouter()
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [editProject, setEditProject] = useState<ProjectRead | null>(null)
const [renameProject, setRenameProject] = useState<ProjectRead | null>(null)
const [deleteProject, setDeleteProject] = useState<ProjectRead | null>(null)
const {
data: projects,
@@ -57,6 +71,45 @@ export const ProjectsPage: FunctionComponent<
}}
/>
{editProject && (
<EditProjectModal
open
onOpenChange={(open) => {
if (!open) setEditProject(null)
}}
project={editProject}
onUpdated={async () => {
await refetchProjects()
}}
/>
)}
{renameProject && (
<RenameProjectModal
open
onOpenChange={(open) => {
if (!open) setRenameProject(null)
}}
project={renameProject}
onRenamed={async () => {
await refetchProjects()
}}
/>
)}
{deleteProject && (
<DeleteProjectModal
open
onOpenChange={(open) => {
if (!open) setDeleteProject(null)
}}
project={deleteProject}
onDeleted={async () => {
await refetchProjects()
}}
/>
)}
<ProjectsHeader />
<div className={styles.projectList}>
@@ -64,12 +117,14 @@ export const ProjectsPage: FunctionComponent<
<ProjectCard
key={project.id}
project={project}
// Mock random progress for demo since API doesn't provide it yet
progress={project.status === "PROCESSING" ? 45 : 0}
currentAction={
project.status === "PROCESSING" ? "Rendering" : undefined
}
onClick={() => router.push(`/projects/${project.id}`)}
onEdit={() => setEditProject(project)}
onRename={() => setRenameProject(project)}
onDelete={() => setDeleteProject(project)}
/>
))}
</div>
+6 -3
View File
@@ -7,6 +7,7 @@ import { Provider as ReduxProvider } from "react-redux"
import { store } from "@shared/store"
import { BreadcrumbsProvider } from "./BreadcrumbsContext"
import { QueryClientProvider } from "./QueryClientProvider"
import { UserSync } from "./UserSync"
@@ -19,9 +20,11 @@ export const AppProviders = ({
<ReduxProvider store={store}>
<QueryClientProvider>
<UserSync />
<Theme accentColor="violet" grayColor="slate" radius="medium">
{children}
</Theme>
<BreadcrumbsProvider>
<Theme accentColor="iris" grayColor="slate" radius="medium" scaling="100%">
{children}
</Theme>
</BreadcrumbsProvider>
</QueryClientProvider>
</ReduxProvider>
)
+52
View File
@@ -0,0 +1,52 @@
"use client"
import {
createContext,
type JSX,
type ReactNode,
useContext,
useEffect,
useState,
} from "react"
export interface BreadcrumbItem {
label: string
href?: string
}
interface BreadcrumbsContextValue {
items: BreadcrumbItem[]
setItems: (items: BreadcrumbItem[]) => void
}
const BreadcrumbsContext = createContext<BreadcrumbsContextValue>({
items: [],
setItems: () => {},
})
export const BreadcrumbsProvider = ({
children,
}: {
children: ReactNode
}): JSX.Element => {
const [items, setItems] = useState<BreadcrumbItem[]>([])
return (
<BreadcrumbsContext.Provider value={{ items, setItems }}>
{children}
</BreadcrumbsContext.Provider>
)
}
export const useBreadcrumbs = (items: BreadcrumbItem[]): void => {
const { setItems } = useContext(BreadcrumbsContext)
useEffect(() => {
setItems(items)
return () => setItems([])
}, [JSON.stringify(items)])
}
export const useBreadcrumbItems = (): BreadcrumbItem[] => {
return useContext(BreadcrumbsContext).items
}
+4 -11
View File
@@ -1,25 +1,18 @@
import type { PayloadAction } from "@reduxjs/toolkit"
import { createSlice } from "@reduxjs/toolkit"
import { AppState } from "./types"
const initialState: AppState = {
currentScreenName: "",
}
const initialState: AppState = {}
const appStateSlice = createSlice({
name: "appState",
initialState,
reducers: {
setCurrentScreenName(state, action: PayloadAction<string>) {
state.currentScreenName = action.payload
},
resetAppState(state) {
state.currentScreenName = initialState.currentScreenName
resetAppState() {
return initialState
},
},
})
export const { resetAppState, setCurrentScreenName } = appStateSlice.actions
export const { resetAppState } = appStateSlice.actions
export const appStateReducer = appStateSlice.reducer
+1 -3
View File
@@ -1,3 +1 @@
export interface AppState {
currentScreenName: string
}
export interface AppState {}
+38 -8
View File
@@ -1,30 +1,60 @@
.root {
padding: 12px 24px;
background-color: variables.$color-white;
padding: 0 24px;
height: var(--header-height);
background-color: variables.$bg-default;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid variables.$border-default;
position: sticky;
top: 0;
z-index: 50;
}
.screenPath {
display: flex;
gap: 6px;
gap: 8px;
align-items: center;
}
.brandTitle {
@include typography.font-body-16(800);
@include typography.font-body-16(600);
color: variables.$text-primary;
cursor: pointer;
&:hover {
color: variables.$text-secondary;
}
}
.separator {
@include typography.font-body-14(400);
color: variables.$text-tertiary;
}
.separator, .screenName {
@include typography.font-body-14(500);
.screenName {
@include typography.font-body-14(400);
color: variables.$text-secondary;
}
.breadcrumbSegment {
display: flex;
gap: 8px;
align-items: center;
}
.breadcrumbLink {
@include typography.font-body-14(400);
color: variables.$text-secondary;
text-decoration: none;
&:hover {
color: variables.$text-primary;
}
}
.start {
display: flex;
gap: 16px;
gap: 12px;
align-items: center;
}
}
+28 -5
View File
@@ -2,11 +2,17 @@
import type { JSX } from "react"
import { Menu as MenuIcon } from "lucide-react"
import { FolderKanban, Menu as MenuIcon } from "lucide-react"
import dynamic from "next/dynamic"
import Link from "next/link"
import { FunctionComponent, useState } from "react"
import { NavigationDrawer } from "@entities/NavigationDrawer"
const NavigationDrawer = dynamic(
() => import("@entities/NavigationDrawer").then((m) => m.NavigationDrawer),
{ ssr: false },
)
import { UserDropdown } from "@entities/UserDropdown"
import { useBreadcrumbItems } from "@shared/context/BreadcrumbsContext"
import { useAppSelector } from "@shared/hooks/useAppSelector"
import { Button } from "@shared/ui"
@@ -15,6 +21,7 @@ import styles from "./Header.module.scss"
export const Header: FunctionComponent<IHeaderProps> = (): JSX.Element => {
const userData = useAppSelector((state) => state.user.user)
const breadcrumbs = useBreadcrumbItems()
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
@@ -26,9 +33,24 @@ export const Header: FunctionComponent<IHeaderProps> = (): JSX.Element => {
<MenuIcon size={24} />
</Button>
<div className={styles.screenPath}>
<h1 className={styles.brandTitle}>Coffee Project</h1>
<span className={styles.separator}>/</span>
<h3 className={styles.screenName}>Projects</h3>
<h1
className={styles.brandTitle}
onClick={() => setIsDrawerOpen(true)}
>
Coffee Project
</h1>
{breadcrumbs.map((item, index) => (
<span key={index} className={styles.breadcrumbSegment}>
<span className={styles.separator}>/</span>
{item.href ? (
<Link href={item.href} className={styles.breadcrumbLink}>
{item.label}
</Link>
) : (
<span className={styles.screenName}>{item.label}</span>
)}
</span>
))}
</div>
</div>
<div className={styles.end}>
@@ -41,6 +63,7 @@ export const Header: FunctionComponent<IHeaderProps> = (): JSX.Element => {
buttons={[
{
label: "Проекты",
icon: FolderKanban,
path: "/projects",
},
]}