gitea +
This commit is contained in:
101
frontend/components/seo-ranking-card.tsx
Normal file
101
frontend/components/seo-ranking-card.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
|
||||
'use client'
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { monitorAPI } from '@/lib/api'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Sparkline } from '@/components/sparkline'
|
||||
|
||||
interface Props {
|
||||
monitorId: string
|
||||
keywords: string[]
|
||||
}
|
||||
|
||||
export function SEORankingCard({ monitorId, keywords }: Props) {
|
||||
const { data: rankings, isLoading } = useQuery({
|
||||
queryKey: ['rankings', monitorId],
|
||||
queryFn: async () => {
|
||||
const response = await monitorAPI.rankings(monitorId)
|
||||
return response // { history: [], latest: [] }
|
||||
}
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-6 flex justify-center">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const { latest = [], history = [] } = rankings || {}
|
||||
|
||||
// Group history by keyword for sparklines
|
||||
const historyByKeyword = (history as any[]).reduce((acc, item) => {
|
||||
if (!acc[item.keyword]) acc[item.keyword] = []
|
||||
acc[item.keyword].push(item)
|
||||
return acc
|
||||
}, {} as Record<string, any[]>)
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{keywords.map(keyword => {
|
||||
const latestRank = latest.find((r: any) => r.keyword === keyword)
|
||||
const keywordHistory = historyByKeyword[keyword] || []
|
||||
// Sort history by date asc for sparkline
|
||||
const rankHistory = keywordHistory
|
||||
.sort((a: any, b: any) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||
.map((item: any) => item.rank || 101) // Use 101 for unranked
|
||||
|
||||
return (
|
||||
<Card key={keyword} className="overflow-hidden">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium flex justify-between items-start">
|
||||
<span className="truncate pr-2" title={keyword}>{keyword}</span>
|
||||
{latestRank?.rank ? (
|
||||
<Badge variant={latestRank.rank <= 3 ? 'success' : latestRank.rank <= 10 ? 'default' : 'secondary'}>
|
||||
#{latestRank.rank}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
Not found
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xs text-muted-foreground mb-3">
|
||||
{latestRank?.urlFound ? (
|
||||
<a href={latestRank.urlFound} target="_blank" rel="noopener noreferrer" className="hover:underline truncate block">
|
||||
{new URL(latestRank.urlFound).pathname}
|
||||
</a>
|
||||
) : (
|
||||
<span>Not in top 100</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rankHistory.length > 1 && (
|
||||
<div className="h-10 w-full mt-2">
|
||||
{/* Simple visualization if Sparkline component accepts array */}
|
||||
<Sparkline
|
||||
data={rankHistory}
|
||||
color={latestRank?.rank ? "#8b5cf6" : "#cbd5e1"}
|
||||
height={40}
|
||||
width={100}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 text-[10px] text-muted-foreground text-right">
|
||||
Last checked: {latestRank ? new Date(latestRank.createdAt).toLocaleDateString() : 'Never'}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user