204 lines
5.4 KiB
TypeScript
204 lines
5.4 KiB
TypeScript
"use client";
|
|
|
|
import React from "react";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Link as LinkIcon,
|
|
Type,
|
|
Image as ImageIcon,
|
|
User,
|
|
Building,
|
|
Calendar,
|
|
HelpCircle,
|
|
Hash,
|
|
Text,
|
|
} from "lucide-react";
|
|
|
|
const keyMappings: { [key: string]: string } = {
|
|
"@type": "Type",
|
|
"@context": "Context",
|
|
"@id": "ID",
|
|
name: "Name",
|
|
headline: "Headline",
|
|
description: "Description",
|
|
author: "Author",
|
|
publisher: "Publisher",
|
|
mainEntityOfPage: "Main Page",
|
|
image: "Image",
|
|
datePublished: "Date Published",
|
|
dateModified: "Date Modified",
|
|
acceptedAnswer: "Answer",
|
|
mainEntity: "Main Content",
|
|
url: "URL",
|
|
text: "Text",
|
|
question: "Question",
|
|
answer: "Answer",
|
|
logo: "Logo",
|
|
telephone: "Telephone",
|
|
email: "Email",
|
|
};
|
|
|
|
const keyIcons: { [key: string]: React.ElementType } = {
|
|
"@type": Type,
|
|
"@id": Hash,
|
|
url: LinkIcon,
|
|
image: ImageIcon,
|
|
logo: ImageIcon,
|
|
author: User,
|
|
publisher: Building,
|
|
datePublished: Calendar,
|
|
dateModified: Calendar,
|
|
question: HelpCircle,
|
|
text: Text,
|
|
};
|
|
|
|
const renderValue = (value: any, level: number): React.ReactNode => {
|
|
if (typeof value === "string") {
|
|
if (value.startsWith("http://") || value.startsWith("https://")) {
|
|
try {
|
|
const url = new URL(value);
|
|
return (
|
|
<a
|
|
href={value}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-blue-500 hover:underline break-all inline-flex items-center gap-1.5"
|
|
>
|
|
<LinkIcon className="h-3 w-3 flex-shrink-0" />
|
|
<span>
|
|
{url.hostname}
|
|
{url.pathname.length > 1 ? "/..." : ""}
|
|
</span>
|
|
</a>
|
|
);
|
|
} catch (e) {
|
|
return <span className="break-all">{value}</span>;
|
|
}
|
|
}
|
|
return <span className="break-words">{value}</span>;
|
|
}
|
|
|
|
if (typeof value === "object" && value !== null) {
|
|
if (Array.isArray(value)) {
|
|
return (
|
|
<div className="flex flex-col gap-1.5">
|
|
{value.map((item, index) => (
|
|
<React.Fragment key={index}>
|
|
{renderValue(item, level + 1)}
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<SchemaObjectRenderer data={value} isNested={true} level={level + 1} />
|
|
);
|
|
}
|
|
|
|
return <span>{String(value)}</span>;
|
|
};
|
|
|
|
const FaqQuestion = ({ item }: { item: any }) => {
|
|
if (
|
|
item["@type"] === "Question" &&
|
|
item.name &&
|
|
item.acceptedAnswer?.text
|
|
) {
|
|
return (
|
|
<div className="p-2 border rounded-md bg-background text-sm space-y-1">
|
|
<div className="grid grid-cols-[auto_1fr] gap-x-2 items-baseline">
|
|
<strong className="font-semibold text-muted-foreground">
|
|
Question:
|
|
</strong>
|
|
<p className="text-foreground">{item.name}</p>
|
|
</div>
|
|
<div className="grid grid-cols-[auto_1fr] gap-x-2 items-baseline">
|
|
<strong className="font-semibold text-muted-foreground">
|
|
Answer:
|
|
</strong>
|
|
<p className="text-foreground">{item.acceptedAnswer.text}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
// Fallback for non-standard Q&A items
|
|
return <SchemaObjectRenderer data={item} isNested={true} level={2} />;
|
|
};
|
|
|
|
const SchemaObjectRenderer = ({
|
|
data,
|
|
isNested = false,
|
|
level = 0,
|
|
}: {
|
|
data: any;
|
|
isNested?: boolean;
|
|
level?: number;
|
|
}) => {
|
|
const gridClasses =
|
|
level > 0 ? "md:grid-cols-[10rem_1fr]" : "md:grid-cols-[12rem_1fr]";
|
|
const paddingClasses = level > 1 ? "p-1.5" : "p-2";
|
|
|
|
const content = (
|
|
<div
|
|
className={`inline-grid grid-cols-1 ${gridClasses} items-start gap-x-4 gap-y-1.5`}
|
|
>
|
|
{Object.entries(data).map(([key, value]) => {
|
|
if (key === "@context") return null;
|
|
|
|
// Custom rendering for FAQPage questions
|
|
if (
|
|
key === "mainEntity" &&
|
|
data["@type"] === "FAQPage" &&
|
|
Array.isArray(value)
|
|
) {
|
|
return (
|
|
<React.Fragment key={key}>
|
|
<div className="flex items-center gap-2 font-semibold text-sm text-muted-foreground flex-shrink-0 whitespace-nowrap pt-0.5">
|
|
<span>Questions</span>
|
|
</div>
|
|
<div className="text-sm text-foreground space-y-2">
|
|
{value.map((item, index) => (
|
|
<FaqQuestion key={index} item={item} />
|
|
))}
|
|
</div>
|
|
</React.Fragment>
|
|
);
|
|
}
|
|
|
|
const label =
|
|
keyMappings[key] || key.charAt(0).toUpperCase() + key.slice(1);
|
|
const Icon = keyIcons[key];
|
|
|
|
return (
|
|
<React.Fragment key={key}>
|
|
<div className="flex items-center gap-2 font-semibold text-sm text-muted-foreground flex-shrink-0 whitespace-nowrap">
|
|
{Icon && <Icon className="h-4 w-4" />}
|
|
<span>{label}</span>
|
|
</div>
|
|
<div className="text-sm text-foreground">
|
|
{key === "@type" ? (
|
|
<Badge variant="secondary">{value as string}</Badge>
|
|
) : (
|
|
renderValue(value, level)
|
|
)}
|
|
</div>
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
|
|
if (isNested) {
|
|
return (
|
|
<div className={`${paddingClasses} border rounded-lg bg-muted/50`}>
|
|
{content}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return content;
|
|
};
|
|
|
|
export function PrettySchemaDisplay({ schema }: { schema: any }) {
|
|
return <SchemaObjectRenderer data={schema} />;
|
|
} |