/
dmitriysturov
/
workspaces_platform
Обзор
Документация
Войти
/
dmitriysturov
/
workspaces_platform
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
frontend/src/components/layout/AppShell.tsx
404 строки
14 KB
dmitriysturov
vkr
04 июн 2026, 22:24
04 июн 2026, 22:24
971dfdc
Код
Авторство
О чём код?
import type { ReactNode } from 'react' import { useEffect, useMemo, useState } from 'react' import { useAuth } from '../../auth/AuthContext' import { navigate, usePathname } from '../../router/router' import type { AccessGroup, EnabledService, Project, ServiceDefinition, Workspace } from '../../types' import { userAvatarSrc, userInitials } from '../../utils/avatar' import { userDisplayName } from '../../utils/format' import { appIconNode, isRunnableServiceSlug, serviceDisplayName, serviceIconNode, sidebarIconKeyFor } from '../../utils/serviceUi' import { NotificationsMenu } from '../NotificationsMenu' import { Badge } from '../ui/Badge' import { Button } from '../ui/Button' const SIDEBAR_STORAGE_KEY = 'workspace_platform_sidebar_collapsed' type NavItem = { key: string label: string icon: ReactNode href?: string active?: boolean disabled?: boolean } type ShellKind = 'main' | 'workspace' | 'project' type AppShellProps = { kind: ShellKind title: string subtitle?: string children: ReactNode workspace?: Workspace | null project?: Project | null externalProjectMode?: boolean activeItem?: string onSelectItem?: (item: string) => void enabledServices?: EnabledService[] serviceCatalog?: ServiceDefinition[] currentUserGroups?: AccessGroup[] } function readCollapsedState() { return localStorage.getItem(SIDEBAR_STORAGE_KEY) === 'true' } function Brand({ collapsed }: { collapsed: boolean }) { return ( <button type="button" className="shell-brand" onClick={() => navigate('/dashboard')} title="Workspace Platform"> <span className="brand-mark">WP</span> {!collapsed && ( <span> <strong>Workspace Platform</strong> <small>Рабочая платформа</small> </span> )} </button> ) } function SidebarLink({ item, collapsed, onSelectItem }: { item: NavItem; collapsed: boolean; onSelectItem?: (item: string) => void }) { const className = `shell-nav-item ${item.active ? 'is-active' : ''} ${item.disabled ? 'is-disabled' : ''}` return ( <button type="button" className={className} title={collapsed ? item.label : undefined} disabled={item.disabled} onClick={() => { if (item.disabled) { return } if (item.href) { navigate(item.href) return } onSelectItem?.(item.key) }} > <span className="shell-nav-icon" aria-hidden="true"> {item.icon} </span> {!collapsed && <span>{item.label}</span>} </button> ) } function serviceItems({ kind, workspace, project, enabledServices, serviceCatalog, pathname, activeItem, }: Pick<AppShellProps, 'kind' | 'workspace' | 'project' | 'enabledServices' | 'serviceCatalog' | 'activeItem'> & { pathname: string }) { if ((kind !== 'workspace' && kind !== 'project') || !enabledServices?.length) { return [] } const catalogBySlug = new Map((serviceCatalog ?? []).map((service) => [service.slug, service])) return enabledServices.filter((enabled) => enabled.serviceSlug !== 'wiki').map<NavItem>((enabled) => { const definition = catalogBySlug.get(enabled.serviceSlug) const isRunnable = isRunnableServiceSlug(enabled.serviceSlug) const servicePath = ['tasks', 'kanban', 'gantt'].includes(enabled.serviceSlug) ? enabled.serviceSlug : enabled.serviceSlug === 'whiteboard' ? 'whiteboards' : `services/${enabled.serviceSlug}` const href = isRunnable ? kind === 'workspace' && workspace ? `/workspaces/${workspace.id}/${servicePath}` : project ? `/projects/${project.id}/${servicePath}` : undefined : undefined const iconKey = sidebarIconKeyFor(enabled, definition) const label = serviceDisplayName(enabled.serviceSlug, definition) return { key: `service:${enabled.serviceSlug}`, label: isRunnable ? label : `${label} · скоро`, icon: serviceIconNode(iconKey), href, disabled: !isRunnable, active: activeItem === `service:${enabled.serviceSlug}` || (href ? pathname === href : false), } }) } function ShellSidebar({ kind, workspace, project, externalProjectMode, activeItem, onSelectItem, enabledServices, serviceCatalog, }: Pick<AppShellProps, 'kind' | 'workspace' | 'project' | 'externalProjectMode' | 'activeItem' | 'onSelectItem' | 'enabledServices' | 'serviceCatalog'>) { const pathname = usePathname() const [collapsed, setCollapsed] = useState(readCollapsedState) useEffect(() => { localStorage.setItem(SIDEBAR_STORAGE_KEY, String(collapsed)) }, [collapsed]) const items = useMemo<NavItem[]>(() => { if (kind === 'workspace' && workspace) { const permissions = workspace.permissions return [ { key: 'overview', label: 'Обзор', icon: appIconNode('home'), active: activeItem === 'overview' }, { key: 'members', label: 'Участники', icon: appIconNode('members'), active: activeItem === 'members' }, { key: 'projects', label: 'Проекты', icon: appIconNode('projects'), active: activeItem === 'projects' }, { key: 'settings', label: 'Настройки', icon: appIconNode('settings'), active: activeItem === 'settings', disabled: !permissions.canUpdateWorkspace && !permissions.canManageWorkspaceMembers && !permissions.canManageWorkspaceServices && !permissions.canDeleteWorkspace, }, ].filter((item) => !item.disabled) } if (kind === 'project' && project) { const permissions = project.permissions const canManageProjectSettings = permissions.canUpdateProject || permissions.canDeleteProject || permissions.canManageProjectMembers || permissions.canManageProjectServices return [ { key: 'overview', label: 'Обзор проекта', icon: appIconNode('home'), active: activeItem === 'overview' }, { key: 'members', label: 'Участники проекта', icon: appIconNode('members'), active: activeItem === 'members' }, { key: 'settings', label: 'Настройки', icon: appIconNode('settings'), active: activeItem === 'settings', disabled: !canManageProjectSettings }, ].filter((item) => !item.disabled) } return [ { key: 'dashboard', label: 'Главная', icon: appIconNode('home'), href: '/dashboard', active: pathname === '/dashboard' }, { key: 'workspaces', label: 'Пространства', icon: appIconNode('workspace'), href: '/workspaces', active: pathname === '/workspaces' }, { key: 'projects', label: 'Проекты', icon: appIconNode('projects'), href: '/projects', active: pathname === '/projects' }, { key: 'services', label: 'Сервисы', icon: appIconNode('overview'), href: '/services', active: pathname === '/services' }, { key: 'invitations', label: 'Приглашения', icon: appIconNode('invitations'), href: '/invitations', active: pathname === '/invitations' }, ] }, [activeItem, kind, pathname, project, workspace]) const connectedServices = useMemo( () => serviceItems({ kind, workspace, project, enabledServices, serviceCatalog, pathname, activeItem }), [activeItem, enabledServices, kind, pathname, project, serviceCatalog, workspace], ) return ( <aside className={`shell-sidebar ${collapsed ? 'is-collapsed' : ''}`}> <Brand collapsed={collapsed} /> {kind === 'workspace' && workspace && ( <div className="shell-context" title={collapsed ? workspace.name : undefined}> {!collapsed && ( <> <span>Рабочее пространство</span> <strong>{workspace.name}</strong> </> )} </div> )} {kind === 'project' && project && ( <div className="shell-context" title={collapsed ? project.name : undefined}> {!collapsed && ( <> <span>{externalProjectMode ? 'Внешний проект' : 'Проект'}</span> <strong>{project.name}</strong> </> )} </div> )} <nav className="shell-nav" aria-label="Навигация"> {items.map((item) => ( <SidebarLink key={item.key} item={item} collapsed={collapsed} onSelectItem={onSelectItem} /> ))} </nav> {connectedServices.length > 0 && ( <nav className="shell-nav shell-nav-services" aria-label="Подключенные сервисы"> {!collapsed && <span className="shell-nav-caption">Сервисы</span>} {connectedServices.map((item) => ( <SidebarLink key={item.key} item={item} collapsed={collapsed} onSelectItem={onSelectItem} /> ))} </nav> )} <div className="shell-sidebar-actions"> {kind === 'project' && project?.canOpenWorkspace && workspace && !externalProjectMode && ( <div className="shell-nav shell-nav-management"> <SidebarLink item={{ key: 'workspace', label: 'К рабочему пространству', icon: appIconNode('workspace'), href: `/workspaces/${project.workspaceId}` }} collapsed={collapsed} onSelectItem={onSelectItem} /> </div> )} <button type="button" className="sidebar-collapse-button" onClick={() => setCollapsed((current) => !current)} title={collapsed ? 'Развернуть меню' : 'Свернуть меню'} aria-label={collapsed ? 'Развернуть меню' : 'Свернуть меню'} > <span aria-hidden="true">{collapsed ? '>' : '<'}</span> {!collapsed && <span>Свернуть</span>} </button> </div> </aside> ) } function TopbarGroups({ groups }: { groups?: AccessGroup[] }) { if (!groups?.length) { return null } const visibleGroups = groups.length <= 3 ? groups : groups.slice(0, 2) const hiddenCount = groups.length - visibleGroups.length const fullList = groups.map((group) => group.name).join(', ') return ( <div className="topbar-groups" title={fullList} aria-label={`Группы: ${fullList}`}> {visibleGroups.map((group) => ( <Badge key={group.id} tone={group.isSystem ? 'success' : 'neutral'}> {group.name} </Badge> ))} {hiddenCount > 0 && <Badge tone="neutral">+{hiddenCount}</Badge>} </div> ) } export function AppShell({ kind, title, subtitle, children, workspace, project, externalProjectMode = false, activeItem, onSelectItem, enabledServices, serviceCatalog, currentUserGroups, }: AppShellProps) { const { token, user, logout } = useAuth() const displayName = userDisplayName(user) const avatarSrc = userAvatarSrc(user) return ( <div className="shell-layout"> <ShellSidebar kind={kind} workspace={workspace} project={project} externalProjectMode={externalProjectMode} activeItem={activeItem} onSelectItem={onSelectItem} enabledServices={enabledServices} serviceCatalog={serviceCatalog} /> <div className="shell-main-column"> <header className="shell-topbar" aria-label={subtitle ? `${title}. ${subtitle}` : title}> <div className="shell-topbar-space" aria-hidden="true" /> <div className="shell-user-actions"> <TopbarGroups groups={currentUserGroups} /> <NotificationsMenu token={token} /> <button type="button" className="topbar-profile-link" onClick={() => navigate('/profile')}> <span className="user-avatar" aria-hidden="true"> {avatarSrc ? <img src={avatarSrc} alt="" /> : userInitials(user)} </span> <span className="profile-link-copy"> <strong>{displayName}</strong> <span>Профиль</span> </span> </button> <Button type="button" variant="secondary" onClick={() => { logout() navigate('/login') }} > Выйти </Button> </div> </header> <main className="shell-content">{children}</main> </div> </div> ) } export function MainShell({ title, subtitle, children }: Omit<AppShellProps, 'kind'>) { return ( <AppShell kind="main" title={title} subtitle={subtitle}> {children} </AppShell> ) } export function WorkspaceShell({ title, subtitle, children, workspace, activeItem, onSelectItem, enabledServices, serviceCatalog, currentUserGroups, }: Omit<AppShellProps, 'kind' | 'project' | 'externalProjectMode'> & { workspace: Workspace | null }) { return ( <AppShell kind="workspace" title={title} subtitle={subtitle} workspace={workspace} activeItem={activeItem} onSelectItem={onSelectItem} enabledServices={enabledServices} serviceCatalog={serviceCatalog} currentUserGroups={currentUserGroups} > {children} </AppShell> ) } export function ProjectShell({ title, subtitle, children, workspace, project, externalProjectMode, activeItem, onSelectItem, enabledServices, serviceCatalog, currentUserGroups, }: Omit<AppShellProps, 'kind'> & { project: Project | null }) { return ( <AppShell kind="project" title={title} subtitle={subtitle} workspace={workspace} project={project} externalProjectMode={externalProjectMode} activeItem={activeItem} onSelectItem={onSelectItem} enabledServices={enabledServices} serviceCatalog={serviceCatalog} currentUserGroups={currentUserGroups} > {children} </AppShell> ) }