@geenius/seo 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.changeset/config.json +11 -0
- package/.github/CODEOWNERS +1 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +16 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +11 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +10 -0
- package/.github/dependabot.yml +11 -0
- package/.github/workflows/ci.yml +23 -0
- package/.github/workflows/release.yml +29 -0
- package/.nvmrc +1 -0
- package/.project/ACCOUNT.yaml +4 -0
- package/.project/IDEAS.yaml +7 -0
- package/.project/PROJECT.yaml +11 -0
- package/.project/ROADMAP.yaml +15 -0
- package/CHANGELOG.md +8 -0
- package/CODE_OF_CONDUCT.md +16 -0
- package/CONTRIBUTING.md +26 -0
- package/LICENSE +21 -0
- package/README.md +1 -0
- package/SECURITY.md +15 -0
- package/SUPPORT.md +8 -0
- package/package.json +75 -0
- package/packages/convex/package.json +42 -0
- package/packages/convex/src/functions.ts +5 -0
- package/packages/convex/src/mutations.ts +83 -0
- package/packages/convex/src/queries.ts +57 -0
- package/packages/convex/src/schema.ts +23 -0
- package/packages/convex/tsconfig.json +19 -0
- package/packages/convex/tsup.config.ts +18 -0
- package/packages/react/README.md +1 -0
- package/packages/react/package.json +49 -0
- package/packages/react/src/components/ArticleJsonLd.tsx +42 -0
- package/packages/react/src/components/BreadcrumbsJsonLd.tsx +24 -0
- package/packages/react/src/components/MetaEditor.tsx +147 -0
- package/packages/react/src/components/SEOHead.tsx +107 -0
- package/packages/react/src/components/SEOPreview.tsx +42 -0
- package/packages/react/src/components/SEOScoreCard.tsx +51 -0
- package/packages/react/src/components/SitemapViewer.tsx +36 -0
- package/packages/react/src/components/index.ts +7 -0
- package/packages/react/src/hooks/index.ts +4 -0
- package/packages/react/src/hooks/useSEO.ts +27 -0
- package/packages/react/src/hooks/useSEOAdmin.ts +42 -0
- package/packages/react/src/hooks/useSEOScore.ts +7 -0
- package/packages/react/src/hooks/useSitemap.ts +8 -0
- package/packages/react/src/index.ts +51 -0
- package/packages/react/src/index.tsx +11 -0
- package/packages/react/src/pages/SEOAdminPage.tsx +101 -0
- package/packages/react/src/pages/SEOAnalyticsPage.tsx +96 -0
- package/packages/react/src/pages/index.ts +2 -0
- package/packages/react/tsconfig.json +19 -0
- package/packages/react/tsup.config.ts +12 -0
- package/packages/react-css/README.md +1 -0
- package/packages/react-css/package.json +36 -0
- package/packages/react-css/src/components/ArticleJsonLd.tsx +42 -0
- package/packages/react-css/src/components/BreadcrumbsJsonLd.tsx +24 -0
- package/packages/react-css/src/components/MetaEditor.tsx +147 -0
- package/packages/react-css/src/components/SEOHead.tsx +95 -0
- package/packages/react-css/src/components/SEOPreview.tsx +42 -0
- package/packages/react-css/src/components/SEOScoreCard.tsx +42 -0
- package/packages/react-css/src/components/SitemapViewer.tsx +36 -0
- package/packages/react-css/src/components/index.ts +7 -0
- package/packages/react-css/src/index.ts +9 -0
- package/packages/react-css/src/pages/SEOAdminPage.tsx +88 -0
- package/packages/react-css/src/pages/SEOAnalyticsPage.tsx +82 -0
- package/packages/react-css/src/pages/index.ts +2 -0
- package/packages/react-css/src/seo.css +650 -0
- package/packages/react-css/tsup.config.ts +2 -0
- package/packages/shared/README.md +1 -0
- package/packages/shared/package.json +42 -0
- package/packages/shared/src/__tests__/seo.test.ts +70 -0
- package/packages/shared/src/config.ts +297 -0
- package/packages/shared/src/index.ts +207 -0
- package/packages/shared/tsconfig.json +18 -0
- package/packages/shared/tsup.config.ts +11 -0
- package/packages/shared/vitest.config.ts +4 -0
- package/packages/solidjs/README.md +1 -0
- package/packages/solidjs/package.json +45 -0
- package/packages/solidjs/src/components/ArticleJsonLd.tsx +35 -0
- package/packages/solidjs/src/components/BreadcrumbsJsonLd.tsx +24 -0
- package/packages/solidjs/src/components/MetaEditor.tsx +155 -0
- package/packages/solidjs/src/components/SEOHead.tsx +109 -0
- package/packages/solidjs/src/components/SEOPreview.tsx +42 -0
- package/packages/solidjs/src/components/SEOScoreCard.tsx +57 -0
- package/packages/solidjs/src/components/SitemapViewer.tsx +44 -0
- package/packages/solidjs/src/components/index.ts +7 -0
- package/packages/solidjs/src/index.ts +11 -0
- package/packages/solidjs/src/pages/SEOAdminPage.tsx +104 -0
- package/packages/solidjs/src/pages/SEOAnalyticsPage.tsx +102 -0
- package/packages/solidjs/src/pages/index.ts +2 -0
- package/packages/solidjs/src/primitives/index.ts +4 -0
- package/packages/solidjs/src/primitives/useSEO.ts +27 -0
- package/packages/solidjs/src/primitives/useSEOAdmin.ts +42 -0
- package/packages/solidjs/src/primitives/useSEOScore.ts +7 -0
- package/packages/solidjs/src/primitives/useSitemap.ts +8 -0
- package/packages/solidjs/tsconfig.json +20 -0
- package/packages/solidjs/tsup.config.ts +12 -0
- package/packages/solidjs-css/README.md +1 -0
- package/packages/solidjs-css/package.json +35 -0
- package/packages/solidjs-css/src/index.ts +5 -0
- package/packages/solidjs-css/src/primitives/index.ts +1 -0
- package/packages/solidjs-css/src/seo.css +650 -0
- package/packages/solidjs-css/tsup.config.ts +2 -0
- package/pnpm-workspace.yaml +2 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createEffect } from 'solid-js'
|
|
2
|
+
import { breadcrumbSchema } from '@geenius-seo/shared'
|
|
3
|
+
|
|
4
|
+
interface BreadcrumbsJsonLdProps {
|
|
5
|
+
items: {
|
|
6
|
+
name: string
|
|
7
|
+
url: string
|
|
8
|
+
}[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function BreadcrumbsJsonLd(props: BreadcrumbsJsonLdProps) {
|
|
12
|
+
createEffect(() => {
|
|
13
|
+
const script = document.createElement('script')
|
|
14
|
+
script.type = 'application/ld+json'
|
|
15
|
+
script.textContent = JSON.stringify(breadcrumbSchema(props.items))
|
|
16
|
+
document.head.appendChild(script)
|
|
17
|
+
|
|
18
|
+
return () => {
|
|
19
|
+
document.head.removeChild(script)
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { createSignal } from 'solid-js'
|
|
2
|
+
import type { SEOMeta } from '@geenius-seo/shared'
|
|
3
|
+
|
|
4
|
+
interface MetaEditorProps {
|
|
5
|
+
meta: SEOMeta
|
|
6
|
+
onChange: (meta: SEOMeta) => void
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function MetaEditor(props: MetaEditorProps) {
|
|
10
|
+
const [localMeta, setLocalMeta] = createSignal(props.meta)
|
|
11
|
+
|
|
12
|
+
const handleChange = (updates: Partial<SEOMeta>) => {
|
|
13
|
+
const updated = { ...localMeta(), ...updates }
|
|
14
|
+
setLocalMeta(updated)
|
|
15
|
+
props.onChange(updated)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div class="space-y-6 p-4 border border-gray-200 rounded">
|
|
20
|
+
{/* Title */}
|
|
21
|
+
<div>
|
|
22
|
+
<label class="block text-sm font-semibold text-gray-900 mb-1">
|
|
23
|
+
Title {localMeta().title.length}/60
|
|
24
|
+
</label>
|
|
25
|
+
<input
|
|
26
|
+
type="text"
|
|
27
|
+
value={localMeta().title}
|
|
28
|
+
onInput={(e) =>
|
|
29
|
+
handleChange({
|
|
30
|
+
title: e.currentTarget.value.slice(0, 60),
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
class="w-full px-3 py-2 border border-gray-300 rounded text-sm"
|
|
34
|
+
placeholder="Page title"
|
|
35
|
+
/>
|
|
36
|
+
<p class="text-xs text-gray-500 mt-1">
|
|
37
|
+
{localMeta().title.length < 30
|
|
38
|
+
? 'Too short'
|
|
39
|
+
: localMeta().title.length > 60
|
|
40
|
+
? 'Too long'
|
|
41
|
+
: 'Good length'}
|
|
42
|
+
</p>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
{/* Description */}
|
|
46
|
+
<div>
|
|
47
|
+
<label class="block text-sm font-semibold text-gray-900 mb-1">
|
|
48
|
+
Description {localMeta().description.length}/160
|
|
49
|
+
</label>
|
|
50
|
+
<textarea
|
|
51
|
+
value={localMeta().description}
|
|
52
|
+
onInput={(e) =>
|
|
53
|
+
handleChange({
|
|
54
|
+
description: e.currentTarget.value.slice(0, 160),
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
class="w-full px-3 py-2 border border-gray-300 rounded text-sm"
|
|
58
|
+
rows={3}
|
|
59
|
+
placeholder="Page description"
|
|
60
|
+
/>
|
|
61
|
+
<p class="text-xs text-gray-500 mt-1">
|
|
62
|
+
{localMeta().description.length < 50
|
|
63
|
+
? 'Too short'
|
|
64
|
+
: localMeta().description.length > 160
|
|
65
|
+
? 'Too long'
|
|
66
|
+
: 'Good length'}
|
|
67
|
+
</p>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
{/* Keywords */}
|
|
71
|
+
<div>
|
|
72
|
+
<label class="block text-sm font-semibold text-gray-900 mb-1">Keywords</label>
|
|
73
|
+
<input
|
|
74
|
+
type="text"
|
|
75
|
+
value={localMeta().keywords.join(', ')}
|
|
76
|
+
onInput={(e) =>
|
|
77
|
+
handleChange({
|
|
78
|
+
keywords: e.currentTarget.value.split(',').map((k) => k.trim()),
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
class="w-full px-3 py-2 border border-gray-300 rounded text-sm"
|
|
82
|
+
placeholder="keyword1, keyword2, keyword3"
|
|
83
|
+
/>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
{/* Canonical */}
|
|
87
|
+
<div>
|
|
88
|
+
<label class="block text-sm font-semibold text-gray-900 mb-1">Canonical URL</label>
|
|
89
|
+
<input
|
|
90
|
+
type="url"
|
|
91
|
+
value={localMeta().canonical || ''}
|
|
92
|
+
onInput={(e) =>
|
|
93
|
+
handleChange({
|
|
94
|
+
canonical: e.currentTarget.value,
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
class="w-full px-3 py-2 border border-gray-300 rounded text-sm"
|
|
98
|
+
placeholder="https://example.com/page"
|
|
99
|
+
/>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
{/* OG Image */}
|
|
103
|
+
<div>
|
|
104
|
+
<label class="block text-sm font-semibold text-gray-900 mb-1">OG Image URL</label>
|
|
105
|
+
<input
|
|
106
|
+
type="url"
|
|
107
|
+
value={localMeta().og.image || ''}
|
|
108
|
+
onInput={(e) =>
|
|
109
|
+
handleChange({
|
|
110
|
+
og: { ...localMeta().og, image: e.currentTarget.value },
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
class="w-full px-3 py-2 border border-gray-300 rounded text-sm"
|
|
114
|
+
placeholder="https://example.com/image.jpg"
|
|
115
|
+
/>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
{/* Twitter Card */}
|
|
119
|
+
<div>
|
|
120
|
+
<label class="block text-sm font-semibold text-gray-900 mb-1">Twitter Card Type</label>
|
|
121
|
+
<select
|
|
122
|
+
value={localMeta().twitter.card}
|
|
123
|
+
onChange={(e) =>
|
|
124
|
+
handleChange({
|
|
125
|
+
twitter: {
|
|
126
|
+
...localMeta().twitter,
|
|
127
|
+
card: e.currentTarget.value as 'summary' | 'summary_large_image',
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
class="w-full px-3 py-2 border border-gray-300 rounded text-sm"
|
|
132
|
+
>
|
|
133
|
+
<option value="summary">Summary</option>
|
|
134
|
+
<option value="summary_large_image">Summary Large Image</option>
|
|
135
|
+
</select>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
{/* Robots */}
|
|
139
|
+
<div>
|
|
140
|
+
<label class="block text-sm font-semibold text-gray-900 mb-1">Robots Directive</label>
|
|
141
|
+
<input
|
|
142
|
+
type="text"
|
|
143
|
+
value={localMeta().robots || ''}
|
|
144
|
+
onInput={(e) =>
|
|
145
|
+
handleChange({
|
|
146
|
+
robots: e.currentTarget.value,
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
class="w-full px-3 py-2 border border-gray-300 rounded text-sm"
|
|
150
|
+
placeholder="index, follow"
|
|
151
|
+
/>
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { createEffect } from 'solid-js'
|
|
2
|
+
import type { SEOMeta } from '@geenius-seo/shared'
|
|
3
|
+
|
|
4
|
+
interface SEOHeadProps {
|
|
5
|
+
meta: SEOMeta
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function SEOHead(props: SEOHeadProps) {
|
|
9
|
+
createEffect(() => {
|
|
10
|
+
const meta = props.meta
|
|
11
|
+
|
|
12
|
+
// Set title
|
|
13
|
+
document.title = meta.title
|
|
14
|
+
|
|
15
|
+
// Set meta tags
|
|
16
|
+
const setMetaTag = (name: string, content: string, isProperty = false) => {
|
|
17
|
+
let tag = document.querySelector(
|
|
18
|
+
`meta[${isProperty ? 'property' : 'name'}="${name}"]`,
|
|
19
|
+
) as HTMLMetaElement
|
|
20
|
+
if (!tag) {
|
|
21
|
+
tag = document.createElement('meta')
|
|
22
|
+
isProperty ? tag.setAttribute('property', name) : tag.setAttribute('name', name)
|
|
23
|
+
document.head.appendChild(tag)
|
|
24
|
+
}
|
|
25
|
+
tag.content = content
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Description
|
|
29
|
+
setMetaTag('description', meta.description)
|
|
30
|
+
|
|
31
|
+
// Keywords
|
|
32
|
+
if (meta.keywords.length > 0) {
|
|
33
|
+
setMetaTag('keywords', meta.keywords.join(', '))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Robots
|
|
37
|
+
if (meta.robots) {
|
|
38
|
+
setMetaTag('robots', meta.robots)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// OG tags
|
|
42
|
+
setMetaTag('og:title', meta.og.title, true)
|
|
43
|
+
setMetaTag('og:description', meta.og.description, true)
|
|
44
|
+
if (meta.og.image) {
|
|
45
|
+
setMetaTag('og:image', meta.og.image, true)
|
|
46
|
+
if (meta.og.imageAlt) {
|
|
47
|
+
setMetaTag('og:image:alt', meta.og.imageAlt, true)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
setMetaTag('og:type', meta.og.type, true)
|
|
51
|
+
setMetaTag('og:url', meta.og.url, true)
|
|
52
|
+
if (meta.og.siteName) {
|
|
53
|
+
setMetaTag('og:site_name', meta.og.siteName, true)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Twitter tags
|
|
57
|
+
setMetaTag('twitter:card', meta.twitter.card)
|
|
58
|
+
setMetaTag('twitter:title', meta.twitter.title)
|
|
59
|
+
setMetaTag('twitter:description', meta.twitter.description)
|
|
60
|
+
if (meta.twitter.image) {
|
|
61
|
+
setMetaTag('twitter:image', meta.twitter.image)
|
|
62
|
+
}
|
|
63
|
+
if (meta.twitter.creator) {
|
|
64
|
+
setMetaTag('twitter:creator', meta.twitter.creator)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Canonical
|
|
68
|
+
if (meta.canonical) {
|
|
69
|
+
let canonicalLink = document.querySelector('link[rel="canonical"]') as HTMLLinkElement
|
|
70
|
+
if (!canonicalLink) {
|
|
71
|
+
canonicalLink = document.createElement('link')
|
|
72
|
+
canonicalLink.rel = 'canonical'
|
|
73
|
+
document.head.appendChild(canonicalLink)
|
|
74
|
+
}
|
|
75
|
+
canonicalLink.href = meta.canonical
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Alternates
|
|
79
|
+
if (meta.alternates) {
|
|
80
|
+
Object.entries(meta.alternates).forEach(([lang, url]) => {
|
|
81
|
+
let altLink = document.querySelector(
|
|
82
|
+
`link[rel="alternate"][hreflang="${lang}"]`,
|
|
83
|
+
) as HTMLLinkElement
|
|
84
|
+
if (!altLink) {
|
|
85
|
+
altLink = document.createElement('link')
|
|
86
|
+
altLink.rel = 'alternate'
|
|
87
|
+
altLink.hrefLang = lang
|
|
88
|
+
document.head.appendChild(altLink)
|
|
89
|
+
}
|
|
90
|
+
altLink.href = url
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// JSON-LD
|
|
95
|
+
if (meta.jsonLd && meta.jsonLd.length > 0) {
|
|
96
|
+
meta.jsonLd.forEach((schema) => {
|
|
97
|
+
let script = document.querySelector('script[type="application/ld+json"]') as HTMLScriptElement
|
|
98
|
+
if (!script) {
|
|
99
|
+
script = document.createElement('script')
|
|
100
|
+
script.type = 'application/ld+json'
|
|
101
|
+
document.head.appendChild(script)
|
|
102
|
+
}
|
|
103
|
+
script.textContent = JSON.stringify(schema)
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
return null
|
|
109
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { SEOMeta } from '@geenius-seo/shared'
|
|
2
|
+
|
|
3
|
+
interface SEOPreviewProps {
|
|
4
|
+
meta: SEOMeta
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function SEOPreview(props: SEOPreviewProps) {
|
|
8
|
+
return (
|
|
9
|
+
<div class="space-y-6">
|
|
10
|
+
{/* Google SERP Preview */}
|
|
11
|
+
<div class="space-y-2">
|
|
12
|
+
<h3 class="text-sm font-semibold text-gray-700">Google Search Preview</h3>
|
|
13
|
+
<div class="bg-white p-4 rounded border border-gray-200">
|
|
14
|
+
<div class="text-xs text-green-700">{new URL(props.meta.og.url).hostname}</div>
|
|
15
|
+
<div class="text-lg text-blue-600 hover:underline cursor-pointer truncate">
|
|
16
|
+
{props.meta.title}
|
|
17
|
+
</div>
|
|
18
|
+
<div class="text-sm text-gray-600 line-clamp-2">{props.meta.description}</div>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
{/* Social Media Preview */}
|
|
23
|
+
<div class="space-y-2">
|
|
24
|
+
<h3 class="text-sm font-semibold text-gray-700">Social Media Preview</h3>
|
|
25
|
+
<div class="bg-gray-900 rounded overflow-hidden max-w-sm">
|
|
26
|
+
{props.meta.og.image && (
|
|
27
|
+
<img
|
|
28
|
+
src={props.meta.og.image}
|
|
29
|
+
alt={props.meta.og.imageAlt || props.meta.og.title}
|
|
30
|
+
class="w-full h-48 object-cover"
|
|
31
|
+
/>
|
|
32
|
+
)}
|
|
33
|
+
<div class="p-3 text-white">
|
|
34
|
+
<div class="font-semibold text-sm truncate">{props.meta.og.title}</div>
|
|
35
|
+
<div class="text-xs text-gray-400 line-clamp-2">{props.meta.og.description}</div>
|
|
36
|
+
<div class="text-xs text-gray-500 mt-1 truncate">{props.meta.og.url}</div>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createMemo } from 'solid-js'
|
|
2
|
+
import { useSEOScore } from '../primitives/useSEOScore'
|
|
3
|
+
import type { SEOMeta } from '@geenius-seo/shared'
|
|
4
|
+
|
|
5
|
+
interface SEOScoreCardProps {
|
|
6
|
+
meta: SEOMeta
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function SEOScoreCard(props: SEOScoreCardProps) {
|
|
10
|
+
const scoreResult = useSEOScore(() => props.meta)
|
|
11
|
+
|
|
12
|
+
const getScoreColor = createMemo(() => {
|
|
13
|
+
const score = scoreResult().score
|
|
14
|
+
if (score >= 80) return 'text-green-600'
|
|
15
|
+
if (score >= 50) return 'text-yellow-600'
|
|
16
|
+
return 'text-red-600'
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const getBarColor = createMemo(() => {
|
|
20
|
+
const score = scoreResult().score
|
|
21
|
+
if (score >= 80) return 'bg-green-500'
|
|
22
|
+
if (score >= 50) return 'bg-yellow-500'
|
|
23
|
+
return 'bg-red-500'
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div class="space-y-4 p-4 border border-gray-200 rounded">
|
|
28
|
+
<div class="flex items-center justify-between">
|
|
29
|
+
<h3 class="font-semibold text-gray-900">SEO Score</h3>
|
|
30
|
+
<span class={`text-2xl font-bold ${getScoreColor()}`}>{scoreResult().score}/100</span>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<div class="space-y-2">
|
|
34
|
+
<div class="h-2 bg-gray-200 rounded overflow-hidden">
|
|
35
|
+
<div
|
|
36
|
+
class={`h-full ${getBarColor()} transition-all`}
|
|
37
|
+
style={{ width: `${scoreResult().score}%` }}
|
|
38
|
+
/>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
{scoreResult().issues.length > 0 && (
|
|
43
|
+
<div class="space-y-2">
|
|
44
|
+
<h4 class="text-sm font-semibold text-gray-700">Issues Found</h4>
|
|
45
|
+
<ul class="space-y-1">
|
|
46
|
+
{scoreResult().issues.map((issue, i) => (
|
|
47
|
+
<li key={i} class="text-sm text-red-600 flex items-start">
|
|
48
|
+
<span class="mr-2">•</span>
|
|
49
|
+
{issue}
|
|
50
|
+
</li>
|
|
51
|
+
))}
|
|
52
|
+
</ul>
|
|
53
|
+
</div>
|
|
54
|
+
)}
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { For } from 'solid-js'
|
|
2
|
+
import type { SitemapEntry } from '@geenius-seo/shared'
|
|
3
|
+
|
|
4
|
+
interface SitemapViewerProps {
|
|
5
|
+
entries: SitemapEntry[]
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function SitemapViewer(props: SitemapViewerProps) {
|
|
9
|
+
return (
|
|
10
|
+
<div class="overflow-x-auto">
|
|
11
|
+
<table class="w-full text-sm">
|
|
12
|
+
<thead class="bg-gray-100 border-b">
|
|
13
|
+
<tr>
|
|
14
|
+
<th class="text-left p-3">URL</th>
|
|
15
|
+
<th class="text-left p-3">Last Modified</th>
|
|
16
|
+
<th class="text-left p-3">Change Frequency</th>
|
|
17
|
+
<th class="text-left p-3">Priority</th>
|
|
18
|
+
</tr>
|
|
19
|
+
</thead>
|
|
20
|
+
<tbody>
|
|
21
|
+
<For each={props.entries}>
|
|
22
|
+
{(entry, i) => (
|
|
23
|
+
<tr class="border-b hover:bg-gray-50">
|
|
24
|
+
<td class="p-3 font-mono text-xs text-blue-600 break-all">
|
|
25
|
+
<a
|
|
26
|
+
href={entry.url}
|
|
27
|
+
target="_blank"
|
|
28
|
+
rel="noopener noreferrer"
|
|
29
|
+
class="hover:underline"
|
|
30
|
+
>
|
|
31
|
+
{entry.url}
|
|
32
|
+
</a>
|
|
33
|
+
</td>
|
|
34
|
+
<td class="p-3 text-gray-600">{entry.lastmod || '-'}</td>
|
|
35
|
+
<td class="p-3 text-gray-600">{entry.changefreq || '-'}</td>
|
|
36
|
+
<td class="p-3 text-gray-600">{entry.priority || '-'}</td>
|
|
37
|
+
</tr>
|
|
38
|
+
)}
|
|
39
|
+
</For>
|
|
40
|
+
</tbody>
|
|
41
|
+
</table>
|
|
42
|
+
</div>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createSignal, For, Show } from 'solid-js'
|
|
2
|
+
import { useSEOAdmin } from '../primitives/useSEOAdmin'
|
|
3
|
+
import { MetaEditor } from '../components/MetaEditor'
|
|
4
|
+
import { SEOPreview } from '../components/SEOPreview'
|
|
5
|
+
import { SEOScoreCard } from '../components/SEOScoreCard'
|
|
6
|
+
import type { SEOMeta } from '@geenius-seo/shared'
|
|
7
|
+
|
|
8
|
+
export function SEOAdminPage() {
|
|
9
|
+
const { pages, upsertMeta, deleteMeta, isLoading } = useSEOAdmin()
|
|
10
|
+
const [selectedPath, setSelectedPath] = createSignal<string | null>(null)
|
|
11
|
+
const [editingMeta, setEditingMeta] = createSignal<SEOMeta | null>(null)
|
|
12
|
+
|
|
13
|
+
const currentPage = () => pages().find((p) => p.path === selectedPath())
|
|
14
|
+
|
|
15
|
+
const handleSave = async () => {
|
|
16
|
+
const path = selectedPath()
|
|
17
|
+
const meta = editingMeta()
|
|
18
|
+
if (path && meta) {
|
|
19
|
+
await upsertMeta(path, meta)
|
|
20
|
+
setEditingMeta(null)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const handleDelete = async () => {
|
|
25
|
+
const path = selectedPath()
|
|
26
|
+
if (path) {
|
|
27
|
+
await deleteMeta(path)
|
|
28
|
+
setSelectedPath(null)
|
|
29
|
+
setEditingMeta(null)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<Show fallback={<div class="p-4">Loading...</div>} when={!isLoading()}>
|
|
35
|
+
<div class="max-w-6xl mx-auto p-6">
|
|
36
|
+
<h1 class="text-3xl font-bold mb-6">SEO Admin</h1>
|
|
37
|
+
|
|
38
|
+
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
|
39
|
+
{/* Pages List */}
|
|
40
|
+
<div class="lg:col-span-1 border border-gray-200 rounded p-4">
|
|
41
|
+
<h2 class="font-semibold mb-4">Pages</h2>
|
|
42
|
+
<div class="space-y-2">
|
|
43
|
+
<For each={pages()}>
|
|
44
|
+
{(page) => (
|
|
45
|
+
<button
|
|
46
|
+
onClick={() => {
|
|
47
|
+
setSelectedPath(page.path)
|
|
48
|
+
setEditingMeta(page.meta)
|
|
49
|
+
}}
|
|
50
|
+
class={`w-full text-left p-2 rounded text-sm transition ${
|
|
51
|
+
selectedPath() === page.path
|
|
52
|
+
? 'bg-blue-100 text-blue-900'
|
|
53
|
+
: 'hover:bg-gray-100'
|
|
54
|
+
}`}
|
|
55
|
+
>
|
|
56
|
+
{page.path || '/'}
|
|
57
|
+
</button>
|
|
58
|
+
)}
|
|
59
|
+
</For>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
{/* Editor and Preview */}
|
|
64
|
+
<Show when={currentPage() && editingMeta()}>
|
|
65
|
+
{(page) => (
|
|
66
|
+
<div class="lg:col-span-3 space-y-6">
|
|
67
|
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
68
|
+
<div>
|
|
69
|
+
<h2 class="font-semibold mb-4">Edit Metadata</h2>
|
|
70
|
+
<MetaEditor meta={editingMeta()!} onChange={setEditingMeta} />
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<div class="space-y-6">
|
|
74
|
+
<div>
|
|
75
|
+
<h2 class="font-semibold mb-4">Preview</h2>
|
|
76
|
+
<SEOPreview meta={editingMeta()!} />
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<SEOScoreCard meta={editingMeta()!} />
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<div class="flex gap-2">
|
|
84
|
+
<button
|
|
85
|
+
onClick={handleSave}
|
|
86
|
+
class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
|
|
87
|
+
>
|
|
88
|
+
Save
|
|
89
|
+
</button>
|
|
90
|
+
<button
|
|
91
|
+
onClick={handleDelete}
|
|
92
|
+
class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
|
|
93
|
+
>
|
|
94
|
+
Delete
|
|
95
|
+
</button>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
</Show>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
</Show>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { createSignal, createEffect, For, Show } from 'solid-js'
|
|
2
|
+
|
|
3
|
+
interface AnalyticsSummary {
|
|
4
|
+
path: string
|
|
5
|
+
views: number
|
|
6
|
+
avgTimeOnPage: number
|
|
7
|
+
bounceRate: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function SEOAnalyticsPage() {
|
|
11
|
+
const [topPages, setTopPages] = createSignal<AnalyticsSummary[]>([])
|
|
12
|
+
const [isLoading, setIsLoading] = createSignal(false)
|
|
13
|
+
|
|
14
|
+
createEffect(() => {
|
|
15
|
+
setIsLoading(true)
|
|
16
|
+
// Placeholder for Convex query integration
|
|
17
|
+
// const result = await client.query(api.queries.getTopPages, { limit: 10 })
|
|
18
|
+
// setTopPages(result)
|
|
19
|
+
setIsLoading(false)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<Show fallback={<div class="p-4">Loading...</div>} when={!isLoading()}>
|
|
24
|
+
<div class="max-w-6xl mx-auto p-6">
|
|
25
|
+
<h1 class="text-3xl font-bold mb-6">SEO Analytics</h1>
|
|
26
|
+
|
|
27
|
+
<div class="space-y-6">
|
|
28
|
+
{/* Top Pages */}
|
|
29
|
+
<div class="border border-gray-200 rounded p-4">
|
|
30
|
+
<h2 class="font-semibold mb-4">Top Pages by Views</h2>
|
|
31
|
+
|
|
32
|
+
<div class="overflow-x-auto">
|
|
33
|
+
<table class="w-full text-sm">
|
|
34
|
+
<thead class="bg-gray-100 border-b">
|
|
35
|
+
<tr>
|
|
36
|
+
<th class="text-left p-3">Page</th>
|
|
37
|
+
<th class="text-right p-3">Views</th>
|
|
38
|
+
<th class="text-right p-3">Avg Time (s)</th>
|
|
39
|
+
<th class="text-right p-3">Bounce Rate</th>
|
|
40
|
+
</tr>
|
|
41
|
+
</thead>
|
|
42
|
+
<tbody>
|
|
43
|
+
<For each={topPages()}>
|
|
44
|
+
{(page, i) => (
|
|
45
|
+
<tr class="border-b hover:bg-gray-50">
|
|
46
|
+
<td class="p-3 font-mono text-xs">{page.path}</td>
|
|
47
|
+
<td class="p-3 text-right">{page.views.toLocaleString()}</td>
|
|
48
|
+
<td class="p-3 text-right">{page.avgTimeOnPage.toFixed(1)}</td>
|
|
49
|
+
<td class="p-3 text-right">{(page.bounceRate * 100).toFixed(1)}%</td>
|
|
50
|
+
</tr>
|
|
51
|
+
)}
|
|
52
|
+
</For>
|
|
53
|
+
</tbody>
|
|
54
|
+
</table>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<Show when={topPages().length === 0}>
|
|
58
|
+
<p class="text-center text-gray-500 py-8">No analytics data available</p>
|
|
59
|
+
</Show>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
{/* Analytics Summary */}
|
|
63
|
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
64
|
+
<div class="border border-gray-200 rounded p-4">
|
|
65
|
+
<h3 class="text-sm text-gray-600 mb-1">Total Views</h3>
|
|
66
|
+
<p class="text-2xl font-bold">
|
|
67
|
+
{topPages()
|
|
68
|
+
.reduce((sum, p) => sum + p.views, 0)
|
|
69
|
+
.toLocaleString()}
|
|
70
|
+
</p>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<div class="border border-gray-200 rounded p-4">
|
|
74
|
+
<h3 class="text-sm text-gray-600 mb-1">Average Time on Page</h3>
|
|
75
|
+
<p class="text-2xl font-bold">
|
|
76
|
+
{topPages().length > 0
|
|
77
|
+
? (
|
|
78
|
+
topPages().reduce((sum, p) => sum + p.avgTimeOnPage, 0) / topPages().length
|
|
79
|
+
).toFixed(1)
|
|
80
|
+
: '0'}
|
|
81
|
+
s
|
|
82
|
+
</p>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<div class="border border-gray-200 rounded p-4">
|
|
86
|
+
<h3 class="text-sm text-gray-600 mb-1">Average Bounce Rate</h3>
|
|
87
|
+
<p class="text-2xl font-bold">
|
|
88
|
+
{topPages().length > 0
|
|
89
|
+
? (
|
|
90
|
+
(topPages().reduce((sum, p) => sum + p.bounceRate, 0) / topPages().length) *
|
|
91
|
+
100
|
|
92
|
+
).toFixed(1)
|
|
93
|
+
: '0'}
|
|
94
|
+
%
|
|
95
|
+
</p>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</Show>
|
|
101
|
+
)
|
|
102
|
+
}
|