Этот коммит содержится в:
Maurus Decimus
2026-07-30 17:16:05 +02:00
родитель f18f3012aa
Коммит 189e270785
31 изменённых файлов: 1478 добавлений и 695 удалений
+33 -7
Просмотреть файл
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useEffect } from 'react';
import { lazy, Suspense, useEffect, type ComponentType, type ReactNode } from 'react';
import { useSchemaStore } from '@/stores/schemaStore';
import { useCacheStore } from '@/stores/cacheStore';
import { useAccountStore } from '@/stores/accountStore';
@@ -12,11 +12,33 @@ import { resolveObject } from '@/lib/schemaResolver';
import { DynamicList } from '@/components/lists/DynamicList';
import { DynamicForm } from '@/components/forms/DynamicForm';
import { DynamicViewPage } from '@/components/views/DynamicViewPage';
import { DashboardView } from '@/features/dashboard/components/DashboardView';
import { DeliveryTracePage } from '@/features/troubleshoot/DeliveryTracePage';
import { LiveTracingPage } from '@/features/tracing/components/LiveTracingPage';
import { TraceDetailView } from '@/features/tracing/components/TraceDetailView';
import { ActionPage } from '@/features/actions/ActionPage';
import { LoadingFallback } from '@/components/common/LoadingFallback';
import type { Schema } from '@/types/schema';
function lazyFeature<M, P>(load: () => Promise<M>, select: (module: M) => ComponentType<P>) {
return lazy(() => load().then((module) => ({ default: select(module) })));
}
const DashboardView = lazyFeature(
() => import('@/features/dashboard/components/DashboardView'),
(m) => m.DashboardView,
);
const DeliveryTracePage = lazyFeature(
() => import('@/features/troubleshoot/DeliveryTracePage'),
(m) => m.DeliveryTracePage,
);
const LiveTracingPage = lazyFeature(
() => import('@/features/tracing/components/LiveTracingPage'),
(m) => m.LiveTracingPage,
);
const TraceDetailView = lazyFeature(
() => import('@/features/tracing/components/TraceDetailView'),
(m) => m.TraceDetailView,
);
const ActionPage = lazyFeature(
() => import('@/features/actions/ActionPage'),
(m) => m.ActionPage,
);
interface MainContentProps {
viewName?: string;
@@ -32,6 +54,10 @@ export function MainContent({ viewName, id, section }: MainContentProps) {
invalidateAllObjectLists();
}, [viewName, invalidateAllObjectLists]);
return <Suspense fallback={<LoadingFallback />}>{renderView(schema, viewName, id, section)}</Suspense>;
}
function renderView(schema: Schema | null, viewName?: string, id?: string, section?: string): ReactNode {
if (!viewName) {
return (
<div className="flex items-center justify-center p-8 text-muted-foreground">Select a view from the sidebar.</div>
@@ -84,7 +110,7 @@ export function MainContent({ viewName, id, section }: MainContentProps) {
}
if (id) {
if (resolved.objectName === 'x:Trace' && id !== 'new') {
if (resolved.objectName === 'x:Trace') {
return <TraceDetailView viewName={viewName} objectId={id} />;
}
const canUpdate = useAccountStore.getState().hasObjectPermission(resolved.permissionPrefix, 'Update');
+67 -22
Просмотреть файл
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import * as LucideIcons from 'lucide-react';
const { ChevronDown, Lock } = LucideIcons;
@@ -74,6 +74,25 @@ function subtreeHasVisibleLink(items: LayoutSubItem[], edition: string): boolean
return false;
}
interface AutoOpenCollapsibleProps {
containsActive: boolean;
children: React.ReactNode;
}
function AutoOpenCollapsible({ containsActive, children }: AutoOpenCollapsibleProps) {
const [open, setOpen] = useState(containsActive);
const [prevContainsActive, setPrevContainsActive] = useState(containsActive);
if (containsActive !== prevContainsActive) {
setPrevContainsActive(containsActive);
if (containsActive) setOpen(true);
}
return (
<Collapsible open={open} onOpenChange={setOpen}>
{children}
</Collapsible>
);
}
function checkLinkVisible(viewName: string): boolean {
const schema = useSchemaStore.getState().schema;
if (!schema) return true;
@@ -95,6 +114,8 @@ function checkIsEnterprise(viewName: string): boolean {
return isLinkEnterprise(schema, viewName, edition);
}
type ActiveItemRef = (el: HTMLButtonElement | null) => void;
interface SidebarSubItemProps {
item: LayoutSubItem;
depth: number;
@@ -103,9 +124,19 @@ interface SidebarSubItemProps {
navigate: ReturnType<typeof useNavigate>;
edition: string;
onUpsell: () => void;
activeItemRef: ActiveItemRef;
}
function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, edition, onUpsell }: SidebarSubItemProps) {
function SidebarSubItem({
item,
depth,
sectionName,
currentPath,
navigate,
edition,
onUpsell,
activeItemRef,
}: SidebarSubItemProps) {
if (item.type === 'link') {
if (!checkLinkVisible(item.viewName)) return null;
@@ -120,6 +151,7 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
return (
<Button
variant="ghost"
ref={isActive ? activeItemRef : undefined}
className={cn(
'w-full justify-start gap-2 font-normal',
isActive && 'bg-accent text-accent-foreground',
@@ -145,7 +177,7 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
const containsActive = subtreeContainsActive(item.items, currentPath, sectionName);
return (
<Collapsible defaultOpen={containsActive}>
<AutoOpenCollapsible containsActive={containsActive}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
@@ -167,10 +199,11 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
navigate={navigate}
edition={edition}
onUpsell={onUpsell}
activeItemRef={activeItemRef}
/>
))}
</CollapsibleContent>
</Collapsible>
</AutoOpenCollapsible>
);
}
@@ -184,9 +217,18 @@ interface SidebarTopItemProps {
navigate: ReturnType<typeof useNavigate>;
edition: string;
onUpsell: () => void;
activeItemRef: ActiveItemRef;
}
function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onUpsell }: SidebarTopItemProps) {
function SidebarTopItem({
item,
sectionName,
currentPath,
navigate,
edition,
onUpsell,
activeItemRef,
}: SidebarTopItemProps) {
if ('link' in item) {
const { name, icon, viewName } = item.link;
@@ -203,6 +245,7 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU
return (
<Button
variant="ghost"
ref={isActive ? activeItemRef : undefined}
className={cn('w-full justify-start gap-2 font-normal', isActive && 'bg-accent text-accent-foreground')}
onClick={() => {
if (isLocked) {
@@ -226,7 +269,7 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU
const containsActive = subtreeContainsActive(items, currentPath, sectionName);
return (
<Collapsible defaultOpen={containsActive}>
<AutoOpenCollapsible containsActive={containsActive}>
<CollapsibleTrigger asChild>
<Button variant="ghost" className="w-full justify-start gap-2 font-normal">
<LucideIcon name={icon} className="h-4 w-4 shrink-0" />
@@ -245,10 +288,11 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU
navigate={navigate}
edition={edition}
onUpsell={onUpsell}
activeItemRef={activeItemRef}
/>
))}
</CollapsibleContent>
</Collapsible>
</AutoOpenCollapsible>
);
}
@@ -264,23 +308,19 @@ export function Sidebar() {
const setSidebarOpen = useUIStore((s) => s.setSidebarOpen);
const schema = useSchemaStore((s) => s.schema);
const edition = useAccountStore((s) => s.edition);
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
const permissions = useAccountStore((s) => s.permissions);
const hasPermission = useAccountStore((s) => s.hasPermission);
const [upsellOpen, setUpsellOpen] = useState(false);
const activeItem = useRef<HTMLButtonElement | null>(null);
const activeItemRef = useCallback<ActiveItemRef>((el) => {
activeItem.current = el;
}, []);
const layouts = useMemo(
() =>
schema ? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission) : [],
[schema, edition, hasObjectPermission, hasPermission],
);
useEffect(() => {
if (!schema) return;
if (layouts.length === 0) return;
if (!layouts.find((l) => l.name === activeSection)) {
setActiveSection(layouts[0].name);
}
}, [schema, layouts, activeSection, setActiveSection]);
const layouts = useMemo(() => {
if (!schema) return [];
const canGet = (prefix: string) => permissions.includes(`${prefix}Get`);
return visibleLayouts(schema, edition, canGet, hasPermission);
}, [schema, edition, permissions, hasPermission]);
useEffect(() => {
if (typeof window === 'undefined') return;
@@ -289,6 +329,10 @@ export function Sidebar() {
}
}, [location.pathname, setSidebarOpen]);
useEffect(() => {
activeItem.current?.scrollIntoView({ block: 'nearest' });
}, [location.pathname, activeSection]);
if (!sidebarOpen || !schema) return null;
const layout: Layout | undefined = layouts.find((l) => l.name === activeSection);
@@ -296,7 +340,7 @@ export function Sidebar() {
const handleSectionClick = (target: Layout) => {
setActiveSection(target.name);
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
const canGet = (prefix: string) => permissions.includes(`${prefix}Get`);
const first =
findFirstAccessibleLinkInLayout(schema, target, edition, canGet, hasPermission) ??
findFirstVisibleLinkInLayout(schema, target, edition, canGet, hasPermission);
@@ -322,6 +366,7 @@ export function Sidebar() {
navigate={navigate}
edition={edition}
onUpsell={() => setUpsellOpen(true)}
activeItemRef={activeItemRef}
/>
))}
</nav>
+29 -12
Просмотреть файл
@@ -9,8 +9,7 @@ import { useTranslation } from 'react-i18next';
import * as LucideIcons from 'lucide-react';
const { Sun, Moon, User, LogOut, Check, Menu, Sparkles, Search } = LucideIcons;
import { Button } from '@/components/ui/button';
import { GlobalSearch } from '@/components/common/GlobalSearch';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { CommandPalette } from '@/components/common/CommandPalette';
import {
DropdownMenu,
DropdownMenuContent,
@@ -27,10 +26,12 @@ import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, visibleL
import { useUIStore } from '@/stores/uiStore';
import { useAuthStore } from '@/stores/authStore';
import { buildEndSessionUrl, getPostLogoutRedirectUri } from '@/services/auth/oauth';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useAccountStore } from '@/stores/accountStore';
import { useSchemaStore } from '@/stores/schemaStore';
const IS_MAC = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
function getIcon(name: string): LucideIcons.LucideIcon {
const formatted = name
.split('-')
@@ -55,7 +56,18 @@ export function TopBar() {
const hasPermission = useAccountStore((s) => s.hasPermission);
const schema = useSchemaStore((s) => s.schema);
const [upsellOpen, setUpsellOpen] = useState(false);
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
useEffect(() => {
function handleGlobalKeyDown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'k') {
e.preventDefault();
setPaletteOpen((open) => !open);
}
}
document.addEventListener('keydown', handleGlobalKeyDown);
return () => document.removeEventListener('keydown', handleGlobalKeyDown);
}, []);
const navigableLayouts = schema
? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission)
@@ -81,7 +93,17 @@ export function TopBar() {
</TooltipProvider>
<div className="hidden min-w-0 flex-1 items-center justify-center px-4 md:flex">
<GlobalSearch />
<button
type="button"
onClick={() => setPaletteOpen(true)}
className="flex h-9 w-full max-w-md items-center gap-2 rounded-md border border-input bg-transparent px-3 text-sm text-muted-foreground shadow-sm transition-colors hover:bg-accent"
>
<Search className="h-4 w-4" />
<span className="flex-1 text-left">{t('globalSearch.placeholder', 'Search pages, fields, settings...')}</span>
<kbd className="pointer-events-none flex h-5 select-none items-center rounded border bg-muted px-1.5 font-mono text-[10px] font-medium">
{IS_MAC ? '⌘K' : 'Ctrl K'}
</kbd>
</button>
</div>
<div className="ml-auto flex items-center gap-2 md:ml-0">
@@ -91,18 +113,13 @@ export function TopBar() {
variant="ghost"
size="icon"
className="md:hidden"
onClick={() => setMobileSearchOpen(true)}
onClick={() => setPaletteOpen(true)}
aria-label={t('search', 'Search')}
>
<Search className="h-4 w-4" />
</Button>
<Dialog open={mobileSearchOpen} onOpenChange={setMobileSearchOpen}>
<DialogContent className="top-4 translate-y-0 max-w-[calc(100vw-2rem)] p-4">
<DialogTitle className="sr-only">{t('search', 'Search')}</DialogTitle>
<GlobalSearch autoFocus onAfterSelect={() => setMobileSearchOpen(false)} />
</DialogContent>
</Dialog>
<CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />
<Button variant="ghost" size="icon" onClick={toggleTheme} aria-label={t('toggleTheme', 'Toggle theme')}>
{theme === 'light' ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}