[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";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useMemo } from "react";
|
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 { Card, CardContent } from "@/components/ui/card";
|
||||||
import {
|
import { extractMetaData } from "@/app/actions";
|
||||||
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 { ResultsSkeleton } from "./results-skeleton";
|
import { ResultsSkeleton } from "./results-skeleton";
|
||||||
import { FaqDisplay } from "./faq-display";
|
import { FaqDisplay } from "./faq-display";
|
||||||
import { HeadlineTree } from "./headline-tree";
|
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 { ImageAltDisplay } from "./image-alt-display";
|
||||||
import { TabIndicator } from "./tab-indicator";
|
import { TabIndicator } from "./tab-indicator";
|
||||||
import { getLengthIndicatorColor, type IndicatorColor } from "@/lib/analysis";
|
import { getLengthIndicatorColor, type IndicatorColor } from "@/lib/analysis";
|
||||||
import { KeywordHighlighter } from "./keyword-highlighter";
|
|
||||||
import { SchemaDisplay } from "./schema-display";
|
import { SchemaDisplay } from "./schema-display";
|
||||||
import { Badge } from "./ui/badge";
|
import { MetaFormInputs } from "./meta-form-inputs";
|
||||||
|
import { AnalysisTab } from "./analysis-tab";
|
||||||
interface MetaData {
|
import type { MetaData } from "@/lib/types";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MetaForm() {
|
export function MetaForm() {
|
||||||
const [url, setUrl] = useState("");
|
const [url, setUrl] = useState("");
|
||||||
@@ -161,7 +125,7 @@ export function MetaForm() {
|
|||||||
if (result.error) {
|
if (result.error) {
|
||||||
setError(result.error);
|
setError(result.error);
|
||||||
} else if (result.data) {
|
} else if (result.data) {
|
||||||
setMetaData(result.data);
|
setMetaData(result.data as MetaData);
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -177,54 +141,16 @@ export function MetaForm() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full space-y-6">
|
<div className="w-full space-y-6">
|
||||||
<form
|
<MetaFormInputs
|
||||||
onSubmit={handleSubmit}
|
url={url}
|
||||||
className="flex flex-col sm:flex-row items-center gap-3"
|
setUrl={setUrl}
|
||||||
>
|
keyword={keyword}
|
||||||
<div className="relative w-full">
|
setKeyword={setKeyword}
|
||||||
<Globe className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
|
handleSubmit={handleSubmit}
|
||||||
<Input
|
handleClear={handleClear}
|
||||||
name="url"
|
loading={loading}
|
||||||
type="text"
|
showClearButton={!!(url || metaData || error)}
|
||||||
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>
|
|
||||||
{(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 />}
|
{loading && <ResultsSkeleton />}
|
||||||
|
|
||||||
@@ -270,288 +196,20 @@ export function MetaForm() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="analysis">
|
<TabsContent value="analysis">
|
||||||
<Card className="w-full shadow-lg rounded-lg">
|
<AnalysisTab
|
||||||
<CardContent className="p-6">
|
metaData={metaData}
|
||||||
<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}
|
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>
|
</TabsContent>
|
||||||
|
|
||||||
{metaData.headlines && metaData.headlines.length > 0 && (
|
{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