@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.
Files changed (103) hide show
  1. package/.changeset/config.json +11 -0
  2. package/.github/CODEOWNERS +1 -0
  3. package/.github/ISSUE_TEMPLATE/bug_report.md +16 -0
  4. package/.github/ISSUE_TEMPLATE/feature_request.md +11 -0
  5. package/.github/PULL_REQUEST_TEMPLATE.md +10 -0
  6. package/.github/dependabot.yml +11 -0
  7. package/.github/workflows/ci.yml +23 -0
  8. package/.github/workflows/release.yml +29 -0
  9. package/.nvmrc +1 -0
  10. package/.project/ACCOUNT.yaml +4 -0
  11. package/.project/IDEAS.yaml +7 -0
  12. package/.project/PROJECT.yaml +11 -0
  13. package/.project/ROADMAP.yaml +15 -0
  14. package/CHANGELOG.md +8 -0
  15. package/CODE_OF_CONDUCT.md +16 -0
  16. package/CONTRIBUTING.md +26 -0
  17. package/LICENSE +21 -0
  18. package/README.md +1 -0
  19. package/SECURITY.md +15 -0
  20. package/SUPPORT.md +8 -0
  21. package/package.json +75 -0
  22. package/packages/convex/package.json +42 -0
  23. package/packages/convex/src/functions.ts +5 -0
  24. package/packages/convex/src/mutations.ts +83 -0
  25. package/packages/convex/src/queries.ts +57 -0
  26. package/packages/convex/src/schema.ts +23 -0
  27. package/packages/convex/tsconfig.json +19 -0
  28. package/packages/convex/tsup.config.ts +18 -0
  29. package/packages/react/README.md +1 -0
  30. package/packages/react/package.json +49 -0
  31. package/packages/react/src/components/ArticleJsonLd.tsx +42 -0
  32. package/packages/react/src/components/BreadcrumbsJsonLd.tsx +24 -0
  33. package/packages/react/src/components/MetaEditor.tsx +147 -0
  34. package/packages/react/src/components/SEOHead.tsx +107 -0
  35. package/packages/react/src/components/SEOPreview.tsx +42 -0
  36. package/packages/react/src/components/SEOScoreCard.tsx +51 -0
  37. package/packages/react/src/components/SitemapViewer.tsx +36 -0
  38. package/packages/react/src/components/index.ts +7 -0
  39. package/packages/react/src/hooks/index.ts +4 -0
  40. package/packages/react/src/hooks/useSEO.ts +27 -0
  41. package/packages/react/src/hooks/useSEOAdmin.ts +42 -0
  42. package/packages/react/src/hooks/useSEOScore.ts +7 -0
  43. package/packages/react/src/hooks/useSitemap.ts +8 -0
  44. package/packages/react/src/index.ts +51 -0
  45. package/packages/react/src/index.tsx +11 -0
  46. package/packages/react/src/pages/SEOAdminPage.tsx +101 -0
  47. package/packages/react/src/pages/SEOAnalyticsPage.tsx +96 -0
  48. package/packages/react/src/pages/index.ts +2 -0
  49. package/packages/react/tsconfig.json +19 -0
  50. package/packages/react/tsup.config.ts +12 -0
  51. package/packages/react-css/README.md +1 -0
  52. package/packages/react-css/package.json +36 -0
  53. package/packages/react-css/src/components/ArticleJsonLd.tsx +42 -0
  54. package/packages/react-css/src/components/BreadcrumbsJsonLd.tsx +24 -0
  55. package/packages/react-css/src/components/MetaEditor.tsx +147 -0
  56. package/packages/react-css/src/components/SEOHead.tsx +95 -0
  57. package/packages/react-css/src/components/SEOPreview.tsx +42 -0
  58. package/packages/react-css/src/components/SEOScoreCard.tsx +42 -0
  59. package/packages/react-css/src/components/SitemapViewer.tsx +36 -0
  60. package/packages/react-css/src/components/index.ts +7 -0
  61. package/packages/react-css/src/index.ts +9 -0
  62. package/packages/react-css/src/pages/SEOAdminPage.tsx +88 -0
  63. package/packages/react-css/src/pages/SEOAnalyticsPage.tsx +82 -0
  64. package/packages/react-css/src/pages/index.ts +2 -0
  65. package/packages/react-css/src/seo.css +650 -0
  66. package/packages/react-css/tsup.config.ts +2 -0
  67. package/packages/shared/README.md +1 -0
  68. package/packages/shared/package.json +42 -0
  69. package/packages/shared/src/__tests__/seo.test.ts +70 -0
  70. package/packages/shared/src/config.ts +297 -0
  71. package/packages/shared/src/index.ts +207 -0
  72. package/packages/shared/tsconfig.json +18 -0
  73. package/packages/shared/tsup.config.ts +11 -0
  74. package/packages/shared/vitest.config.ts +4 -0
  75. package/packages/solidjs/README.md +1 -0
  76. package/packages/solidjs/package.json +45 -0
  77. package/packages/solidjs/src/components/ArticleJsonLd.tsx +35 -0
  78. package/packages/solidjs/src/components/BreadcrumbsJsonLd.tsx +24 -0
  79. package/packages/solidjs/src/components/MetaEditor.tsx +155 -0
  80. package/packages/solidjs/src/components/SEOHead.tsx +109 -0
  81. package/packages/solidjs/src/components/SEOPreview.tsx +42 -0
  82. package/packages/solidjs/src/components/SEOScoreCard.tsx +57 -0
  83. package/packages/solidjs/src/components/SitemapViewer.tsx +44 -0
  84. package/packages/solidjs/src/components/index.ts +7 -0
  85. package/packages/solidjs/src/index.ts +11 -0
  86. package/packages/solidjs/src/pages/SEOAdminPage.tsx +104 -0
  87. package/packages/solidjs/src/pages/SEOAnalyticsPage.tsx +102 -0
  88. package/packages/solidjs/src/pages/index.ts +2 -0
  89. package/packages/solidjs/src/primitives/index.ts +4 -0
  90. package/packages/solidjs/src/primitives/useSEO.ts +27 -0
  91. package/packages/solidjs/src/primitives/useSEOAdmin.ts +42 -0
  92. package/packages/solidjs/src/primitives/useSEOScore.ts +7 -0
  93. package/packages/solidjs/src/primitives/useSitemap.ts +8 -0
  94. package/packages/solidjs/tsconfig.json +20 -0
  95. package/packages/solidjs/tsup.config.ts +12 -0
  96. package/packages/solidjs-css/README.md +1 -0
  97. package/packages/solidjs-css/package.json +35 -0
  98. package/packages/solidjs-css/src/index.ts +5 -0
  99. package/packages/solidjs-css/src/primitives/index.ts +1 -0
  100. package/packages/solidjs-css/src/seo.css +650 -0
  101. package/packages/solidjs-css/tsup.config.ts +2 -0
  102. package/pnpm-workspace.yaml +2 -0
  103. 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,7 @@
1
+ export * from './SEOHead'
2
+ export * from './SEOPreview'
3
+ export * from './SEOScoreCard'
4
+ export * from './MetaEditor'
5
+ export * from './SitemapViewer'
6
+ export * from './BreadcrumbsJsonLd'
7
+ export * from './ArticleJsonLd'
@@ -0,0 +1,11 @@
1
+ // Shared types and utilities
2
+ export * from '@geenius-seo/shared'
3
+
4
+ // Primitives
5
+ export * from './primitives'
6
+
7
+ // Components
8
+ export * from './components'
9
+
10
+ // Pages
11
+ export * from './pages'
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ export * from './SEOAdminPage'
2
+ export * from './SEOAnalyticsPage'
@@ -0,0 +1,4 @@
1
+ export * from './useSEO'
2
+ export * from './useSEOAdmin'
3
+ export * from './useSEOScore'
4
+ export * from './useSitemap'