Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions web-client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 2 additions & 6 deletions web-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,11 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.545.0",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"shadcn": "^4.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0",
"tw-animate-css": "^1.4.0",
Expand Down Expand Up @@ -62,11 +64,5 @@
"typescript-eslint": "^8.59.3",
"vite": "^8.0.0",
"vitest": "^4.1.5"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"lightningcss"
]
}
}
18 changes: 18 additions & 0 deletions web-client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { RouterProvider } from '@tanstack/react-router';
import { QueryClientProvider } from '@tanstack/react-query';
import { getRouter } from './router';
import { queryClient } from './lib/queryClient';
import { Toaster } from './components/ui/sonner';

const router = getRouter();

function App() {
return (
<QueryClientProvider client={queryClient}>
<Toaster />
<RouterProvider router={router} />
</QueryClientProvider>
);
}

export default App;
35 changes: 35 additions & 0 deletions web-client/src/components/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { ReactNode } from 'react';
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '#/components/ui/empty';

interface EmptyStateProps {
icon?: ReactNode;
title: string;
description?: string;
children?: ReactNode;
}

function EmptyState({ icon, title, description, children }: EmptyStateProps) {
return (
<Empty>
<EmptyHeader>
{icon && <EmptyMedia>{icon}</EmptyMedia>}
<EmptyContent>
<EmptyTitle>{title}</EmptyTitle>
{description && (
<EmptyDescription>{description}</EmptyDescription>
)}
</EmptyContent>
</EmptyHeader>
{children}
</Empty>
);
}

export { EmptyState };
57 changes: 57 additions & 0 deletions web-client/src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';

interface ErrorBoundaryProps {
children: ReactNode;
fallback?: (error: Error, retry: () => void) => ReactNode;
}

interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}

class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}

static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}

componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
console.error('ErrorBoundary caught an error:', error, errorInfo);
}

handleRetry = (): void => {
this.setState({ hasError: false, error: null });
};

render(): ReactNode {
if (this.state.hasError && this.state.error) {
if (this.props.fallback) {
return this.props.fallback(this.state.error, this.handleRetry);
}

return (
<div className="flex flex-col items-center justify-center gap-4 p-8 text-center">
<p className="text-sm text-muted-foreground">
{this.state.error.message}
</p>
<button
type="button"
onClick={this.handleRetry}
className="inline-flex h-8 items-center justify-center rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/80"
>
Retry
</button>
</div>
);
}

return this.props.children;
}
}

export { ErrorBoundary };
24 changes: 24 additions & 0 deletions web-client/src/components/LoadingSpinner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { cn } from '#/lib/utils';
import { Spinner } from '#/components/ui/spinner';

const sizeClasses = {
sm: 'size-3',
md: 'size-4',
lg: 'size-6',
} as const;

interface LoadingSpinnerProps {
size?: keyof typeof sizeClasses;
className?: string;
}

function LoadingSpinner({ size = 'md', className }: LoadingSpinnerProps) {
return (
<Spinner
className={cn(sizeClasses[size], className)}
aria-label="Loading"
/>
);
}

export { LoadingSpinner };
67 changes: 67 additions & 0 deletions web-client/src/components/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
export { LoadingSpinner } from './LoadingSpinner';
export { EmptyState } from './EmptyState';
export { ErrorBoundary } from './ErrorBoundary';

// shadcn UI components
export { Button, buttonVariants } from './ui/button';
export { Input } from './ui/input';
export { Skeleton } from './ui/skeleton';
export { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
export {
Breadcrumb,
BreadcrumbEllipsis,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from './ui/breadcrumb';
export { Separator } from './ui/separator';
export {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from './ui/sheet';
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
} from './ui/sidebar';
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';

// shadcn primitives added for this change
export { Spinner } from './ui/spinner';
export { Toaster } from './ui/sonner';
export {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from './ui/empty';
2 changes: 2 additions & 0 deletions web-client/src/components/layout/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
Calendar,
MessageSquare,
Settings,
Palette,
} from 'lucide-react';

import {
Expand All @@ -26,6 +27,7 @@ const navItems = [
{ to: '/calendar', label: 'Calendar', icon: Calendar },
{ to: '/chat', label: 'Chat', icon: MessageSquare },
{ to: '/settings', label: 'Settings', icon: Settings },
{ to: '/demo', label: 'Demo', icon: Palette },
];

export function AppSidebar() {
Expand Down
104 changes: 104 additions & 0 deletions web-client/src/components/ui/empty.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "src/lib/utils"

function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
className
)}
{...props}
/>
)
}

function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
{...props}
/>
)
}

const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: "default",
},
}
)

function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}

function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn(
"font-heading text-sm font-medium tracking-tight",
className
)}
{...props}
/>
)
}

function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}

function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
className
)}
{...props}
/>
)
}

export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}
Loading