[dyad] Refactored the meta form component - wrote 4 file(s)

This commit is contained in:
[dyad]
2026-01-20 16:09:56 +01:00
parent 1ab5727979
commit 6a64531739
4 changed files with 450 additions and 371 deletions

View 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>
);
}

View 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>
);
}

View File

@@ -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
View 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;
}