Skip to content

Commit d12f03e

Browse files
Copilothotlong
andcommitted
feat: add skeleton states, toast notifications, keyboard shortcuts, responsive sidebar, breadcrumb dropdowns, recent items, and file upload integration
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 97b8ac3 commit d12f03e

19 files changed

Lines changed: 825 additions & 10 deletions

apps/console/src/App.tsx

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation, useSe
22
import { useState, useEffect } from 'react';
33
import { ObjectForm } from '@object-ui/plugin-form';
44
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Empty, EmptyTitle } from '@object-ui/components';
5+
import { toast } from 'sonner';
56
import { SchemaRendererProvider } from '@object-ui/react';
67
import { ObjectStackAdapter } from './dataSource';
78
import type { ConnectionState } from './dataSource';
@@ -20,6 +21,8 @@ import { PageView } from './components/PageView';
2021
import { ReportView } from './components/ReportView';
2122
import { ExpressionProvider } from './context/ExpressionProvider';
2223
import { ConditionalAuthWrapper } from './components/ConditionalAuthWrapper';
24+
import { KeyboardShortcutsDialog } from './components/KeyboardShortcutsDialog';
25+
import { useRecentItems } from './hooks/useRecentItems';
2326

2427
// Auth Pages
2528
import { LoginPage } from './pages/LoginPage';
@@ -35,6 +38,7 @@ import { ProfilePage } from './pages/system/ProfilePage';
3538

3639
import { useParams } from 'react-router-dom';
3740
import { ThemeProvider } from './components/theme-provider';
41+
import { ConsoleToaster } from './components/ConsoleToaster';
3842

