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,042 lines (873 loc) • 25.1 kB
Markdown
---
title: Card
dimension: things
category: components
tags: architecture, ontology
related_dimensions: connections, events, groups, people
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 components category.
Location: one/things/components/card.md
Purpose: Documents card component
Related dimensions: connections, events, groups, people
For AI agents: Read this to understand card.
---
# Card Component
**Generic card component that renders ANY thing type from the ontology**
---
## Overview
`Card` is a universal component that:
- ✅ Works with all 66 thing types
- ✅ Reads rendering instructions from ontology UI spec
- ✅ Eliminates need for type-specific components
- ✅ Consistent UI across platform (built on shadcn/ui)
- ✅ Customizable per organization
**Before (66 components):**
```tsx
<CourseCard course={course} />
<ProductCard product={product} />
<PostCard post={post} />
// ... 63 more type-specific components 😱
```
**After (1 component):**
```tsx
<Card thing={course} />
<Card thing={product} />
<Card thing={post} />
// Works for ALL types! 🎉
```
---
## Architecture
```
┌─────────────────────────────────────────┐
│ Card (Generic Component) │
│ │
│ 1. Receives: thing (any type) │
│ 2. Reads: ontology UI config │
│ 3. Renders: fields + actions │
│ 4. Handles: user interactions │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ Field (Generic Field Renderer) │
│ │
│ - Heading, Text, Price, Image, etc. │
│ - Reads field config from ontology │
│ - Renders appropriate component │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ Actions (Generic Action Handler) │
│ │
│ - Reads actions from ontology │
│ - Renders buttons/links │
│ - Handles clicks → services │
└─────────────────────────────────────────┘
```
---
## Implementation
### Core Component
```tsx
// frontend/src/components/generic/Card.tsx
import { type Thing } from "@oneie/core";
import { useThingConfig } from "@/ontology/hooks";
import {
Card as ShadCard,
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";
interface CardProps {
thing: Thing;
view?: "card" | "list" | "detail";
onClick?: (thing: Thing) => void;
showActions?: boolean;
showConnections?: boolean;
className?: string;
}
export function Card({
thing,
view = "card",
onClick,
showActions = true,
showConnections = true,
className,
}: CardProps) {
// Get UI config from ontology
const config = useThingConfig(thing.type);
// Get view configuration
const viewConfig = config.ui.views[view];
if (!viewConfig) {
console.warn(`View "${view}" not defined for type "${thing.type}"`);
return null;
}
// Get fields to display
const fields =
viewConfig.fields === "*"
? Object.keys(config.properties)
: viewConfig.fields;
// Handle click
const handleClick = () => {
if (onClick) {
onClick(thing);
} else if (config.ui.actions.primary) {
// Execute primary action by default
handleAction(config.ui.actions.primary.action, thing);
}
};
return (
<ShadCard
className={cn("cursor-pointer transition-all hover:shadow-lg", className)}
onClick={handleClick}
>
{/* Header: Typically thumbnail + title */}
<CardHeader>
{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>
{/* Body: Description, price, badges, etc. */}
<CardContent className="space-y-3">
{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 && (
<>
<Separator />
<CardFooter className="pt-3">
<Actions
thing={thing}
primary={config.ui.actions.primary}
secondary={config.ui.actions.secondary}
/>
</CardFooter>
</>
)}
</ShadCard>
);
}
```
---
### Field Component
```tsx
// frontend/src/components/generic/Field.tsx
import { type FieldUI } from "@/ontology/types";
import { type Thing } from "@oneie/core";
// shadcn/ui components
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Progress } from "@/components/ui/progress";
import { Separator } from "@/components/ui/separator";
// 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";
interface FieldProps {
name: string;
value: any;
config: FieldUI;
thing: Thing;
}
export function Field({ name, value, config, thing }: FieldProps) {
// Handle null/undefined values
if (value === null || value === undefined) {
return null;
}
// Apply formatting function if provided
const formattedValue = config.format ? config.format(value, thing) : value;
// Render appropriate component based on config.component
switch (config.component) {
case "Heading":
return (
<Heading
size={config.size}
weight={config.weight}
truncate={config.truncate}
>
{formattedValue}
</Heading>
);
case "Text":
return (
<Text
size={config.size}
color={config.color}
lines={config.lines}
icon={config.icon}
italic={config.italic}
weight={config.weight}
expandable={config.expandable}
>
{formattedValue}
</Text>
);
case "Price":
return (
<Price
value={formattedValue}
currency={config.currency}
format={config.format}
badge={config.badge}
size={config.size}
strikethrough={config.strikethrough}
free={config.free}
/>
);
case "Image":
return (
<Image
src={formattedValue}
alt={thing.name}
aspect={config.aspect}
lazy={config.lazy}
placeholder={config.placeholder}
fallback={config.fallback}
sizes={config.sizes}
/>
);
case "Badge":
const badgeLabel = config.labels?.[value] || config.label || value;
return <Badge variant="secondary">{badgeLabel}</Badge>;
case "TagList":
return (
<TagList
tags={formattedValue}
max={config.max}
color={config.color}
moreLabel={config.moreLabel}
/>
);
case "Date":
return (
<Date
value={formattedValue}
format={config.format} // 'relative' | 'full' | 'short'
icon={config.icon}
/>
);
case "Markdown":
return (
<Markdown
content={formattedValue}
className={config.className}
lines={config.lines}
expandable={config.expandable}
/>
);
case "Avatar":
return (
<Avatar>
<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
href={formattedValue}
icon={config.icon}
external={config.external}
size={config.size}
>
{config.label || formattedValue}
</Link>
);
case "ImageGallery":
return (
<ImageGallery
images={formattedValue}
aspect={config.aspect}
lazy={config.lazy}
zoom={config.zoom}
thumbnails={config.thumbnails}
/>
);
case "Video":
return (
<Video
src={formattedValue}
controls={config.controls}
autoplay={config.autoplay}
muted={config.muted}
/>
);
case "SocialLinks":
return (
<SocialLinks
links={formattedValue}
display={config.display}
size={config.size}
platforms={config.platforms}
/>
);
case "Checkbox":
return (
<Checkbox
checked={formattedValue}
label={config.label}
checkedIcon={config.checkedIcon}
uncheckedIcon={config.uncheckedIcon}
onChange={(checked) => {
// Update thing property
updateThing(thing._id, { [name]: checked });
}}
/>
);
// Add more component types as needed
default:
console.warn(`Unknown field component: ${config.component}`);
return <Text>{String(formattedValue)}</Text>;
}
}
```
---
### Actions Component
```tsx
// frontend/src/components/generic/Actions.tsx
import { type Thing } from "@oneie/core";
import { type ActionConfig } from "@/ontology/types";
import { Button } from "@/components/ui/button";
import { useAction } from "@/hooks/useAction";
interface ActionsProps {
thing: Thing;
primary?: ActionConfig;
secondary?: ActionConfig[];
}
export function Actions({ thing, primary, secondary }: ActionsProps) {
const { executeAction, loading } = useAction();
const handleAction = async (action: string) => {
await executeAction(action, thing);
};
return (
<div className="flex items-center gap-2 w-full">
{/* Primary action */}
{primary && (
<Button
variant={primary.variant === "danger" ? "destructive" : "default"}
onClick={() => handleAction(primary.action)}
disabled={loading}
className="flex-1"
>
{loading ? "Loading..." : primary.label}
</Button>
)}
{/* Secondary actions */}
{secondary?.map((action) => (
<Button
key={action.action}
variant={action.variant === "ghost" ? "outline" : "secondary"}
onClick={() => handleAction(action.action)}
disabled={loading}
>
{action.label}
</Button>
))}
</div>
);
}
```
---
### Connection Badges Component
```tsx
// frontend/src/components/generic/ConnectionBadges.tsx
import { type Thing } from "@oneie/core";
import { type ConnectionUI } from "@/ontology/types";
import { useConnections } from "@/hooks/useConnections";
import { Badge } from "@/components/ui/Badge";
import { Avatar } from "@/components/ui/Avatar";
interface ConnectionBadgesProps {
thing: Thing;
config: Record<string, ConnectionUI>;
}
export function ConnectionBadges({ thing, config }: ConnectionBadgesProps) {
const connections = useConnections(thing._id);
return (
<div className="flex flex-wrap gap-2 mt-3">
{Object.entries(config).map(([type, connectionConfig]) => {
// Get connections of this type
const items = connections.filter((c) => c.relationshipType === type);
if (items.length === 0) return null;
// Format label with count or name
const label = connectionConfig.label
.replace("{count}", items.length.toString())
.replace("{name}", items[0]?.name || "");
// Render based on display type
switch (connectionConfig.display) {
case "badge":
return (
<Badge
key={type}
icon={connectionConfig.icon}
link={
connectionConfig.link ? `/connections/${type}` : undefined
}
>
{label}
</Badge>
);
case "avatar":
return (
<div key={type} className="flex items-center gap-2">
<Avatar
src={items[0]?.properties?.avatar}
name={items[0]?.name}
size="sm"
/>
<span className="text-sm text-muted">{label}</span>
</div>
);
case "inline":
return (
<span key={type} className="text-sm text-muted">
{connectionConfig.icon && <Icon name={connectionConfig.icon} />}
{label}
</span>
);
case "list":
return (
<div key={type} className="space-y-1">
{items.slice(0, connectionConfig.max || 5).map((item) => (
<Badge key={item._id}>{item.name}</Badge>
))}
</div>
);
default:
return null;
}
})}
</div>
);
}
```
---
## Hook: useThingConfig
```tsx
// frontend/src/ontology/hooks/useThingConfig.ts
import { type ThingType } from "@oneie/core";
import { ontologyUIConfig } from "@/ontology/config";
export function useThingConfig(type: ThingType) {
const config = ontologyUIConfig[type];
if (!config) {
console.error(`No UI config found for thing type: ${type}`);
// Return minimal fallback config
return {
type,
properties: {},
ui: {
component: "Card",
layouts: { grid: { columns: 3, gap: "md" } },
fields: {},
views: { card: { fields: ["name"] } },
actions: {},
connections: {},
empty: {
icon: "box",
title: "No items",
description: "Get started by creating one",
},
},
};
}
return config;
}
```
---
## Hook: useAction
```tsx
// frontend/src/hooks/useAction.ts
import { useState } from "react";
import { type Thing } from "@oneie/core";
import { Effect } from "effect";
import { ThingService } from "@/services/ThingService";
import { useEffectRunner } from "@/hooks/useEffectRunner";
export function useAction() {
const [loading, setLoading] = useState(false);
const { run } = useEffectRunner();
const executeAction = async (action: string, thing: Thing) => {
setLoading(true);
const program = Effect.gen(function* () {
const service = yield* ThingService;
// Handle different action types
switch (action) {
case "enroll":
return yield* service.enroll(thing._id);
case "purchase":
return yield* service.purchase(thing._id);
case "preview":
// Navigate to preview page
window.location.href = `/preview/${thing.type}/${thing._id}`;
break;
case "share":
// Open share dialog
await navigator.share({
title: thing.name,
url: window.location.href,
});
break;
case "bookmark":
return yield* service.bookmark(thing._id);
case "edit":
window.location.href = `/edit/${thing.type}/${thing._id}`;
break;
case "delete":
if (confirm("Are you sure?")) {
return yield* service.delete(thing._id);
}
break;
case "addToCart":
return yield* service.addToCart(thing._id);
case "wishlist":
return yield* service.addToWishlist(thing._id);
// Add more actions as needed
default:
console.warn(`Unknown action: ${action}`);
}
});
await run(program, {
onSuccess: () => {
setLoading(false);
},
onError: (error) => {
console.error("Action failed:", error);
setLoading(false);
},
});
};
return { executeAction, loading };
}
```
---
## Usage Examples
### Basic Usage
```tsx
// frontend/src/pages/courses/index.astro
---
import { getCourses } from '@/services/ThingService'
import Card from '@/components/generic/Card'
const courses = await getCourses()
---
<div class="grid grid-cols-3 gap-6">
{courses.map(course => (
<Card
thing={course}
client:load
/>
))}
</div>
```
### Custom View
```tsx
// Use list view instead of card view
<Card thing={course} view="list" client:load />
```
### Custom Click Handler
```tsx
// Handle click manually
<Card
thing={course}
onClick={(thing) => {
console.log("Clicked:", thing);
router.push(`/courses/${thing._id}`);
}}
client:load
/>
```
### Hide Actions
```tsx
// Show card without actions
<Card thing={course} showActions={false} client:load />
```
### Hide Connections
```tsx
// Show card without connection badges
<Card thing={course} showConnections={false} client:load />
```
---
## Variants
### ThingList
```tsx
// frontend/src/components/generic/ThingList.tsx
export function ThingList({ things, view = "list" }) {
const config = useThingConfig(things[0]?.type);
return (
<div className="space-y-4">
{things.map((thing) => (
<Card key={thing._id} thing={thing} view={view} />
))}
</div>
);
}
```
### ThingGrid
```tsx
// frontend/src/components/generic/ThingGrid.tsx
export function ThingGrid({ things }) {
const config = useThingConfig(things[0]?.type);
const gridLayout = config.ui.layouts.grid;
return (
<div
className="grid gap-6"
style={{
gridTemplateColumns: `repeat(${gridLayout.columns}, 1fr)`,
}}
>
{things.map((thing) => (
<Card key={thing._id} thing={thing} view="card" />
))}
</div>
);
}
```
### ThingDetail
```tsx
// frontend/src/components/generic/ThingDetail.tsx
export function ThingDetail({ thing }) {
const config = useThingConfig(thing.type);
return (
<div className="max-w-prose mx-auto">
<Card
thing={thing}
view="detail"
showActions={true}
showConnections={true}
/>
</div>
);
}
```
### ThingTable
```tsx
// frontend/src/components/generic/ThingTable.tsx
export function ThingTable({ things }) {
const config = useThingConfig(things[0]?.type);
const tableView = config.ui.views.table;
return (
<table className="w-full">
<thead>
<tr>
{tableView.fields.map((fieldName) => (
<th key={fieldName}>
{config.ui.fields[fieldName]?.label || fieldName}
</th>
))}
</tr>
</thead>
<tbody>
{things.map((thing) => (
<tr key={thing._id}>
{tableView.fields.map((fieldName) => (
<td key={fieldName}>
<Field
name={fieldName}
value={thing.properties[fieldName]}
config={config.ui.fields[fieldName]}
thing={thing}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
```
---
## Empty State
```tsx
// frontend/src/components/generic/EmptyState.tsx
import { type EmptyStateConfig } from "@/ontology/types";
import { Button } from "@/components/ui/Button";
import { Icon } from "@/components/ui/Icon";
interface EmptyStateProps {
config: EmptyStateConfig;
onAction?: () => void;
}
export function EmptyState({ config, onAction }: EmptyStateProps) {
return (
<div className="text-center py-12">
<Icon name={config.icon} size="3xl" className="text-muted mb-4" />
<h3 className="text-xl font-semibold mb-2">{config.title}</h3>
<p className="text-muted mb-6">{config.description}</p>
{config.action && (
<Button
icon={config.action.icon}
variant={config.action.variant}
onClick={onAction}
>
{config.action.label}
</Button>
)}
</div>
);
}
```
**Usage:**
```tsx
// Show empty state when no things
{
things.length === 0 ? (
<EmptyState
config={config.ui.empty}
onAction={() => router.push("/courses/new")}
/>
) : (
<ThingGrid things={things} />
);
}
```
---
## Testing
### Unit Tests
```tsx
// frontend/src/components/generic/__tests__/Card.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { Card } from "../Card";
describe("Card", () => {
it("renders course card", () => {
const course = {
_id: "123",
type: "course",
name: "Test Course",
properties: {
title: "Test Course",
description: "A test course",
price: 99,
level: "beginner",
},
};
render(<Card thing={course} />);
expect(screen.getByText("Test Course")).toBeInTheDocument();
expect(screen.getByText("A test course")).toBeInTheDocument();
expect(screen.getByText("$99")).toBeInTheDocument();
expect(screen.getByText("beginner")).toBeInTheDocument();
});
it("renders product card", () => {
const product = {
_id: "456",
type: "product",
name: "Test Product",
properties: {
name: "Test Product",
price: 49,
inventory: 10,
},
};
render(<Card thing={product} />);
expect(screen.getByText("Test Product")).toBeInTheDocument();
expect(screen.getByText("$49")).toBeInTheDocument();
expect(screen.getByText("10 in stock")).toBeInTheDocument();
});
it("handles click", () => {
const onClick = vi.fn();
const course = {
/* ... */
};
render(<Card thing={course} onClick={onClick} />);
fireEvent.click(screen.getByRole("article"));
expect(onClick).toHaveBeenCalledWith(course);
});
});
```
---
## Performance Optimization
### Memoization
```tsx
// Memoize config lookup
import { useMemo } from "react";
export function Card({ thing, ...props }) {
const config = useMemo(() => getThingConfig(thing.type), [thing.type]);
// ... rest of component
}
```
### Lazy Loading
```tsx
// Lazy load images
<Image src={thing.properties.thumbnail} lazy={true} loading="lazy" />
```
### Virtual Scrolling
```tsx
// Use virtual scrolling for long lists
import { VirtualList } from "@/components/ui/VirtualList";
<VirtualList
items={things}
renderItem={(thing) => <Card thing={thing} />}
itemHeight={200}
/>;
```
---
## Next Steps
### Week 1
- [ ] Implement Card core component
- [ ] Implement Field component with 10 field types
- [ ] Implement Actions component
- [ ] Test with 3 thing types
### Week 2
- [ ] Add all field component types (20+)
- [ ] Implement ConnectionBadges
- [ ] Implement EmptyState
- [ ] Add responsive layouts
### Week 3
- [ ] Build ThingList variant
- [ ] Build ThingGrid variant
- [ ] Build ThingDetail variant
- [ ] Build ThingTable variant
### Week 4
- [ ] Add customization API
- [ ] Add theme support
- [ ] Add accessibility features
- [ ] Performance optimization
---
**With Card, you build ONE component and support 66 thing types automatically.**