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 { Button } from "@shared/ui"
import cls from "./HomePage.module.scss" import cls from "./HomePage.module.scss"
const HomePage = () => { const HomePage = () => {
useBreadcrumbs([{ label: "Home" }])
return ( return (
<div className={cls.homepage}> <div className={cls.homepage}>
<p className={cls.title}>Coffee Project Starter</p> <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 type { JSX } from "react"
import { useParams } from "next/navigation"
import { FunctionComponent } from "react" import { FunctionComponent } from "react"
import { useBreadcrumbs } from "@shared/context/BreadcrumbsContext"
import { IProjectWorkspacePageProps } from "./ProjectWorkspacePage.d" import { IProjectWorkspacePageProps } from "./ProjectWorkspacePage.d"
import styles from "./ProjectWorkspacePage.module.scss" import styles from "./ProjectWorkspacePage.module.scss"
export const ProjectWorkspacePage: FunctionComponent< export const ProjectWorkspacePage: FunctionComponent<
IProjectWorkspacePageProps IProjectWorkspacePageProps
> = (): JSX.Element => { > = (): JSX.Element => {
const params = useParams<{ project_id: string }>()
const projectId = params?.project_id ?? ""
useBreadcrumbs([
{ label: "Projects", href: "/projects" },
{ label: `Project ${projectId}` },
])
return ( return (
<div className={styles.root} data-testid="ProjectWorkspacePage"> <div className={styles.root} data-testid="ProjectWorkspacePage">
ProjectWorkspacePage Component ProjectWorkspacePage Component
+59 -4
View File
@@ -1,12 +1,21 @@
"use client" "use client"
import type { components } from "@shared/api/__generated__/openapi.types"
import type { JSX } from "react" import type { JSX } from "react"
import { PlusIcon } from "lucide-react" import { PlusIcon } from "lucide-react"
import { FunctionComponent, useState } from "react" import { FunctionComponent, useState } from "react"
import { useRouter } from "next/navigation"
import { ProjectCard } from "@entities/ProjectCard" 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 api from "@shared/api"
import { Button } from "@shared/ui" import { Button } from "@shared/ui"
import { StaticLoader } from "@shared/ui/Loader" import { StaticLoader } from "@shared/ui/Loader"
@@ -14,13 +23,18 @@ import { ProjectsHeader } from "@widgets/Projects/ProjectsHeader"
import { IProjectsPageProps } from "./ProjectsPage.d" import { IProjectsPageProps } from "./ProjectsPage.d"
import styles from "./ProjectsPage.module.scss" import styles from "./ProjectsPage.module.scss"
import { useRouter } from "next/navigation"
type ProjectRead = components["schemas"]["ProjectRead"]
export const ProjectsPage: FunctionComponent< export const ProjectsPage: FunctionComponent<
IProjectsPageProps IProjectsPageProps
> = (): JSX.Element => { > = (): JSX.Element => {
const router = useRouter(); useBreadcrumbs([{ label: "Projects", href: "/projects" }])
const router = useRouter()
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false) 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 { const {
data: projects, 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 /> <ProjectsHeader />
<div className={styles.projectList}> <div className={styles.projectList}>
@@ -64,12 +117,14 @@ export const ProjectsPage: FunctionComponent<
<ProjectCard <ProjectCard
key={project.id} key={project.id}
project={project} project={project}
// Mock random progress for demo since API doesn't provide it yet
progress={project.status === "PROCESSING" ? 45 : 0} progress={project.status === "PROCESSING" ? 45 : 0}
currentAction={ currentAction={
project.status === "PROCESSING" ? "Rendering" : undefined project.status === "PROCESSING" ? "Rendering" : undefined
} }
onClick={() => router.push(`/projects/${project.id}`)} onClick={() => router.push(`/projects/${project.id}`)}
onEdit={() => setEditProject(project)}
onRename={() => setRenameProject(project)}
onDelete={() => setDeleteProject(project)}
/> />
))} ))}
</div> </div>
+4 -1
View File
@@ -7,6 +7,7 @@ import { Provider as ReduxProvider } from "react-redux"
import { store } from "@shared/store" import { store } from "@shared/store"
import { BreadcrumbsProvider } from "./BreadcrumbsContext"
import { QueryClientProvider } from "./QueryClientProvider" import { QueryClientProvider } from "./QueryClientProvider"
import { UserSync } from "./UserSync" import { UserSync } from "./UserSync"
@@ -19,9 +20,11 @@ export const AppProviders = ({
<ReduxProvider store={store}> <ReduxProvider store={store}>
<QueryClientProvider> <QueryClientProvider>
<UserSync /> <UserSync />
<Theme accentColor="violet" grayColor="slate" radius="medium"> <BreadcrumbsProvider>
<Theme accentColor="iris" grayColor="slate" radius="medium" scaling="100%">
{children} {children}
</Theme> </Theme>
</BreadcrumbsProvider>
</QueryClientProvider> </QueryClientProvider>
</ReduxProvider> </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 { createSlice } from "@reduxjs/toolkit"
import { AppState } from "./types" import { AppState } from "./types"
const initialState: AppState = { const initialState: AppState = {}
currentScreenName: "",
}
const appStateSlice = createSlice({ const appStateSlice = createSlice({
name: "appState", name: "appState",
initialState, initialState,
reducers: { reducers: {
setCurrentScreenName(state, action: PayloadAction<string>) { resetAppState() {
state.currentScreenName = action.payload return initialState
},
resetAppState(state) {
state.currentScreenName = initialState.currentScreenName
}, },
}, },
}) })
export const { resetAppState, setCurrentScreenName } = appStateSlice.actions export const { resetAppState } = appStateSlice.actions
export const appStateReducer = appStateSlice.reducer export const appStateReducer = appStateSlice.reducer
+1 -3
View File
@@ -1,3 +1 @@
export interface AppState { export interface AppState {}
currentScreenName: string
}
+37 -7
View File
@@ -1,30 +1,60 @@
.root { .root {
padding: 12px 24px; padding: 0 24px;
background-color: variables.$color-white; height: var(--header-height);
background-color: variables.$bg-default;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
border-bottom: 1px solid variables.$border-default;
position: sticky;
top: 0;
z-index: 50;
} }
.screenPath { .screenPath {
display: flex; display: flex;
gap: 6px; gap: 8px;
align-items: center; align-items: center;
} }
.brandTitle { .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 { .screenName {
@include typography.font-body-14(500); @include typography.font-body-14(400);
color: variables.$text-secondary; 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 { .start {
display: flex; display: flex;
gap: 16px; gap: 12px;
align-items: center; align-items: center;
} }
+27 -4
View File
@@ -2,11 +2,17 @@
import type { JSX } from "react" 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 { 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 { UserDropdown } from "@entities/UserDropdown"
import { useBreadcrumbItems } from "@shared/context/BreadcrumbsContext"
import { useAppSelector } from "@shared/hooks/useAppSelector" import { useAppSelector } from "@shared/hooks/useAppSelector"
import { Button } from "@shared/ui" import { Button } from "@shared/ui"
@@ -15,6 +21,7 @@ import styles from "./Header.module.scss"
export const Header: FunctionComponent<IHeaderProps> = (): JSX.Element => { export const Header: FunctionComponent<IHeaderProps> = (): JSX.Element => {
const userData = useAppSelector((state) => state.user.user) const userData = useAppSelector((state) => state.user.user)
const breadcrumbs = useBreadcrumbItems()
const [isDrawerOpen, setIsDrawerOpen] = useState(false) const [isDrawerOpen, setIsDrawerOpen] = useState(false)
@@ -26,9 +33,24 @@ export const Header: FunctionComponent<IHeaderProps> = (): JSX.Element => {
<MenuIcon size={24} /> <MenuIcon size={24} />
</Button> </Button>
<div className={styles.screenPath}> <div className={styles.screenPath}>
<h1 className={styles.brandTitle}>Coffee Project</h1> <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> <span className={styles.separator}>/</span>
<h3 className={styles.screenName}>Projects</h3> {item.href ? (
<Link href={item.href} className={styles.breadcrumbLink}>
{item.label}
</Link>
) : (
<span className={styles.screenName}>{item.label}</span>
)}
</span>
))}
</div> </div>
</div> </div>
<div className={styles.end}> <div className={styles.end}>
@@ -41,6 +63,7 @@ export const Header: FunctionComponent<IHeaderProps> = (): JSX.Element => {
buttons={[ buttons={[
{ {
label: "Проекты", label: "Проекты",
icon: FolderKanban,
path: "/projects", path: "/projects",
}, },
]} ]}