[dyad] Refactored the meta form component - wrote 4 file(s)
This commit is contained in:
327
src/components/analysis-tab.tsx
Normal file
327
src/components/analysis-tab.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Edit,
|
||||
Check,
|
||||
ImageOff,
|
||||
ShieldCheck,
|
||||
ShieldX,
|
||||
Link as LinkIcon,
|
||||
} from "lucide-react";
|
||||
import { LengthIndicator } from "./length-indicator";
|
||||
import { CopyButton } from "./copy-button";
|
||||
import { SerpPreview } from "./serp-preview";
|
||||
import { KeywordHighlighter } from "./keyword-highlighter";
|
||||
import { Badge } from "./ui/badge";
|
||||
import type { MetaData } from "@/lib/types";
|
||||
|
||||
interface AnalysisTabProps {
|
||||
metaData: MetaData;
|
||||
url: string;
|
||||
editableTitle: string;
|
||||
setEditableTitle: (title: string) => void;
|
||||
isEditingTitle: boolean;
|
||||
setIsEditingTitle: (isEditing: boolean) => void;
|
||||
editableDescription: string;
|
||||
setEditableDescription: (description: string) => void;
|
||||
isEditingDescription: boolean;
|
||||
setIsEditingDescription: (isEditing: boolean) => void;
|
||||
imageError: boolean;
|
||||
setImageError: (hasError: boolean) => void;
|
||||
}
|
||||
|
||||
export function AnalysisTab({
|
||||
metaData,
|
||||
url,
|
||||
editableTitle,
|
||||
setEditableTitle,
|
||||
isEditingTitle,
|
||||
setIsEditingTitle,
|
||||
editableDescription,
|
||||
setEditableDescription,
|
||||
isEditingDescription,
|
||||
setIsEditingDescription,
|
||||
imageError,
|
||||
setImageError,
|
||||
}: AnalysisTabProps) {
|
||||
return (
|
||||
<Card className="w-full shadow-lg rounded-lg">
|
||||
<CardContent className="p-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="font-semibold text-card-foreground mb-2">
|
||||
SERP Preview
|
||||
</h3>
|
||||
<SerpPreview
|
||||
title={editableTitle}
|
||||
description={editableDescription}
|
||||
url={url}
|
||||
keyword={metaData.keyword}
|
||||
/>
|
||||
</div>
|
||||
{metaData.image && (
|
||||
<div>
|
||||
<h3 className="font-semibold text-card-foreground mb-2">
|
||||
Preview Image
|
||||
</h3>
|
||||
<div className="aspect-video bg-muted rounded-md overflow-hidden relative flex items-center justify-center">
|
||||
{!imageError ? (
|
||||
<img
|
||||
src={metaData.image}
|
||||
alt="Meta preview image"
|
||||
className="w-full h-full object-cover"
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<ImageOff className="h-8 w-8" />
|
||||
<span>Image not available</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-card-foreground">
|
||||
Meta Title
|
||||
</h3>
|
||||
<LengthIndicator
|
||||
length={editableTitle.length}
|
||||
type="title"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{editableTitle.length} Characters
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CopyButton textToCopy={editableTitle} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsEditingTitle(!isEditingTitle)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
{isEditingTitle ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Edit className="h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{isEditingTitle ? "Done editing" : "Edit"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1 mb-2">
|
||||
The title of the page, ideally between 30 and 60 characters.
|
||||
</p>
|
||||
{isEditingTitle ? (
|
||||
<Input
|
||||
value={editableTitle}
|
||||
onChange={(e) => setEditableTitle(e.target.value)}
|
||||
className="w-full"
|
||||
placeholder="Meta Title"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground bg-muted p-3 rounded-md min-h-[40px] break-words">
|
||||
{editableTitle ? (
|
||||
<KeywordHighlighter
|
||||
text={editableTitle}
|
||||
keyword={metaData.keyword}
|
||||
/>
|
||||
) : (
|
||||
"Not found"
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-card-foreground">
|
||||
Meta Description
|
||||
</h3>
|
||||
<LengthIndicator
|
||||
length={editableDescription.length}
|
||||
type="description"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{editableDescription.length} Characters
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CopyButton textToCopy={editableDescription} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setIsEditingDescription(!isEditingDescription)
|
||||
}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
{isEditingDescription ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Edit className="h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{isEditingDescription ? "Done editing" : "Edit"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1 mb-2">
|
||||
A brief summary of the page's content, ideally between 120 and
|
||||
158 characters.
|
||||
</p>
|
||||
{isEditingDescription ? (
|
||||
<Textarea
|
||||
value={editableDescription}
|
||||
onChange={(e) => setEditableDescription(e.target.value)}
|
||||
className="w-full min-h-[100px]"
|
||||
placeholder="Meta Description"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground bg-muted p-3 rounded-md min-h-[100px] break-words">
|
||||
{editableDescription ? (
|
||||
<KeywordHighlighter
|
||||
text={editableDescription}
|
||||
keyword={metaData.keyword}
|
||||
/>
|
||||
) : (
|
||||
"Not found"
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{metaData.keyword && typeof metaData.keywordCount === "number" && (
|
||||
<div>
|
||||
<h3 className="font-semibold text-card-foreground">
|
||||
Keyword Density
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 mb-2">
|
||||
The keyword "{metaData.keyword}" appears on the page.
|
||||
</p>
|
||||
<div className="bg-primary/10 border border-primary/20 p-4 rounded-lg flex items-baseline gap-3">
|
||||
<span className="font-mono text-4xl font-bold text-primary">
|
||||
{metaData.keywordCount}
|
||||
</span>
|
||||
<span className="text-muted-foreground font-medium">
|
||||
{metaData.keywordCount === 1
|
||||
? "occurrence"
|
||||
: "occurrences"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="font-semibold text-card-foreground">
|
||||
Indexability
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 mb-2">
|
||||
Indicates if search engines are allowed to index this page.
|
||||
</p>
|
||||
{(() => {
|
||||
const isIndexable =
|
||||
!metaData.robots ||
|
||||
!metaData.robots.toLowerCase().includes("noindex");
|
||||
return (
|
||||
<div
|
||||
className={`flex items-start gap-3 p-3 rounded-md ${
|
||||
isIndexable
|
||||
? "bg-green-500/10 border-green-500/20"
|
||||
: "bg-red-500/10 border-red-500/20"
|
||||
}`}
|
||||
>
|
||||
{isIndexable ? (
|
||||
<ShieldCheck className="h-5 w-5 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
) : (
|
||||
<ShieldX className="h-5 w-5 text-red-500 mt-0.5 flex-shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
<p
|
||||
className={`font-semibold ${
|
||||
isIndexable
|
||||
? "text-green-700 dark:text-green-400"
|
||||
: "text-red-700 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
{isIndexable
|
||||
? "Page is indexable"
|
||||
: "Page is set to 'noindex'"}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{metaData.robots
|
||||
? `Robots tag: "${metaData.robots}"`
|
||||
: "No robots meta tag found."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-card-foreground">
|
||||
Canonical URL
|
||||
</h3>
|
||||
<CopyButton
|
||||
textToCopy={metaData.canonical || "Not found"}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1 mb-2">
|
||||
The preferred URL for this page, to avoid duplicate content
|
||||
issues.
|
||||
</p>
|
||||
<p className="text-muted-foreground bg-muted p-3 rounded-md min-h-[40px] break-all flex items-center gap-2">
|
||||
{metaData.canonical ? (
|
||||
<>
|
||||
<LinkIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">{metaData.canonical}</span>
|
||||
</>
|
||||
) : (
|
||||
"Not found"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-card-foreground">
|
||||
Meta Keywords
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 mb-2">
|
||||
Keywords associated with the page. Note: Most search engines
|
||||
no longer use this tag for ranking.
|
||||
</p>
|
||||
<div className="text-muted-foreground bg-muted p-3 rounded-md min-h-[40px]">
|
||||
{metaData.keywords ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{metaData.keywords
|
||||
.split(",")
|
||||
.map((k) => k.trim())
|
||||
.filter(Boolean)
|
||||
.map((k, i) => (
|
||||
<Badge key={i} variant="secondary">
|
||||
{k}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
"Not found"
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
78
src/components/meta-form-inputs.tsx
Normal file
78
src/components/meta-form-inputs.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Globe, Search, Loader2, X } from "lucide-react";
|
||||
|
||||
interface MetaFormInputsProps {
|
||||
url: string;
|
||||
setUrl: (url: string) => void;
|
||||
keyword: string;
|
||||
setKeyword: (keyword: string) => void;
|
||||
handleSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
||||
handleClear: () => void;
|
||||
loading: boolean;
|
||||
showClearButton: boolean;
|
||||
}
|
||||
|
||||
export function MetaFormInputs({
|
||||
url,
|
||||
setUrl,
|
||||
keyword,
|
||||
setKeyword,
|
||||
handleSubmit,
|
||||
handleClear,
|
||||
loading,
|
||||
showClearButton,
|
||||
}: MetaFormInputsProps) {
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col sm:flex-row items-center gap-3"
|
||||
>
|
||||
<div className="relative w-full">
|
||||
<Globe className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
|
||||
<Input
|
||||
name="url"
|
||||
type="text"
|
||||
placeholder="example.com"
|
||||
required
|
||||
className="pl-10 h-12 text-base rounded-lg shadow-sm"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative w-full sm:w-auto">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
|
||||
<Input
|
||||
name="keyword"
|
||||
type="text"
|
||||
placeholder="Keyword (optional)"
|
||||
className="pl-10 h-12 text-base rounded-lg shadow-sm sm:w-64"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full sm:w-auto h-12 px-8 rounded-lg font-semibold transition-all flex items-center gap-2"
|
||||
>
|
||||
{loading && <Loader2 className="h-5 w-5 animate-spin" />}
|
||||
{loading ? "Extracting..." : "Extract"}
|
||||
</Button>
|
||||
{showClearButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleClear}
|
||||
className="h-12 w-12"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
<span className="sr-only">Clear</span>
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Globe,
|
||||
Edit,
|
||||
Check,
|
||||
Loader2,
|
||||
X,
|
||||
ImageOff,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
ShieldX,
|
||||
Link as LinkIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
extractMetaData,
|
||||
type HeadlineNode,
|
||||
type ImageAltData,
|
||||
} from "@/app/actions";
|
||||
import { LengthIndicator } from "./length-indicator";
|
||||
import { CopyButton } from "./copy-button";
|
||||
import { SerpPreview } from "./serp-preview";
|
||||
import { extractMetaData } from "@/app/actions";
|
||||
import { ResultsSkeleton } from "./results-skeleton";
|
||||
import { FaqDisplay } from "./faq-display";
|
||||
import { HeadlineTree } from "./headline-tree";
|
||||
@@ -32,24 +10,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ImageAltDisplay } from "./image-alt-display";
|
||||
import { TabIndicator } from "./tab-indicator";
|
||||
import { getLengthIndicatorColor, type IndicatorColor } from "@/lib/analysis";
|
||||
import { KeywordHighlighter } from "./keyword-highlighter";
|
||||
import { SchemaDisplay } from "./schema-display";
|
||||
import { Badge } from "./ui/badge";
|
||||
|
||||
interface MetaData {
|
||||
title: string;
|
||||
description: string;
|
||||
image?: string | null;
|
||||
faq?: { question: string; answer: string }[] | null;
|
||||
schema?: any[] | null;
|
||||
headlines?: HeadlineNode[] | null;
|
||||
keyword?: string | null;
|
||||
keywordCount?: number | null;
|
||||
images?: ImageAltData[] | null;
|
||||
canonical?: string | null;
|
||||
keywords?: string | null;
|
||||
robots?: string | null;
|
||||
}
|
||||
import { MetaFormInputs } from "./meta-form-inputs";
|
||||
import { AnalysisTab } from "./analysis-tab";
|
||||
import type { MetaData } from "@/lib/types";
|
||||
|
||||
export function MetaForm() {
|
||||
const [url, setUrl] = useState("");
|
||||
@@ -161,7 +125,7 @@ export function MetaForm() {
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
} else if (result.data) {
|
||||
setMetaData(result.data);
|
||||
setMetaData(result.data as MetaData);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
@@ -177,54 +141,16 @@ export function MetaForm() {
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-6">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col sm:flex-row items-center gap-3"
|
||||
>
|
||||
<div className="relative w-full">
|
||||
<Globe className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
|
||||
<Input
|
||||
name="url"
|
||||
type="text"
|
||||
placeholder="example.com"
|
||||
required
|
||||
className="pl-10 h-12 text-base rounded-lg shadow-sm"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
<MetaFormInputs
|
||||
url={url}
|
||||
setUrl={setUrl}
|
||||
keyword={keyword}
|
||||
setKeyword={setKeyword}
|
||||
handleSubmit={handleSubmit}
|
||||
handleClear={handleClear}
|
||||
loading={loading}
|
||||
showClearButton={!!(url || metaData || error)}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative w-full sm:w-auto">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
|
||||
<Input
|
||||
name="keyword"
|
||||
type="text"
|
||||
placeholder="Keyword (optional)"
|
||||
className="pl-10 h-12 text-base rounded-lg shadow-sm sm:w-64"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full sm:w-auto h-12 px-8 rounded-lg font-semibold transition-all flex items-center gap-2"
|
||||
>
|
||||
{loading && <Loader2 className="h-5 w-5 animate-spin" />}
|
||||
{loading ? "Extracting..." : "Extract"}
|
||||
</Button>
|
||||
{(url || metaData || error) && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleClear}
|
||||
className="h-12 w-12"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
<span className="sr-only">Clear</span>
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{loading && <ResultsSkeleton />}
|
||||
|
||||
@@ -270,288 +196,20 @@ export function MetaForm() {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="analysis">
|
||||
<Card className="w-full shadow-lg rounded-lg">
|
||||
<CardContent className="p-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="font-semibold text-card-foreground mb-2">
|
||||
SERP Preview
|
||||
</h3>
|
||||
<SerpPreview
|
||||
title={editableTitle}
|
||||
description={editableDescription}
|
||||
<AnalysisTab
|
||||
metaData={metaData}
|
||||
url={url}
|
||||
keyword={metaData.keyword}
|
||||
editableTitle={editableTitle}
|
||||
setEditableTitle={setEditableTitle}
|
||||
isEditingTitle={isEditingTitle}
|
||||
setIsEditingTitle={setIsEditingTitle}
|
||||
editableDescription={editableDescription}
|
||||
setEditableDescription={setEditableDescription}
|
||||
isEditingDescription={isEditingDescription}
|
||||
setIsEditingDescription={setIsEditingDescription}
|
||||
imageError={imageError}
|
||||
setImageError={setImageError}
|
||||
/>
|
||||
</div>
|
||||
{metaData.image && (
|
||||
<div>
|
||||
<h3 className="font-semibold text-card-foreground mb-2">
|
||||
Preview Image
|
||||
</h3>
|
||||
<div className="aspect-video bg-muted rounded-md overflow-hidden relative flex items-center justify-center">
|
||||
{!imageError ? (
|
||||
<img
|
||||
src={metaData.image}
|
||||
alt="Meta preview image"
|
||||
className="w-full h-full object-cover"
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<ImageOff className="h-8 w-8" />
|
||||
<span>Image not available</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-card-foreground">
|
||||
Meta Title
|
||||
</h3>
|
||||
<LengthIndicator
|
||||
length={editableTitle.length}
|
||||
type="title"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{editableTitle.length} Characters
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CopyButton textToCopy={editableTitle} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsEditingTitle(!isEditingTitle)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
{isEditingTitle ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Edit className="h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{isEditingTitle ? "Done editing" : "Edit"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1 mb-2">
|
||||
The title of the page, ideally between 30 and 60
|
||||
characters.
|
||||
</p>
|
||||
{isEditingTitle ? (
|
||||
<Input
|
||||
value={editableTitle}
|
||||
onChange={(e) => setEditableTitle(e.target.value)}
|
||||
className="w-full"
|
||||
placeholder="Meta Title"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground bg-muted p-3 rounded-md min-h-[40px] break-words">
|
||||
{editableTitle ? (
|
||||
<KeywordHighlighter
|
||||
text={editableTitle}
|
||||
keyword={metaData.keyword}
|
||||
/>
|
||||
) : (
|
||||
"Not found"
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-card-foreground">
|
||||
Meta Description
|
||||
</h3>
|
||||
<LengthIndicator
|
||||
length={editableDescription.length}
|
||||
type="description"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{editableDescription.length} Characters
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CopyButton textToCopy={editableDescription} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setIsEditingDescription(!isEditingDescription)
|
||||
}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
{isEditingDescription ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Edit className="h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{isEditingDescription ? "Done editing" : "Edit"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1 mb-2">
|
||||
A brief summary of the page's content, ideally between
|
||||
120 and 158 characters.
|
||||
</p>
|
||||
{isEditingDescription ? (
|
||||
<Textarea
|
||||
value={editableDescription}
|
||||
onChange={(e) =>
|
||||
setEditableDescription(e.target.value)
|
||||
}
|
||||
className="w-full min-h-[100px]"
|
||||
placeholder="Meta Description"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground bg-muted p-3 rounded-md min-h-[100px] break-words">
|
||||
{editableDescription ? (
|
||||
<KeywordHighlighter
|
||||
text={editableDescription}
|
||||
keyword={metaData.keyword}
|
||||
/>
|
||||
) : (
|
||||
"Not found"
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{metaData.keyword &&
|
||||
typeof metaData.keywordCount === "number" && (
|
||||
<div>
|
||||
<h3 className="font-semibold text-card-foreground">
|
||||
Keyword Density
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 mb-2">
|
||||
The keyword "{metaData.keyword}" appears on the
|
||||
page.
|
||||
</p>
|
||||
<div className="bg-primary/10 border border-primary/20 p-4 rounded-lg flex items-baseline gap-3">
|
||||
<span className="font-mono text-4xl font-bold text-primary">
|
||||
{metaData.keywordCount}
|
||||
</span>
|
||||
<span className="text-muted-foreground font-medium">
|
||||
{metaData.keywordCount === 1
|
||||
? "occurrence"
|
||||
: "occurrences"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="font-semibold text-card-foreground">
|
||||
Indexability
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 mb-2">
|
||||
Indicates if search engines are allowed to index this
|
||||
page.
|
||||
</p>
|
||||
{(() => {
|
||||
const isIndexable =
|
||||
!metaData.robots ||
|
||||
!metaData.robots.toLowerCase().includes("noindex");
|
||||
return (
|
||||
<div
|
||||
className={`flex items-start gap-3 p-3 rounded-md ${
|
||||
isIndexable
|
||||
? "bg-green-500/10 border-green-500/20"
|
||||
: "bg-red-500/10 border-red-500/20"
|
||||
}`}
|
||||
>
|
||||
{isIndexable ? (
|
||||
<ShieldCheck className="h-5 w-5 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
) : (
|
||||
<ShieldX className="h-5 w-5 text-red-500 mt-0.5 flex-shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
<p
|
||||
className={`font-semibold ${
|
||||
isIndexable
|
||||
? "text-green-700 dark:text-green-400"
|
||||
: "text-red-700 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
{isIndexable
|
||||
? "Page is indexable"
|
||||
: "Page is set to 'noindex'"}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{metaData.robots
|
||||
? `Robots tag: "${metaData.robots}"`
|
||||
: "No robots meta tag found."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-card-foreground">
|
||||
Canonical URL
|
||||
</h3>
|
||||
<CopyButton
|
||||
textToCopy={metaData.canonical || "Not found"}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1 mb-2">
|
||||
The preferred URL for this page, to avoid duplicate
|
||||
content issues.
|
||||
</p>
|
||||
<p className="text-muted-foreground bg-muted p-3 rounded-md min-h-[40px] break-all flex items-center gap-2">
|
||||
{metaData.canonical ? (
|
||||
<>
|
||||
<LinkIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
{metaData.canonical}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
"Not found"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-card-foreground">
|
||||
Meta Keywords
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 mb-2">
|
||||
Keywords associated with the page. Note: Most search
|
||||
engines no longer use this tag for ranking.
|
||||
</p>
|
||||
<div className="text-muted-foreground bg-muted p-3 rounded-md min-h-[40px]">
|
||||
{metaData.keywords ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{metaData.keywords
|
||||
.split(",")
|
||||
.map((k) => k.trim())
|
||||
.filter(Boolean)
|
||||
.map((k, i) => (
|
||||
<Badge key={i} variant="secondary">
|
||||
{k}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
"Not found"
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{metaData.headlines && metaData.headlines.length > 0 && (
|
||||
|
||||
16
src/lib/types.ts
Normal file
16
src/lib/types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { HeadlineNode, ImageAltData } from "@/app/actions";
|
||||
|
||||
export interface MetaData {
|
||||
title: string;
|
||||
description: string;
|
||||
image?: string | null;
|
||||
faq?: { question: string; answer: string }[] | null;
|
||||
schema?: any[] | null;
|
||||
headlines?: HeadlineNode[] | null;
|
||||
keyword?: string | null;
|
||||
keywordCount?: number | null;
|
||||
images?: ImageAltData[] | null;
|
||||
canonical?: string | null;
|
||||
keywords?: string | null;
|
||||
robots?: string | null;
|
||||
}
|
||||
Reference in New Issue
Block a user