3943
export function AppContent() {
4044
const [dataSource, setDataSource] = useState<ObjectStackAdapter | null>(null);
@@ -55,6 +59,7 @@ export function AppContent() {
5559
const [isDialogOpen, setIsDialogOpen] = useState(false);
5660
const [editingRecord, setEditingRecord] = useState<any>(null);
5761
const [refreshKey, setRefreshKey] = useState(0);
62+
const { addRecentItem } = useRecentItems();
5863

5964
// Branding is now applied by AppShell via ConsoleLayout
6065

@@ -116,6 +121,37 @@ export function AppContent() {
116121

117122
const currentObjectDef = allObjects.find((o: any) => o.name === objectNameFromPath);
118123

124+
// Track recent items on route change
125+
useEffect(() => {
126+
if (!activeApp) return;
127+
const basePath = `/apps/${activeApp.name}`;
128+
if (objectNameFromPath) {
129+
const obj = allObjects.find((o: any) => o.name === objectNameFromPath);
130+
if (obj) {
131+
addRecentItem({
132+
id: `object:${obj.name}`,
133+
label: obj.label || obj.name,
134+
href: `${basePath}/${obj.name}`,
135+
type: 'object',
136+
});
137+
}
138+
} else if (cleanParts[2] === 'dashboard' && cleanParts[3]) {
139+
addRecentItem({
140+
id: `dashboard:${cleanParts[3]}`,
141+
label: cleanParts[3].replace(/[-_]/g, ' ').replace(/\b\w/g, (c: string) => c.toUpperCase()),
142+
href: `${basePath}/dashboard/${cleanParts[3]}`,
143+
type: 'dashboard',
144+
});
145+
} else if (cleanParts[2] === 'report' && cleanParts[3]) {
146+
addRecentItem({
147+
id: `report:${cleanParts[3]}`,
148+
label: cleanParts[3].replace(/[-_]/g, ' ').replace(/\b\w/g, (c: string) => c.toUpperCase()),
149+
href: `${basePath}/report/${cleanParts[3]}`,
150+
type: 'report',
151+
});
152+
}
153+
}, [location.pathname]); // eslint-disable-line react-hooks/exhaustive-deps
154+
119155
const handleEdit = (record: any) => {
120156
setEditingRecord(record);
121157
setIsDialogOpen(true);
@@ -166,6 +202,7 @@ export function AppContent() {
166202
objects={allObjects}
167203
onAppChange={handleAppChange}
168204
/>
205+
<KeyboardShortcutsDialog />
169206
<SchemaRendererProvider dataSource={dataSource || {}}>
170207
<ErrorBoundary>
171208
<Routes>
@@ -242,7 +279,15 @@ export function AppContent() {
242279
? currentObjectDef.fields.map((f: any) => typeof f === 'string' ? f : f.name)
243280
: Object.keys(currentObjectDef.fields))
244281
: [],
245-
onSuccess: () => { setIsDialogOpen(false); setRefreshKey(k => k + 1); },
282+
onSuccess: () => {
283+
setIsDialogOpen(false);
284+
setRefreshKey(k => k + 1);
285+
toast.success(
286+
editingRecord
287+
? `${currentObjectDef?.label} updated successfully`
288+
: `${currentObjectDef?.label} created successfully`
289+
);
290+
},
246291
onCancel: () => setIsDialogOpen(false),
247292
showSubmit: true,
248293
showCancel: true,
@@ -292,6 +337,7 @@ function RootRedirect() {
292337
export function App() {
293338
return (
294339
<ThemeProvider defaultTheme="system" storageKey="object-ui-theme">
340+
<ConsoleToaster position="bottom-right" />
295341
<ConditionalAuthWrapper authUrl="/api/auth">
296342
<BrowserRouter basename="/">
297343
<Routes>

apps/console/src/components/AppHeader.tsx

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,12 @@ import {
1919
SidebarTrigger,
2020
Button,
2121
Separator,
22+
DropdownMenu,
23+
DropdownMenuTrigger,
24+
DropdownMenuContent,
25+
DropdownMenuItem,
2226
} from '@object-ui/components';
23-
import { Search, Bell, HelpCircle } from 'lucide-react';
27+
import { Search, Bell, HelpCircle, ChevronDown } from 'lucide-react';
2428

2529
import { ModeToggle } from './mode-toggle';
2630
import { ConnectionStatus } from './ConnectionStatus';
@@ -43,10 +47,17 @@ export function AppHeader({ appName, objects, connectionState }: { appName: stri
4347

4448
const appNameFromRoute = params.appName || pathParts[1];
4549
const routeType = pathParts[2]; // 'contact', 'dashboard', 'page', 'report'
50+
const baseHref = `/apps/${appNameFromRoute}`;
4651

47-
// Determine breadcrumb items
48-
const breadcrumbItems: { label: string; href?: string }[] = [
49-
{ label: appName, href: `/apps/${appNameFromRoute}` }
52+
// Build sibling links for quick navigation dropdown
53+
const objectSiblings = objects.map((o: any) => ({
54+
label: o.label || o.name,
55+
href: `${baseHref}/${o.name}`,
56+
}));
57+
58+
// Determine breadcrumb items with optional siblings for dropdown
59+
const breadcrumbItems: { label: string; href?: string; siblings?: { label: string; href: string }[] }[] = [
60+
{ label: appName, href: baseHref }
5061
];
5162

5263
if (routeType === 'dashboard') {
@@ -75,7 +86,8 @@ export function AppHeader({ appName, objects, connectionState }: { appName: stri
7586
if (currentObject) {
7687
breadcrumbItems.push({
7788
label: currentObject.label || routeType,
78-
href: `/apps/${appNameFromRoute}/${routeType}`
89+
href: `/apps/${appNameFromRoute}/${routeType}`,
90+
siblings: objectSiblings,
7991
});
8092

8193
// Check if viewing a specific record
@@ -102,7 +114,37 @@ export function AppHeader({ appName, objects, connectionState }: { appName: stri
102114
{index > 0 && <BreadcrumbSeparator />}
103115
<BreadcrumbItem>
104116
{index === breadcrumbItems.length - 1 || !item.href ? (
105-
<BreadcrumbPage className="truncate max-w-[200px]">{item.label}</BreadcrumbPage>
117+
item.siblings && item.siblings.length > 1 ? (
118+
<DropdownMenu>
119+
<DropdownMenuTrigger className="flex items-center gap-1 text-sm font-medium">
120+
{item.label}
121+
<ChevronDown className="h-3 w-3" />
122+
</DropdownMenuTrigger>
123+
<DropdownMenuContent align="start" className="max-h-64 overflow-y-auto">
124+
{item.siblings.map((sibling) => (
125+
<DropdownMenuItem key={sibling.href} asChild>
126+
<Link to={sibling.href} className="w-full">{sibling.label}</Link>
127+
</DropdownMenuItem>
128+
))}
129+
</DropdownMenuContent>
130+
</DropdownMenu>
131+
) : (
132+
<BreadcrumbPage className="truncate max-w-[200px]">{item.label}</BreadcrumbPage>
133+
)
134+
) : item.siblings && item.siblings.length > 1 ? (
135+
<DropdownMenu>
136+
<DropdownMenuTrigger className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors">
137+
{item.label}
138+
<ChevronDown className="h-3 w-3" />
139+
</DropdownMenuTrigger>
140+
<DropdownMenuContent align="start" className="max-h-64 overflow-y-auto">
141+
{item.siblings.map((sibling) => (
142+
<DropdownMenuItem key={sibling.href} asChild>
143+
<Link to={sibling.href} className="w-full">{sibling.label}</Link>
144+
</DropdownMenuItem>
145+
))}
146+
</DropdownMenuContent>
147+
</DropdownMenu>
106148
) : (
107149
<BreadcrumbLink asChild>
108150
<Link to={item.href} className="truncate max-w-[150px]">{item.label}</Link>

apps/console/src/components/AppSidebar.tsx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,19 @@ import {
3636
CollapsibleTrigger,
3737
CollapsibleContent,
3838
} from '@object-ui/components';
39-
import {
39+
import {
4040
ChevronsUpDown,
4141
Plus,
4242
Settings,
4343
LogOut,
4444
Database,
4545
ChevronRight,
46+
Clock,
4647
} from 'lucide-react';
4748
import appConfig from '../../objectstack.shared';
4849
import { useExpressionContext, evaluateVisibility } from '../context/ExpressionProvider';
4950
import { useAuth, getUserInitials } from '@object-ui/auth';
51+
import { useRecentItems } from '../hooks/useRecentItems';
5052

5153
/**
5254
* Resolve a Lucide icon component by name string.
@@ -77,6 +79,7 @@ function getIcon(name?: string): React.ComponentType<any> {
7779
export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: string, onAppChange: (name: string) => void }) {
7880
const { isMobile } = useSidebar();
7981
const { user, signOut } = useAuth();
82+
const { recentItems } = useRecentItems();
8083

8184
const apps = appConfig.apps || [];
8285
// Filter out inactive apps
@@ -155,6 +158,32 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
155158

156159
<SidebarContent>
157160
<NavigationTree items={activeApp.navigation || []} activeAppName={activeAppName} />
161+
162+
{/* Recent Items */}
163+
{recentItems.length > 0 && (
164+
<SidebarGroup>
165+
<SidebarGroupLabel className="flex items-center gap-1.5">
166+
<Clock className="h-3.5 w-3.5" />
167+
Recent
168+
</SidebarGroupLabel>
169+
<SidebarGroupContent>
170+
<SidebarMenu>
171+
{recentItems.slice(0, 5).map(item => (
172+
<SidebarMenuItem key={item.id}>
173+
<SidebarMenuButton asChild tooltip={item.label}>
174+
<Link to={item.href}>
175+
<span className="text-muted-foreground">
176+
{item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : '📄'}
177+
</span>
178+
<span className="truncate">{item.label}</span>
179+
</Link>
180+
</SidebarMenuButton>
181+
</SidebarMenuItem>
182+
))}
183+
</SidebarMenu>
184+
</SidebarGroupContent>
185+
</SidebarGroup>
186+
)}
158187
</SidebarContent>
159188

160189
<SidebarFooter>

apps/console/src/components/ConsoleLayout.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import React from 'react';
1010
import { AppShell } from '@object-ui/layout';
1111
import { AppSidebar } from './AppSidebar';
1212
import { AppHeader } from './AppHeader';
13+
import { useResponsiveSidebar } from '../hooks/useResponsiveSidebar';
1314
import type { ConnectionState } from '../dataSource';
1415

1516
interface ConsoleLayoutProps {
@@ -21,6 +22,12 @@ interface ConsoleLayoutProps {
2122
connectionState?: ConnectionState;
2223
}
2324

25+
/** Inner component that can access SidebarProvider context */
26+
function ConsoleLayoutInner({ children }: { children: React.ReactNode }) {
27+
useResponsiveSidebar();
28+
return <>{children}</>;
29+
}
30+
2431
export function ConsoleLayout({
2532
children,
2633
activeAppName,
@@ -59,7 +66,9 @@ export function ConsoleLayout({
5966
: undefined
6067
}
6168
>
62-
{children}
69+
<ConsoleLayoutInner>
70+
{children}
71+
</ConsoleLayoutInner>
6372
</AppShell>
6473
);
6574
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* ConsoleToaster
3+
*
4+
* Sonner Toaster configured for the console app. Uses the local ThemeProvider
5+
* instead of next-themes to resolve the current color scheme.
6+
* @module
7+
*/
8+
9+
import { Toaster as Sonner } from 'sonner';
10+
import { CircleCheck, Info, LoaderCircle, OctagonX, TriangleAlert } from 'lucide-react';
11+
import { useTheme } from './theme-provider';
12+
13+
type ToasterProps = React.ComponentProps<typeof Sonner>;
14+
15+
export function ConsoleToaster(props: ToasterProps) {
16+
const { theme = 'system' } = useTheme();
17+
18+
return (
19+
<Sonner
20+
theme={theme as ToasterProps['theme']}
21+
className="toaster group"
22+
icons={{
23+
success: <CircleCheck className="h-4 w-4" />,
24+
info: <Info className="h-4 w-4" />,
25+
warning: <TriangleAlert className="h-4 w-4" />,
26+
error: <OctagonX className="h-4 w-4" />,
27+
loading: <LoaderCircle className="h-4 w-4 animate-spin" />,
28+
}}
29+
toastOptions={{
30+
classNames: {
31+
toast:
32+
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
33+
description: 'group-[.toast]:text-muted-foreground',
34+
actionButton:
35+
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
36+
cancelButton:
37+
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
38+
},
39+
}}
40+
{...props}
41+
/>
42+
);
43+
}

apps/console/src/components/DashboardView.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,34 @@
33
* Renders a dashboard based on the dashboardName parameter
44
*/
55

6+
import { useState, useEffect } from 'react';
67
import { useParams } from 'react-router-dom';
78
import { DashboardRenderer } from '@object-ui/plugin-dashboard';
89
import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
910
import { LayoutDashboard } from 'lucide-react';
1011
import { MetadataToggle, MetadataPanel, useMetadataInspector } from './MetadataInspector';
12+
import { SkeletonDashboard } from './skeletons';
1113
import appConfig from '../../objectstack.shared';
1214

1315
export function DashboardView({ dataSource }: { dataSource?: any }) {
1416
const { dashboardName } = useParams<{ dashboardName: string }>();
1517
const { showDebug, toggleDebug } = useMetadataInspector();
18+
const [isLoading, setIsLoading] = useState(true);
19+
20+
useEffect(() => {
21+
// Simulate initial data load; real implementation would await dataSource readiness
22+
const timer = setTimeout(() => setIsLoading(false), 0);
23+
return () => clearTimeout(timer);
24+
}, [dashboardName]);
1625

1726
// Find dashboard definition in config
1827
// In a real implementation, this would fetch from the server
1928
const dashboard = appConfig.dashboards?.find((d: any) => d.name === dashboardName);
2029

30+
if (isLoading) {
31+
return <SkeletonDashboard />;
32+
}
33+
2134
if (!dashboard) {
2235
return (
2336
<div className="h-full flex items-center justify-center p-8">

0 commit comments

Comments
 (0)