oneie
Version:
Build apps, websites, and AI agents in English. Zero-interaction setup for AI agents (Claude Code, Cursor, Windsurf). Download to your computer, run in the cloud, deploy to the edge. Open source and free forever.
1,303 lines (1,111 loc) • 35 kB
Markdown
---
title: Ontology Ui Shadcn
dimension: things
category: products
tags: 6-dimensions, ai, ontology, things
related_dimensions: events
scope: global
created: 2025-11-03
updated: 2025-11-03
version: 1.0.0
ai_context: |
This document is part of the things dimension in the products category.
Location: one/things/products/ontology-ui-shadcn.md
Purpose: Documents ontology ui with shadcn/ui integration
Related dimensions: events
For AI agents: Read this to understand ontology ui shadcn.
---
# Ontology UI with shadcn/ui Integration
> **⚠️ DEPRECATED:** This document has been superseded by a cleaner approach. Please use:
>
> - `one/things/features/ontology-ui.md` - Clean UI metadata specification
> - `one/things/components/card.md` - Implementation using shadcn/ui naturally
> - `one/things/plans/ontology-ui-approach.md` - Philosophy explanation
>
> **Why the change?** The new approach keeps ontology-ui.md as a clean specification and uses shadcn/ui as an implementation detail in card.md, resulting in 50% less documentation with no redundancy.
**Ontology-driven UI using shadcn/ui components for consistent, accessible design**
---
## Overview
The ONE ontology UI specification is fully integrated with [shadcn/ui](https://ui.shadcn.com/) - a collection of beautifully designed, accessible components built with Radix UI and Tailwind CSS.
**Why shadcn/ui:**
- ✅ **Accessible** - Built on Radix UI primitives (WAI-ARIA compliant)
- ✅ **Customizable** - Full control via Tailwind CSS
- ✅ **Copy-paste** - Not an npm dependency, you own the code
- ✅ **TypeScript** - Fully typed components
- ✅ **Consistent** - Professional design system out of the box
- ✅ **Well-documented** - Comprehensive docs and examples
**Integration Benefits:**
- ✅ Every field component maps to shadcn/ui component
- ✅ Consistent design across all 66 thing types
- ✅ Accessible by default (screen readers, keyboard nav)
- ✅ Theme support (light/dark mode)
- ✅ Responsive and mobile-friendly
---
## Setup
### 1. Install shadcn/ui
```bash
cd frontend
# Initialize shadcn/ui
npx shadcn-ui@latest init
# Choose:
# Style: Default
# Base color: Slate
# CSS variables: Yes
```
### 2. Install Required Components
```bash
# Core components
npx shadcn-ui@latest add card
npx shadcn-ui@latest add button
npx shadcn-ui@latest add badge
npx shadcn-ui@latest add avatar
npx shadcn-ui@latest add separator
# Form components
npx shadcn-ui@latest add input
npx shadcn-ui@latest add textarea
npx shadcn-ui@latest add checkbox
npx shadcn-ui@latest add switch
npx shadcn-ui@latest add select
# Display components
npx shadcn-ui@latest add table
npx shadcn-ui@latest add dialog
npx shadcn-ui@latest add tooltip
npx shadcn-ui@latest add dropdown-menu
npx shadcn-ui@latest add popover
# Data components
npx shadcn-ui@latest add skeleton
npx shadcn-ui@latest add progress
npx shadcn-ui@latest add calendar
```
### 3. Project Structure
```
frontend/
├── src/
│ ├── components/
│ │ ├── ui/ # shadcn/ui components (auto-generated)
│ │ │ ├── card.tsx
│ │ │ ├── button.tsx
│ │ │ ├── badge.tsx
│ │ │ └── ...
│ │ ├── generic/ # Ontology-driven components
│ │ │ ├── ThingCard.tsx
│ │ │ ├── Field.tsx
│ │ │ └── Actions.tsx
│ │ └── fields/ # Field-specific components
│ │ ├── Heading.tsx
│ │ ├── Text.tsx
│ │ ├── Price.tsx
│ │ └── ...
│ ├── ontology/
│ │ ├── config/ # UI configs for all types
│ │ │ ├── course.ts
│ │ │ ├── product.ts
│ │ │ └── ...
│ │ └── hooks/
│ │ └── useThingConfig.ts
│ └── lib/
│ └── utils.ts # shadcn/ui utilities
```
---
## Field Component Mapping
Every ontology field component maps to a shadcn/ui component:
| Field Component | shadcn/ui Component | Usage |
| --------------- | --------------------------- | --------------------------- |
| `Heading` | Custom (Typography) | Headings with size variants |
| `Text` | Custom (Typography) | Body text with truncation |
| `Price` | `Badge` + Custom formatting | Formatted prices |
| `Image` | Custom `<img>` + `Skeleton` | Lazy-loaded images |
| `Badge` | `Badge` | Status badges |
| `TagList` | Multiple `Badge` | Tag collections |
| `Date` | Custom formatting | Formatted dates |
| `Avatar` | `Avatar` | User avatars |
| `Button` | `Button` | Action buttons |
| `Link` | Custom `<a>` | Hyperlinks |
| `Checkbox` | `Checkbox` | Checkboxes |
| `Switch` | `Switch` | Toggle switches |
| `Select` | `Select` | Dropdowns |
| `Input` | `Input` | Text inputs |
| `Textarea` | `Textarea` | Multi-line text |
| `Rating` | Custom stars | Star ratings |
| `Progress` | `Progress` | Progress bars |
| `Table` | `Table` | Data tables |
| `Dialog` | `Dialog` | Modal dialogs |
| `Tooltip` | `Tooltip` | Hover tooltips |
---
## ThingCard with shadcn/ui
### Implementation
```tsx
// frontend/src/components/generic/ThingCard.tsx
import { type Thing } from "@one-platform/core";
import { useThingConfig } from "@/ontology/hooks/useThingConfig";
import {
Card,
CardContent,
CardFooter,
CardHeader,
} from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Field } from "./Field";
import { Actions } from "./Actions";
import { ConnectionBadges } from "./ConnectionBadges";
import { cn } from "@/lib/utils";
interface ThingCardProps {
thing: Thing;
view?: "card" | "list" | "detail";
onClick?: (thing: Thing) => void;
showActions?: boolean;
showConnections?: boolean;
className?: string;
}
export function ThingCard({
thing,
view = "card",
onClick,
showActions = true,
showConnections = true,
className,
}: ThingCardProps) {
const config = useThingConfig(thing.type);
const viewConfig = config.ui.views[view];
if (!viewConfig) return null;
const fields =
viewConfig.fields === "*"
? Object.keys(config.properties)
: viewConfig.fields;
return (
<Card
className={cn("cursor-pointer transition-all hover:shadow-lg", className)}
onClick={onClick}
>
<CardHeader className="space-y-0 pb-3">
{/* Render header fields (typically thumbnail + title) */}
{fields.slice(0, 2).map((fieldName) => {
const fieldConfig = config.ui.fields[fieldName];
if (fieldConfig?.hidden) return null;
return (
<Field
key={fieldName}
name={fieldName}
value={thing.properties[fieldName]}
config={fieldConfig}
thing={thing}
/>
);
})}
</CardHeader>
<CardContent className="space-y-2">
{/* Render body fields */}
{fields.slice(2).map((fieldName) => {
const fieldConfig = config.ui.fields[fieldName];
if (fieldConfig?.hidden) return null;
if (fieldConfig?.showOnlyIf && !thing.properties[fieldName])
return null;
return (
<Field
key={fieldName}
name={fieldName}
value={thing.properties[fieldName]}
config={fieldConfig}
thing={thing}
/>
);
})}
</CardContent>
{/* Connection badges */}
{showConnections && config.ui.connections && (
<>
<Separator />
<CardContent className="pt-3">
<ConnectionBadges thing={thing} config={config.ui.connections} />
</CardContent>
</>
)}
{/* Actions */}
{showActions &&
(config.ui.actions.primary || config.ui.actions.secondary) && (
<>
<Separator />
<CardFooter className="pt-3">
<Actions
thing={thing}
primary={config.ui.actions.primary}
secondary={config.ui.actions.secondary}
/>
</CardFooter>
</>
)}
</Card>
);
}
```
---
## Field Component with shadcn/ui
```tsx
// frontend/src/components/generic/Field.tsx
import { type FieldUI } from "@/ontology/types";
import { type Thing } from "@one-platform/core";
// shadcn/ui components
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch";
import { Progress } from "@/components/ui/progress";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
// Custom field components
import { Heading } from "@/components/fields/Heading";
import { Text } from "@/components/fields/Text";
import { Price } from "@/components/fields/Price";
import { Image } from "@/components/fields/Image";
import { TagList } from "@/components/fields/TagList";
import { DateField } from "@/components/fields/DateField";
import { Link } from "@/components/fields/Link";
interface FieldProps {
name: string;
value: any;
config: FieldUI;
thing: Thing;
}
export function Field({ name, value, config, thing }: FieldProps) {
if (value === null || value === undefined) return null;
const formattedValue = config.format ? config.format(value, thing) : value;
switch (config.component) {
case "Heading":
return <Heading {...config}>{formattedValue}</Heading>;
case "Text":
return <Text {...config}>{formattedValue}</Text>;
case "Price":
return <Price {...config} value={formattedValue} />;
case "Image":
return <Image {...config} src={formattedValue} alt={thing.name} />;
case "Badge":
const badgeColor = config.colors?.[value] || config.color;
const badgeLabel = config.labels?.[value] || config.label || value;
return (
<Badge
variant={badgeColor as any}
className="inline-flex items-center gap-1"
>
{config.icon && <span className="h-3 w-3">{/* icon */}</span>}
{badgeLabel}
</Badge>
);
case "TagList":
return <TagList {...config} tags={formattedValue} />;
case "Date":
return <DateField {...config} value={formattedValue} />;
case "Avatar":
return (
<Avatar className={config.size === "sm" ? "h-8 w-8" : "h-10 w-10"}>
<AvatarImage src={formattedValue} alt={thing.name} />
<AvatarFallback>
{config.fallback === "initials"
? thing.name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
: config.fallback}
</AvatarFallback>
</Avatar>
);
case "Link":
return <Link {...config} href={formattedValue} />;
case "Checkbox":
return (
<div className="flex items-center space-x-2">
<Checkbox
checked={formattedValue}
onCheckedChange={(checked) => {
// Update thing
}}
/>
{config.label && (
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{config.label}
</label>
)}
</div>
);
case "Switch":
return (
<div className="flex items-center space-x-2">
<Switch
checked={formattedValue}
onCheckedChange={(checked) => {
// Update thing
}}
/>
{config.label && (
<label className="text-sm font-medium">{config.label}</label>
)}
</div>
);
case "Progress":
return (
<div className="space-y-2">
{config.label && (
<div className="flex justify-between text-sm">
<span>{config.label}</span>
<span>{formattedValue}%</span>
</div>
)}
<Progress value={formattedValue} />
</div>
);
case "Separator":
return <Separator />;
case "Skeleton":
return <Skeleton className={config.className} />;
default:
return <Text size="sm">{String(formattedValue)}</Text>;
}
}
```
---
## Actions Component with shadcn/ui
```tsx
// frontend/src/components/generic/Actions.tsx
import { type Thing } from "@one-platform/core";
import { type ActionConfig } from "@/ontology/types";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useAction } from "@/hooks/useAction";
import { MoreVertical } from "lucide-react";
import { useState } from "react";
interface ActionsProps {
thing: Thing;
primary?: ActionConfig;
secondary?: ActionConfig[];
context?: ActionConfig[];
}
export function Actions({ thing, primary, secondary, context }: ActionsProps) {
const { executeAction, loading } = useAction();
const [confirmAction, setConfirmAction] = useState<ActionConfig | null>(null);
const handleAction = async (action: ActionConfig) => {
// Show confirmation dialog if required
if (action.confirm) {
setConfirmAction(action);
return;
}
await executeAction(action.action, thing);
};
return (
<>
<div className="flex items-center gap-2 w-full">
{/* Primary action */}
{primary && (
<Button
variant={primary.variant === "danger" ? "destructive" : "default"}
className="flex-1"
disabled={loading}
onClick={() => handleAction(primary)}
>
{loading ? "Loading..." : primary.label}
</Button>
)}
{/* Secondary actions */}
{secondary && secondary.length > 0 && (
<div className="flex gap-2">
{secondary.map((action) => (
<Button
key={action.action}
variant={action.variant === "ghost" ? "outline" : "default"}
size="icon"
disabled={loading}
onClick={() => handleAction(action)}
>
{/* Icon from lucide-react */}
<span className="h-4 w-4" />
</Button>
))}
</div>
)}
{/* Context menu (more actions) */}
{context && context.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{context.map((action, index) => (
<div key={action.action}>
{index > 0 && action.variant === "danger" && (
<DropdownMenuSeparator />
)}
<DropdownMenuItem
onClick={() => handleAction(action)}
className={
action.variant === "danger" ? "text-destructive" : ""
}
>
{action.label}
</DropdownMenuItem>
</div>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{/* Confirmation dialog */}
{confirmAction && (
<AlertDialog
open={!!confirmAction}
onOpenChange={() => setConfirmAction(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
{confirmAction.confirm}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await executeAction(confirmAction.action, thing);
setConfirmAction(null);
}}
>
Continue
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</>
);
}
```
---
## Connection Badges with shadcn/ui
```tsx
// frontend/src/components/generic/ConnectionBadges.tsx
import { type Thing } from "@one-platform/core";
import { type ConnectionUI } from "@/ontology/types";
import { useConnections } from "@/hooks/useConnections";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Separator } from "@/components/ui/separator";
interface ConnectionBadgesProps {
thing: Thing;
config: Record<string, ConnectionUI>;
}
export function ConnectionBadges({ thing, config }: ConnectionBadgesProps) {
const connections = useConnections(thing._id);
const badges = Object.entries(config).map(([type, connectionConfig]) => {
const items = connections.filter((c) => c.relationshipType === type);
if (items.length === 0) return null;
const label = connectionConfig.label
.replace("{count}", items.length.toString())
.replace("{name}", items[0]?.name || "");
switch (connectionConfig.display) {
case "badge":
return (
<Badge key={type} variant="secondary" className="text-xs">
{label}
</Badge>
);
case "avatar":
return (
<div key={type} className="flex items-center gap-2">
<Avatar className="h-6 w-6">
<AvatarImage src={items[0]?.properties?.avatar} />
<AvatarFallback>
{items[0]?.name?.[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="text-sm text-muted-foreground">{label}</span>
</div>
);
case "inline":
return (
<span key={type} className="text-sm text-muted-foreground">
{label}
</span>
);
case "list":
return (
<div key={type} className="flex flex-wrap gap-1">
{items.slice(0, connectionConfig.max || 5).map((item) => (
<Badge key={item._id} variant="outline" className="text-xs">
{item.name}
</Badge>
))}
</div>
);
default:
return null;
}
});
const validBadges = badges.filter(Boolean);
if (validBadges.length === 0) return null;
return <div className="flex flex-wrap items-center gap-2">{validBadges}</div>;
}
```
---
## Field Components with shadcn/ui
### Price Component
```tsx
// frontend/src/components/fields/Price.tsx
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
interface PriceProps {
value: number;
currency?: string;
format?: "full" | "compact";
badge?: boolean;
size?: "sm" | "md" | "lg";
strikethrough?: boolean;
free?: { label: string; badge: boolean };
className?: string;
}
export function Price({
value,
currency = "USD",
format = "compact",
badge = false,
size = "md",
strikethrough = false,
free,
className,
}: PriceProps) {
// Show "Free" if value is 0 and free config provided
if (value === 0 && free) {
return free.badge ? (
<Badge variant="secondary">{free.label}</Badge>
) : (
<span className="text-sm font-medium text-muted-foreground">
{free.label}
</span>
);
}
const formatted = new Intl.NumberFormat("en-US", {
style: "currency",
currency,
minimumFractionDigits: format === "compact" ? 0 : 2,
maximumFractionDigits: format === "compact" ? 0 : 2,
}).format(value);
const sizeClasses = {
sm: "text-sm",
md: "text-base",
lg: "text-lg",
};
const content = (
<span
className={cn(
"font-semibold",
sizeClasses[size],
strikethrough && "line-through text-muted-foreground",
className,
)}
>
{formatted}
</span>
);
return badge ? (
<Badge variant="default" className="font-semibold">
{formatted}
</Badge>
) : (
content
);
}
```
### Image Component
```tsx
// frontend/src/components/fields/Image.tsx
import { useState } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
interface ImageProps {
src: string;
alt: string;
aspect?: "square" | "video" | "wide" | "portrait";
lazy?: boolean;
placeholder?: string;
fallback?: string;
sizes?: string;
className?: string;
}
export function Image({
src,
alt,
aspect = "video",
lazy = true,
placeholder,
fallback,
sizes,
className,
}: ImageProps) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const aspectClasses = {
square: "aspect-square",
video: "aspect-video",
wide: "aspect-[21/9]",
portrait: "aspect-[3/4]",
};
return (
<div
className={cn(
"relative overflow-hidden rounded-md",
aspectClasses[aspect],
)}
>
{loading && <Skeleton className="absolute inset-0" />}
<img
src={error && fallback ? fallback : src}
alt={alt}
loading={lazy ? "lazy" : "eager"}
sizes={sizes}
className={cn(
"h-full w-full object-cover transition-opacity",
loading ? "opacity-0" : "opacity-100",
className,
)}
onLoad={() => setLoading(false)}
onError={() => {
setError(true);
setLoading(false);
}}
/>
</div>
);
}
```
### TagList Component
```tsx
// frontend/src/components/fields/TagList.tsx
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface TagListProps {
tags: string[];
max?: number;
color?: string;
moreLabel?: string;
}
export function TagList({
tags,
max = 3,
color = "default",
moreLabel = "+{count} more",
}: TagListProps) {
if (!tags || tags.length === 0) return null;
const visibleTags = tags.slice(0, max);
const hiddenTags = tags.slice(max);
return (
<div className="flex flex-wrap gap-1">
{visibleTags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
{hiddenTags.length > 0 && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="text-xs cursor-help">
{moreLabel.replace("{count}", hiddenTags.length.toString())}
</Badge>
</TooltipTrigger>
<TooltipContent>
<div className="flex flex-wrap gap-1 max-w-xs">
{hiddenTags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
}
```
---
## Empty State with shadcn/ui
```tsx
// frontend/src/components/generic/EmptyState.tsx
import { type EmptyStateConfig } from "@/ontology/types";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
interface EmptyStateProps {
config: EmptyStateConfig;
onAction?: () => void;
}
export function EmptyState({ config, onAction }: EmptyStateProps) {
return (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-12">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted mb-4">
{/* Icon from lucide-react */}
<span className="h-10 w-10 text-muted-foreground" />
</div>
<h3 className="text-xl font-semibold mb-2">{config.title}</h3>
<p className="text-sm text-muted-foreground text-center mb-6 max-w-sm">
{config.description}
</p>
{config.action && (
<Button onClick={onAction}>{config.action.label}</Button>
)}
</CardContent>
</Card>
);
}
```
---
## ThingTable with shadcn/ui
```tsx
// frontend/src/components/generic/ThingTable.tsx
import { type Thing } from "@one-platform/core";
import { useThingConfig } from "@/ontology/hooks/useThingConfig";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Field } from "./Field";
import { ArrowUpDown } from "lucide-react";
interface ThingTableProps {
things: Thing[];
onRowClick?: (thing: Thing) => void;
}
export function ThingTable({ things, onRowClick }: ThingTableProps) {
if (things.length === 0) return null;
const config = useThingConfig(things[0].type);
const tableView = config.ui.views.table;
if (!tableView) return null;
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
{tableView.fields.map((fieldName) => {
const fieldConfig = config.ui.fields[fieldName];
return (
<TableHead key={fieldName}>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => {
// Handle sort
}}
>
{fieldConfig?.label || fieldName}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
{things.map((thing) => (
<TableRow
key={thing._id}
className="cursor-pointer"
onClick={() => onRowClick?.(thing)}
>
{tableView.fields.map((fieldName) => (
<TableCell key={fieldName}>
<Field
name={fieldName}
value={thing.properties[fieldName]}
config={config.ui.fields[fieldName]}
thing={thing}
/>
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
```
---
## Theme Support
### Dark Mode
shadcn/ui includes built-in dark mode support:
```tsx
// frontend/src/components/ThemeProvider.tsx
import { createContext, useContext, useEffect, useState } from "react";
type Theme = "dark" | "light" | "system";
const ThemeProviderContext = createContext<{
theme: Theme;
setTheme: (theme: Theme) => void;
}>({
theme: "system",
setTheme: () => null,
});
export function ThemeProvider({
children,
defaultTheme = "system",
}: {
children: React.ReactNode;
defaultTheme?: Theme;
}) {
const [theme, setTheme] = useState<Theme>(defaultTheme);
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove("light", "dark");
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}, [theme]);
return (
<ThemeProviderContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => useContext(ThemeProviderContext);
```
### Theme Toggle
```tsx
// frontend/src/components/ThemeToggle.tsx
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTheme } from "./ThemeProvider";
export function ThemeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
```
---
## Responsive Design
shadcn/ui components are responsive by default. Use Tailwind's responsive utilities:
```tsx
<Card className="w-full sm:w-1/2 lg:w-1/3">
{/* Responsive card width */}
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Responsive grid */}
</div>
```
---
## Accessibility
shadcn/ui components are built on Radix UI, which provides:
- ✅ **Keyboard navigation** - Full keyboard support
- ✅ **Screen reader support** - ARIA labels and descriptions
- ✅ **Focus management** - Proper focus handling
- ✅ **Color contrast** - WCAG AA compliant
- ✅ **RTL support** - Right-to-left languages
### Example: Accessible Form
```tsx
<form className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Course Title</Label>
<Input
id="title"
placeholder="Enter title"
aria-describedby="title-description"
/>
<p id="title-description" className="text-sm text-muted-foreground">
This will be the main title displayed to students
</p>
</div>
</form>
```
---
## Usage Examples
### Course Card
```tsx
import { ThingCard } from "@/components/generic/ThingCard";
export function CoursesPage() {
const courses = await getCourses();
return (
<div className="container mx-auto py-8">
<h1 className="text-3xl font-bold mb-6">Courses</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{courses.map((course) => (
<ThingCard
key={course._id}
thing={course}
onClick={(thing) => {
router.push(`/courses/${thing._id}`);
}}
/>
))}
</div>
</div>
);
}
```
### Product Grid with Filters
```tsx
import { ThingCard } from "@/components/generic/ThingCard";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
export function ProductsPage() {
const [products, setProducts] = useState([]);
const [category, setCategory] = useState("all");
return (
<div className="container mx-auto py-8">
<div className="flex items-center gap-4 mb-6">
<Input placeholder="Search products..." className="max-w-sm" />
<Select value={category} onValueChange={setCategory}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
<SelectItem value="books">Books</SelectItem>
<SelectItem value="courses">Courses</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{products.map((product) => (
<ThingCard key={product._id} thing={product} />
))}
</div>
</div>
);
}
```
---
## Customization
### Custom Theme Colors
```css
/* frontend/src/styles/globals.css */
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
/* ... other colors */
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
/* ... other colors */
}
}
```
### Organization-Specific Overrides
```tsx
// Custom badge colors per organization
const config = {
...defaultCourseConfig,
ui: {
...defaultCourseConfig.ui,
fields: {
...defaultCourseConfig.ui.fields,
level: {
...defaultCourseConfig.ui.fields.level,
colors: {
beginner: "blue", // Override to blue
intermediate: "purple",
advanced: "orange",
},
},
},
},
};
```
---
## Next Steps
### Week 1: Setup
- [ ] Install shadcn/ui
- [ ] Add all required components
- [ ] Setup theme provider
- [ ] Configure Tailwind
### Week 2: Core Components
- [ ] Build ThingCard with shadcn/ui
- [ ] Build Field component
- [ ] Build Actions component
- [ ] Test with 3 thing types
### Week 3: Field Components
- [ ] Build all field components
- [ ] Add Price, Image, TagList
- [ ] Add DateField, Avatar, Link
- [ ] Test responsive design
### Week 4: Advanced
- [ ] Build ThingTable
- [ ] Add dark mode support
- [ ] Add accessibility features
- [ ] Performance optimization
---
## Resources
- **shadcn/ui Docs**: https://ui.shadcn.com
- **Radix UI Docs**: https://www.radix-ui.com
- **Tailwind CSS**: https://tailwindcss.com
- **Lucide Icons**: https://lucide.dev
---
**With shadcn/ui, every thing type gets beautiful, accessible UI automatically.**