feat(companion): orchestrator page skeleton
This commit is contained in:
@@ -1,22 +1,426 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
import { Container } from "@/components/Container";
|
import { SafeAreaScrollView } from "@/components/safe-area-scroll-view";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const fixture = {
|
||||||
|
location: {
|
||||||
|
authorization: "When In Use",
|
||||||
|
lastLocation: {
|
||||||
|
latitude: 37.33182,
|
||||||
|
longitude: -122.03118,
|
||||||
|
accuracyMeters: 12.4,
|
||||||
|
speedMps: 0.4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
recompute: {
|
||||||
|
lastReason: "manual",
|
||||||
|
lastTime: new Date("2024-05-21T09:32:00Z"),
|
||||||
|
elapsedMs: 482,
|
||||||
|
fetchFailed: false,
|
||||||
|
error: null as string | null,
|
||||||
|
},
|
||||||
|
winner: {
|
||||||
|
title: "Glass Now online",
|
||||||
|
subtitle: "Connected to iPhone",
|
||||||
|
type: "INFO",
|
||||||
|
bucket: "RIGHT_NOW",
|
||||||
|
priority: 0.8,
|
||||||
|
ttlSec: 86400,
|
||||||
|
poiType: "CAFE",
|
||||||
|
startsAt: 1767717000,
|
||||||
|
id: "demo:welcome",
|
||||||
|
},
|
||||||
|
feed: [
|
||||||
|
{
|
||||||
|
id: "demo:welcome",
|
||||||
|
title: "Glass Now online",
|
||||||
|
subtitle: "Connected to iPhone",
|
||||||
|
type: "INFO",
|
||||||
|
bucket: "RIGHT_NOW",
|
||||||
|
priority: 0.8,
|
||||||
|
ttlSec: 86400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cal:demo:1767717000",
|
||||||
|
title: "Team Sync",
|
||||||
|
subtitle: "",
|
||||||
|
type: "CALENDAR_EVENT",
|
||||||
|
bucket: "FYI",
|
||||||
|
priority: 0.7,
|
||||||
|
ttlSec: 5400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "demo:next",
|
||||||
|
title: "Next: Calendar",
|
||||||
|
subtitle: "Then Weather + POI",
|
||||||
|
type: "INFO",
|
||||||
|
bucket: "FYI",
|
||||||
|
priority: 0.4,
|
||||||
|
ttlSec: 86400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "music:now:demo",
|
||||||
|
title: "Midnight City",
|
||||||
|
subtitle: "M83 - Hurry Up, We're Dreaming",
|
||||||
|
type: "NOW_PLAYING",
|
||||||
|
bucket: "FYI",
|
||||||
|
priority: 0.35,
|
||||||
|
ttlSec: 30,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nowPlaying: {
|
||||||
|
auth: "Authorized",
|
||||||
|
title: "Midnight City",
|
||||||
|
artist: "M83",
|
||||||
|
album: "Hurry Up, We're Dreaming",
|
||||||
|
playbackStatus: "playing",
|
||||||
|
},
|
||||||
|
weatherDiagnostics: {
|
||||||
|
provider: "WeatherKit",
|
||||||
|
last_fetch: "2024-05-21 09:30:14",
|
||||||
|
alerts: "0",
|
||||||
|
conditions: "mostly_clear_day",
|
||||||
|
},
|
||||||
|
calendarDiagnostics: {
|
||||||
|
events_upcoming: "3",
|
||||||
|
next_event: "Team Sync - 10:30",
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
note: "Test actions are not available in the React Native client.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (date: Date | null) => {
|
||||||
|
if (!date) {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
return date.toLocaleTimeString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatSpeed = (speedMps: number) => {
|
||||||
|
if (speedMps < 0) {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
return `${speedMps.toFixed(1)} m/s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLatLon = (value: number) => value.toFixed(5);
|
||||||
|
|
||||||
|
const formatPriority = (value: number) => value.toFixed(2);
|
||||||
|
|
||||||
export default function OrchestratorScreen() {
|
export default function OrchestratorScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className={styles.container}>
|
<View className="flex flex-1 bg-white">
|
||||||
<Container>
|
<SafeAreaScrollView>
|
||||||
<Text variant="h3">Orchestrator</Text>
|
<View className="gap-4" style={{ paddingBottom: insets.bottom + 24 }}>
|
||||||
<Text className={styles.subtitle}>
|
<View className="gap-1 px-8">
|
||||||
Port of SwiftUI OrchestratorView will live here.
|
<Text variant="h3">Orchestrator</Text>
|
||||||
</Text>
|
<Text className="text-sm text-muted-foreground">
|
||||||
</Container>
|
Context engine status and diagnostics.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="w-full h-px bg-muted" />
|
||||||
|
<LocationSection />
|
||||||
|
|
||||||
|
<View className="w-full h-px bg-muted" />
|
||||||
|
<RecomputeSection />
|
||||||
|
|
||||||
|
<View className="w-full h-px bg-muted" />
|
||||||
|
<WinnerSection />
|
||||||
|
|
||||||
|
<View className="w-full h-px bg-muted" />
|
||||||
|
<FeedSection />
|
||||||
|
|
||||||
|
<View className="w-full h-px bg-muted" />
|
||||||
|
<NowPlayingSection />
|
||||||
|
|
||||||
|
<View className="w-full h-px bg-muted" />
|
||||||
|
<DiagnosticsSection
|
||||||
|
title="Weather Diagnostics"
|
||||||
|
entries={fixture.weatherDiagnostics}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View className="w-full h-px bg-muted" />
|
||||||
|
<DiagnosticsSection
|
||||||
|
title="Calendar Diagnostics"
|
||||||
|
entries={fixture.calendarDiagnostics}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View className="w-full h-px bg-muted" />
|
||||||
|
<TestSection />
|
||||||
|
</View>
|
||||||
|
</SafeAreaScrollView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = {
|
function ValueRow({ label, value }: { label: ReactNode; value: ReactNode }) {
|
||||||
container: "flex flex-1 bg-white",
|
return (
|
||||||
subtitle: "mt-2 text-sm text-muted-foreground",
|
<View className="flex flex-row items-center justify-between">
|
||||||
};
|
{typeof label === "string" ? (
|
||||||
|
<Text className="text-sm text-muted-foreground">{label}</Text>
|
||||||
|
) : (
|
||||||
|
label
|
||||||
|
)}
|
||||||
|
{typeof value === "string" || typeof value === "number" ? (
|
||||||
|
<Text className="text-sm">{value}</Text>
|
||||||
|
) : (
|
||||||
|
value
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LocationSection() {
|
||||||
|
const { authorization, lastLocation } = fixture.location;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="px-8">
|
||||||
|
<Text variant="h4" className="mb-2 font-medium">
|
||||||
|
Location
|
||||||
|
</Text>
|
||||||
|
<View className="gap-1">
|
||||||
|
<ValueRow label="Auth" value={authorization} />
|
||||||
|
{lastLocation ? (
|
||||||
|
<>
|
||||||
|
<ValueRow
|
||||||
|
label="Lat/Lon"
|
||||||
|
value={
|
||||||
|
<Text selectable className="text-sm">
|
||||||
|
{`${formatLatLon(lastLocation.latitude)}, ${formatLatLon(lastLocation.longitude)}`}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ValueRow
|
||||||
|
label="Accuracy"
|
||||||
|
value={`${Math.round(lastLocation.accuracyMeters)} m`}
|
||||||
|
/>
|
||||||
|
<ValueRow
|
||||||
|
label="Speed"
|
||||||
|
value={formatSpeed(lastLocation.speedMps)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text className="text-sm text-muted-foreground">No location yet</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecomputeSection() {
|
||||||
|
const { lastReason, lastTime, elapsedMs, fetchFailed, error } =
|
||||||
|
fixture.recompute;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="px-8">
|
||||||
|
<Text variant="h4" className="mb-2 font-medium">
|
||||||
|
Recompute
|
||||||
|
</Text>
|
||||||
|
<View className="gap-1">
|
||||||
|
<ValueRow label="Last reason" value={lastReason || "--"} />
|
||||||
|
<ValueRow label="Last time" value={formatTime(lastTime)} />
|
||||||
|
<ValueRow label="Elapsed" value={`${elapsedMs} ms`} />
|
||||||
|
<ValueRow label="Fetch failed" value={fetchFailed ? "Yes" : "No"} />
|
||||||
|
{error ? (
|
||||||
|
<Text className="text-xs text-muted-foreground">{error}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Button onPress={() => {}} variant="outline" className="mt-3">
|
||||||
|
<Text>Recompute Now</Text>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WinnerSection() {
|
||||||
|
const winner = fixture.winner;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Text variant="h4" className="mb-2 font-medium px-8">
|
||||||
|
Winner
|
||||||
|
</Text>
|
||||||
|
{winner ? (
|
||||||
|
<Card className="mx-4 gap-2 pb-2">
|
||||||
|
<CardHeader className="gap-0 border-b border-border pb-2 flex-row items-start justify-between">
|
||||||
|
<View className="gap-1.5">
|
||||||
|
<CardTitle className="flex-1">{winner.title}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{winner.subtitle ?? "No description"}
|
||||||
|
</CardDescription>
|
||||||
|
</View>
|
||||||
|
<Badge variant="default">
|
||||||
|
<Text>{winner.type}</Text>
|
||||||
|
</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="gap-1">
|
||||||
|
<ValueRow label="Bucket" value={winner.bucket} />
|
||||||
|
<ValueRow
|
||||||
|
label="Priority"
|
||||||
|
value={formatPriority(winner.priority)}
|
||||||
|
/>
|
||||||
|
<ValueRow label="TTL" value={`${winner.ttlSec}s`} />
|
||||||
|
{winner.poiType ? (
|
||||||
|
<ValueRow label="POI type" value={winner.poiType} />
|
||||||
|
) : null}
|
||||||
|
{winner.startsAt ? (
|
||||||
|
<ValueRow label="Starts at" value={`${winner.startsAt}`} />
|
||||||
|
) : null}
|
||||||
|
<ValueRow
|
||||||
|
label="ID"
|
||||||
|
value={
|
||||||
|
<Text selectable className="text-xs text-muted-foreground">
|
||||||
|
{winner.id}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Text className="text-sm text-muted-foreground">No winner yet</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeedSection() {
|
||||||
|
const feed = fixture.feed;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Text variant="h4" className="mb-2 font-medium px-8">
|
||||||
|
Feed
|
||||||
|
</Text>
|
||||||
|
{feed.length === 0 ? (
|
||||||
|
<Text className="text-sm text-muted-foreground">No feed items yet</Text>
|
||||||
|
) : (
|
||||||
|
<View className="px-4">
|
||||||
|
{feed.map((item, i) => (
|
||||||
|
<View
|
||||||
|
key={item.id}
|
||||||
|
className={cn("px-4 py-2 gap-1 border border-b-0 border-border", {
|
||||||
|
"rounded-t-lg": i === 0,
|
||||||
|
"rounded-b-lg border-b": i === feed.length - 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center justify-between gap-2">
|
||||||
|
<View className="gap-1">
|
||||||
|
<Text
|
||||||
|
className="text-sm font-semibold flex-1"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{item.subtitle || "No description"}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-muted-foreground">
|
||||||
|
{`bucket ${item.bucket} | prio ${formatPriority(item.priority)} | ttl ${item.ttlSec}s`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
<Text>{item.type}</Text>
|
||||||
|
</Badge>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NowPlayingSection() {
|
||||||
|
const { auth, title, artist, album, playbackStatus } = fixture.nowPlaying;
|
||||||
|
const subtitle = [artist, album].filter(Boolean).join(" | ") || "Apple Music";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="px-8">
|
||||||
|
<Text variant="h4" className="mb-2 font-medium">
|
||||||
|
Now Playing
|
||||||
|
</Text>
|
||||||
|
<View className="gap-1">
|
||||||
|
<ValueRow label="Music auth" value={auth} />
|
||||||
|
{title ? (
|
||||||
|
<>
|
||||||
|
<Text className="text-sm font-semibold" numberOfLines={1}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-muted-foreground" numberOfLines={1}>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-muted-foreground">
|
||||||
|
{playbackStatus}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text className="text-sm text-muted-foreground">
|
||||||
|
{auth === "Authorized" ? "Nothing playing" : "Not authorized"}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiagnosticsSection({
|
||||||
|
title,
|
||||||
|
entries,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
entries: Record<string, string>;
|
||||||
|
}) {
|
||||||
|
const keys = Object.keys(entries).sort();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="px-8">
|
||||||
|
<Text variant="h4" className="mb-2 font-medium">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<View className="gap-1">
|
||||||
|
{keys.map((key) => (
|
||||||
|
<ValueRow
|
||||||
|
key={key}
|
||||||
|
label={key}
|
||||||
|
value={
|
||||||
|
<Text selectable className="text-xs text-muted-foreground">
|
||||||
|
{entries[key]}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TestSection() {
|
||||||
|
return (
|
||||||
|
<View className="px-8">
|
||||||
|
<Text variant="h4" className="mb-2 font-medium">
|
||||||
|
Test
|
||||||
|
</Text>
|
||||||
|
<Text className="text-sm text-muted-foreground">{fixture.test.note}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
67
aris/apps/companion/components/ui/badge.tsx
Normal file
67
aris/apps/companion/components/ui/badge.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { TextClassContext } from '@/components/ui/text';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import * as Slot from '@rn-primitives/slot';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { Platform, View, ViewProps } from 'react-native';
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
cn(
|
||||||
|
'border-border group shrink-0 flex-row items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5',
|
||||||
|
Platform.select({
|
||||||
|
web: 'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive w-fit whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3',
|
||||||
|
})
|
||||||
|
),
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: cn(
|
||||||
|
'bg-primary border-transparent',
|
||||||
|
Platform.select({ web: '[a&]:hover:bg-primary/90' })
|
||||||
|
),
|
||||||
|
secondary: cn(
|
||||||
|
'bg-secondary border-transparent',
|
||||||
|
Platform.select({ web: '[a&]:hover:bg-secondary/90' })
|
||||||
|
),
|
||||||
|
destructive: cn(
|
||||||
|
'bg-destructive border-transparent',
|
||||||
|
Platform.select({ web: '[a&]:hover:bg-destructive/90' })
|
||||||
|
),
|
||||||
|
outline: Platform.select({ web: '[a&]:hover:bg-accent [a&]:hover:text-accent-foreground' }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const badgeTextVariants = cva('text-xs font-medium', {
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'text-primary-foreground',
|
||||||
|
secondary: 'text-secondary-foreground',
|
||||||
|
destructive: 'text-white',
|
||||||
|
outline: 'text-foreground',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type BadgeProps = ViewProps &
|
||||||
|
React.RefAttributes<View> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
} & VariantProps<typeof badgeVariants>;
|
||||||
|
|
||||||
|
function Badge({ className, variant, asChild, ...props }: BadgeProps) {
|
||||||
|
const Component = asChild ? Slot.View : View;
|
||||||
|
return (
|
||||||
|
<TextClassContext.Provider value={badgeTextVariants({ variant })}>
|
||||||
|
<Component className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
</TextClassContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeTextVariants, badgeVariants };
|
||||||
|
export type { BadgeProps };
|
||||||
@@ -1,52 +1,80 @@
|
|||||||
import { Text, TextClassContext } from '@/components/ui/text';
|
import { Text, TextClassContext } from "@/components/ui/text";
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from "@/lib/utils";
|
||||||
import { View, type ViewProps } from 'react-native';
|
import { View, type ViewProps } from "react-native";
|
||||||
|
|
||||||
function Card({ className, ...props }: ViewProps & React.RefAttributes<View>) {
|
function Card({ className, ...props }: ViewProps & React.RefAttributes<View>) {
|
||||||
return (
|
return (
|
||||||
<TextClassContext.Provider value="text-card-foreground">
|
<TextClassContext.Provider value="text-card-foreground">
|
||||||
<View
|
<View
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-card border-border flex flex-col gap-6 rounded-xl border py-6 shadow-sm shadow-black/5',
|
"bg-card border-border flex flex-col gap-4 rounded-xl border py-4 shadow-sm shadow-black/5",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</TextClassContext.Provider>
|
</TextClassContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardHeader({ className, ...props }: ViewProps & React.RefAttributes<View>) {
|
function CardHeader({
|
||||||
return <View className={cn('flex flex-col gap-1.5 px-6', className)} {...props} />;
|
className,
|
||||||
|
...props
|
||||||
|
}: ViewProps & React.RefAttributes<View>) {
|
||||||
|
return (
|
||||||
|
<View className={cn("flex flex-col gap-1.5 px-4", className)} {...props} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardTitle({
|
function CardTitle({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof Text> & React.RefAttributes<Text>) {
|
}: React.ComponentProps<typeof Text> & React.RefAttributes<Text>) {
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
role="heading"
|
role="heading"
|
||||||
aria-level={3}
|
aria-level={3}
|
||||||
className={cn('font-semibold leading-none', className)}
|
className={cn("font-semibold leading-none", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardDescription({
|
function CardDescription({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof Text> & React.RefAttributes<Text>) {
|
}: React.ComponentProps<typeof Text> & React.RefAttributes<Text>) {
|
||||||
return <Text className={cn('text-muted-foreground text-sm', className)} {...props} />;
|
return (
|
||||||
|
<Text
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardContent({ className, ...props }: ViewProps & React.RefAttributes<View>) {
|
function CardContent({
|
||||||
return <View className={cn('px-6', className)} {...props} />;
|
className,
|
||||||
|
...props
|
||||||
|
}: ViewProps & React.RefAttributes<View>) {
|
||||||
|
return <View className={cn("px-4", className)} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardFooter({ className, ...props }: ViewProps & React.RefAttributes<View>) {
|
function CardFooter({
|
||||||
return <View className={cn('flex flex-row items-center px-6', className)} {...props} />;
|
className,
|
||||||
|
...props
|
||||||
|
}: ViewProps & React.RefAttributes<View>) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className={cn("flex flex-row items-center px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
|
export {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user