mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-29 10:56:24 +02:00
refactor: remove link_preview tool and associated components to streamline agent functionality
This commit is contained in:
parent
6c507989d2
commit
a009cae62a
16 changed files with 5 additions and 1202 deletions
|
|
@ -48,27 +48,6 @@ export {
|
|||
DeleteLinearIssueToolUI,
|
||||
UpdateLinearIssueToolUI,
|
||||
} from "./linear";
|
||||
export {
|
||||
type LinkPreviewArgs,
|
||||
LinkPreviewArgsSchema,
|
||||
type LinkPreviewResult,
|
||||
LinkPreviewResultSchema,
|
||||
LinkPreviewToolUI,
|
||||
type MultiLinkPreviewArgs,
|
||||
MultiLinkPreviewArgsSchema,
|
||||
type MultiLinkPreviewResult,
|
||||
MultiLinkPreviewResultSchema,
|
||||
MultiLinkPreviewToolUI,
|
||||
} from "./link-preview";
|
||||
export {
|
||||
MediaCard,
|
||||
MediaCardErrorBoundary,
|
||||
MediaCardLoading,
|
||||
type MediaCardProps,
|
||||
MediaCardSkeleton,
|
||||
parseSerializableMediaCard,
|
||||
type SerializableMediaCard,
|
||||
} from "./media-card";
|
||||
export { CreateNotionPageToolUI, DeleteNotionPageToolUI, UpdateNotionPageToolUI } from "./notion";
|
||||
export {
|
||||
Plan,
|
||||
|
|
|
|||
|
|
@ -1,250 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
|
||||
import { AlertCircleIcon, ExternalLinkIcon, LinkIcon } from "lucide-react";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
MediaCard,
|
||||
MediaCardErrorBoundary,
|
||||
MediaCardLoading,
|
||||
parseSerializableMediaCard,
|
||||
type SerializableMediaCard,
|
||||
} from "@/components/tool-ui/media-card";
|
||||
|
||||
// ============================================================================
|
||||
// Zod Schemas
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Schema for link_preview tool arguments
|
||||
*/
|
||||
const LinkPreviewArgsSchema = z.object({
|
||||
url: z.string(),
|
||||
title: z.string().nullish(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for link_preview tool result
|
||||
*/
|
||||
const LinkPreviewResultSchema = z.object({
|
||||
id: z.string(),
|
||||
assetId: z.string(),
|
||||
kind: z.literal("link"),
|
||||
href: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().nullish(),
|
||||
thumb: z.string().nullish(),
|
||||
domain: z.string().nullish(),
|
||||
error: z.string().nullish(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type LinkPreviewArgs = z.infer<typeof LinkPreviewArgsSchema>;
|
||||
type LinkPreviewResult = z.infer<typeof LinkPreviewResultSchema>;
|
||||
|
||||
/**
|
||||
* Error state component shown when link preview fails
|
||||
*/
|
||||
function LinkPreviewErrorState({ url, error }: { url: string; error: string }) {
|
||||
return (
|
||||
<div className="my-4 overflow-hidden rounded-xl border border-destructive/20 bg-destructive/5 p-4 max-w-md">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
|
||||
<AlertCircleIcon className="size-6 text-destructive" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-destructive text-sm">Failed to load preview</p>
|
||||
<p className="text-muted-foreground text-xs mt-0.5 truncate">{url}</p>
|
||||
<p className="text-muted-foreground text-xs mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancelled state component
|
||||
*/
|
||||
function LinkPreviewCancelledState({ url }: { url: string }) {
|
||||
return (
|
||||
<div className="my-4 rounded-xl border border-muted p-4 text-muted-foreground max-w-md">
|
||||
<p className="flex items-center gap-2">
|
||||
<LinkIcon className="size-4" />
|
||||
<span className="line-through truncate">Preview: {url}</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed MediaCard component with error handling
|
||||
*/
|
||||
function ParsedMediaCard({ result }: { result: unknown }) {
|
||||
const card = parseSerializableMediaCard(result);
|
||||
|
||||
return (
|
||||
<MediaCard
|
||||
{...card}
|
||||
maxWidth="420px"
|
||||
responseActions={[{ id: "open", label: "Open", variant: "default" }]}
|
||||
onResponseAction={(id) => {
|
||||
if (id === "open" && card.href) {
|
||||
window.open(card.href, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Link Preview Tool UI Component
|
||||
*
|
||||
* This component is registered with assistant-ui to render a rich
|
||||
* link preview card when the link_preview tool is called by the agent.
|
||||
*
|
||||
* It displays website metadata including:
|
||||
* - Title and description
|
||||
* - Thumbnail/Open Graph image
|
||||
* - Domain name
|
||||
* - Clickable link to open in new tab
|
||||
*/
|
||||
export const LinkPreviewToolUI = ({ args, result, status }: ToolCallMessagePartProps<LinkPreviewArgs, LinkPreviewResult>) => {
|
||||
const url = args.url || "Unknown URL";
|
||||
|
||||
// Loading state - tool is still running
|
||||
if (status.type === "running" || status.type === "requires-action") {
|
||||
return (
|
||||
<div className="my-4">
|
||||
<MediaCardLoading title={`Loading preview for ${url}...`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Incomplete/cancelled state
|
||||
if (status.type === "incomplete") {
|
||||
if (status.reason === "cancelled") {
|
||||
return <LinkPreviewCancelledState url={url} />;
|
||||
}
|
||||
if (status.reason === "error") {
|
||||
return (
|
||||
<LinkPreviewErrorState
|
||||
url={url}
|
||||
error={typeof status.error === "string" ? status.error : "An error occurred"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// No result yet
|
||||
if (!result) {
|
||||
return (
|
||||
<div className="my-4">
|
||||
<MediaCardLoading title={`Fetching metadata for ${url}...`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error result from the tool
|
||||
if (result.error) {
|
||||
return <LinkPreviewErrorState url={url} error={result.error} />;
|
||||
}
|
||||
|
||||
// Success - render the media card
|
||||
return (
|
||||
<div className="my-4">
|
||||
<MediaCardErrorBoundary>
|
||||
<ParsedMediaCard result={result} />
|
||||
</MediaCardErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Multi Link Preview Schemas
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Schema for multi_link_preview tool arguments
|
||||
*/
|
||||
const MultiLinkPreviewArgsSchema = z.object({
|
||||
urls: z.array(z.string()),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for error items in multi_link_preview result
|
||||
*/
|
||||
const MultiLinkPreviewErrorSchema = z.object({
|
||||
url: z.string(),
|
||||
error: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for multi_link_preview tool result
|
||||
*/
|
||||
const MultiLinkPreviewResultSchema = z.object({
|
||||
previews: z.array(LinkPreviewResultSchema),
|
||||
errors: z.array(MultiLinkPreviewErrorSchema).nullish(),
|
||||
});
|
||||
|
||||
type MultiLinkPreviewArgs = z.infer<typeof MultiLinkPreviewArgsSchema>;
|
||||
type MultiLinkPreviewResult = z.infer<typeof MultiLinkPreviewResultSchema>;
|
||||
|
||||
export const MultiLinkPreviewToolUI = ({ args, result, status }: ToolCallMessagePartProps<MultiLinkPreviewArgs, MultiLinkPreviewResult>) => {
|
||||
const urls = args.urls || [];
|
||||
|
||||
// Loading state
|
||||
if (status.type === "running" || status.type === "requires-action") {
|
||||
return (
|
||||
<div className="my-4 grid gap-4 sm:grid-cols-2">
|
||||
{urls.slice(0, 4).map((url, index) => (
|
||||
<MediaCardLoading key={`loading-${url}-${index}`} title="Loading..." />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Incomplete state
|
||||
if (status.type === "incomplete") {
|
||||
return (
|
||||
<div className="my-4 text-muted-foreground text-sm">
|
||||
<p className="flex items-center gap-2">
|
||||
<LinkIcon className="size-4" />
|
||||
<span>Link previews cancelled</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No result
|
||||
if (!result || !result.previews) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Render grid of previews
|
||||
return (
|
||||
<div className="my-4 grid gap-4 sm:grid-cols-2">
|
||||
{result.previews.map((preview) => (
|
||||
<MediaCardErrorBoundary key={preview.id}>
|
||||
<ParsedMediaCard result={preview} />
|
||||
</MediaCardErrorBoundary>
|
||||
))}
|
||||
{result.errors?.map((err) => (
|
||||
<LinkPreviewErrorState key={err.url} url={err.url} error={err.error} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
LinkPreviewArgsSchema,
|
||||
LinkPreviewResultSchema,
|
||||
MultiLinkPreviewArgsSchema,
|
||||
MultiLinkPreviewResultSchema,
|
||||
type LinkPreviewArgs,
|
||||
type LinkPreviewResult,
|
||||
type MultiLinkPreviewArgs,
|
||||
type MultiLinkPreviewResult,
|
||||
};
|
||||
|
|
@ -1,354 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { ExternalLinkIcon, Globe, ImageIcon, LinkIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { Component, type ReactNode } from "react";
|
||||
import { z } from "zod";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Zod schemas for runtime validation
|
||||
*/
|
||||
const AspectRatioSchema = z.enum(["1:1", "4:3", "16:9", "9:16", "21:9", "auto"]);
|
||||
const MediaCardKindSchema = z.enum(["link", "image", "video", "audio"]);
|
||||
|
||||
const ResponseActionSchema = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
variant: z.enum(["default", "secondary", "outline", "destructive", "ghost"]).nullish(),
|
||||
confirmLabel: z.string().nullish(),
|
||||
});
|
||||
|
||||
const SerializableMediaCardSchema = z.object({
|
||||
id: z.string(),
|
||||
assetId: z.string(),
|
||||
kind: MediaCardKindSchema,
|
||||
href: z.string().nullish(),
|
||||
src: z.string().nullish(),
|
||||
title: z.string(),
|
||||
description: z.string().nullish(),
|
||||
thumb: z.string().nullish(),
|
||||
ratio: AspectRatioSchema.nullish(),
|
||||
domain: z.string().nullish(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Types derived from Zod schemas
|
||||
*/
|
||||
type AspectRatio = z.infer<typeof AspectRatioSchema>;
|
||||
type MediaCardKind = z.infer<typeof MediaCardKindSchema>;
|
||||
type ResponseAction = z.infer<typeof ResponseActionSchema>;
|
||||
export type SerializableMediaCard = z.infer<typeof SerializableMediaCardSchema>;
|
||||
|
||||
/**
|
||||
* Props for the MediaCard component
|
||||
*/
|
||||
export interface MediaCardProps {
|
||||
id: string;
|
||||
assetId: string;
|
||||
kind: MediaCardKind;
|
||||
href?: string;
|
||||
src?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
thumb?: string;
|
||||
ratio?: AspectRatio;
|
||||
domain?: string;
|
||||
maxWidth?: string;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
responseActions?: ResponseAction[];
|
||||
onResponseAction?: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate serializable media card from tool result
|
||||
*/
|
||||
export function parseSerializableMediaCard(result: unknown): SerializableMediaCard {
|
||||
const parsed = SerializableMediaCardSchema.safeParse(result);
|
||||
|
||||
if (!parsed.success) {
|
||||
console.warn("Invalid media card data:", parsed.error.issues);
|
||||
throw new Error(`Invalid media card: ${parsed.error.issues.map((i) => i.message).join(", ")}`);
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get aspect ratio class based on ratio prop
|
||||
*/
|
||||
function getAspectRatioClass(ratio?: AspectRatio): string {
|
||||
switch (ratio) {
|
||||
case "1:1":
|
||||
return "aspect-square";
|
||||
case "4:3":
|
||||
return "aspect-[4/3]";
|
||||
case "16:9":
|
||||
return "aspect-video";
|
||||
case "9:16":
|
||||
return "aspect-[9/16]";
|
||||
case "21:9":
|
||||
return "aspect-[21/9]";
|
||||
case "auto":
|
||||
default:
|
||||
return "aspect-[2/1]";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon based on media card kind
|
||||
*/
|
||||
function getKindIcon(kind: MediaCardKind) {
|
||||
switch (kind) {
|
||||
case "link":
|
||||
return <LinkIcon className="size-5" />;
|
||||
case "image":
|
||||
return <ImageIcon className="size-5" />;
|
||||
case "video":
|
||||
case "audio":
|
||||
return <Globe className="size-5" />;
|
||||
default:
|
||||
return <LinkIcon className="size-5" />;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error boundary for MediaCard
|
||||
*/
|
||||
interface MediaCardErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export class MediaCardErrorBoundary extends Component<
|
||||
{ children: ReactNode },
|
||||
MediaCardErrorBoundaryState
|
||||
> {
|
||||
constructor(props: { children: ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): MediaCardErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Card className="w-full max-w-md border-destructive/20 bg-destructive/5">
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
|
||||
<LinkIcon className="size-5 text-destructive" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-destructive text-sm">Failed to load preview</p>
|
||||
<p className="text-muted-foreground text-xs truncate">
|
||||
{this.state.error?.message || "An error occurred"}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading skeleton for MediaCard
|
||||
*/
|
||||
export function MediaCardSkeleton({ maxWidth = "420px" }: { maxWidth?: string }) {
|
||||
return (
|
||||
<Card className="w-full overflow-hidden animate-pulse" style={{ maxWidth }}>
|
||||
<div className="aspect-[2/1] bg-muted" />
|
||||
<CardContent className="p-4">
|
||||
<div className="h-4 w-3/4 rounded bg-muted" />
|
||||
<div className="mt-2 h-3 w-full rounded bg-muted" />
|
||||
<div className="mt-1 h-3 w-2/3 rounded bg-muted" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* MediaCard Component
|
||||
*
|
||||
* A rich media card for displaying link previews, images, and other media
|
||||
* in AI chat applications. Supports thumbnails, descriptions, and actions.
|
||||
*/
|
||||
export function MediaCard({
|
||||
id,
|
||||
kind,
|
||||
href,
|
||||
title,
|
||||
description,
|
||||
thumb,
|
||||
ratio = "auto",
|
||||
domain,
|
||||
maxWidth = "420px",
|
||||
alt,
|
||||
className,
|
||||
responseActions,
|
||||
onResponseAction,
|
||||
}: MediaCardProps) {
|
||||
const aspectRatioClass = getAspectRatioClass(ratio);
|
||||
const displayDomain = domain || (href ? new URL(href).hostname.replace("www.", "") : undefined);
|
||||
|
||||
const handleCardClick = () => {
|
||||
if (href) {
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Card
|
||||
id={id}
|
||||
className={cn(
|
||||
"group relative w-full overflow-hidden transition-all duration-200",
|
||||
"hover:shadow-lg hover:border-primary/20",
|
||||
href && "cursor-pointer",
|
||||
className
|
||||
)}
|
||||
style={{ maxWidth }}
|
||||
onClick={href ? handleCardClick : undefined}
|
||||
role={href ? "link" : undefined}
|
||||
tabIndex={href ? 0 : undefined}
|
||||
onKeyDown={(e) => {
|
||||
if (href && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
handleCardClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
{thumb && (
|
||||
<div className={cn("relative w-full overflow-hidden bg-muted", aspectRatioClass)}>
|
||||
<Image
|
||||
src={thumb}
|
||||
alt={alt || title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
unoptimized
|
||||
onError={(e) => {
|
||||
// Hide broken images
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
{/* Gradient overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback when no thumbnail */}
|
||||
{!thumb && (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex w-full items-center justify-center bg-gradient-to-br from-muted to-muted/50",
|
||||
aspectRatioClass
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
{getKindIcon(kind)}
|
||||
<span className="text-xs">{kind === "link" ? "Link Preview" : kind}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Domain favicon placeholder */}
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
<Globe className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Domain badge */}
|
||||
{displayDomain && (
|
||||
<div className="mb-1.5 flex items-center gap-1.5">
|
||||
<Badge variant="secondary" className="text-xs font-normal">
|
||||
{displayDomain}
|
||||
</Badge>
|
||||
{href && (
|
||||
<ExternalLinkIcon className="size-3 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="font-semibold text-foreground text-sm leading-tight line-clamp-2 group-hover:text-primary transition-colors">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="mt-1.5 text-muted-foreground text-xs leading-relaxed line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Response Actions */}
|
||||
{responseActions && responseActions.length > 0 && (
|
||||
<div className="mt-4 flex items-center justify-end gap-2 border-t pt-3">
|
||||
{responseActions.map((action) => (
|
||||
<Tooltip key={action.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={action.variant || "secondary"}
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onResponseAction?.(action.id);
|
||||
}}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{action.confirmLabel && (
|
||||
<TooltipContent>
|
||||
<p>{action.confirmLabel}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* MediaCard Loading State
|
||||
*/
|
||||
export function MediaCardLoading({ title = "Loading preview..." }: { title?: string }) {
|
||||
return (
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="aspect-[2/1] bg-muted animate-pulse flex items-center justify-center">
|
||||
<Spinner size="lg" className="text-muted-foreground" />
|
||||
</div>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 rounded-lg bg-muted animate-pulse" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 w-3/4 rounded bg-muted animate-pulse" />
|
||||
<div className="h-3 w-1/2 rounded bg-muted animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-center text-muted-foreground text-sm">{title}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue