sanity-plugin-seofields 1.2.4 → 1.2.6
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/dist/index.cjs +2604 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +422 -0
- package/dist/index.d.ts +339 -492
- package/dist/index.js +1284 -2013
- package/dist/index.js.map +1 -1
- package/dist/next.cjs +182 -0
- package/dist/next.cjs.map +1 -0
- package/dist/next.d.cts +241 -0
- package/dist/next.d.ts +202 -295
- package/dist/next.js +110 -70
- package/dist/next.js.map +1 -1
- package/dist/types-B91ena4g.d.cts +89 -0
- package/dist/types-B91ena4g.d.ts +89 -0
- package/package.json +37 -18
- package/dist/index.d.mts +0 -575
- package/dist/index.mjs +0 -3292
- package/dist/index.mjs.map +0 -1
- package/dist/next.d.mts +0 -334
- package/dist/next.mjs +0 -102
- package/dist/next.mjs.map +0 -1
- package/sanity.json +0 -8
- package/src/components/SeoHealthDashboard.tsx +0 -1568
- package/src/components/SeoHealthPane.tsx +0 -81
- package/src/components/SeoHealthTool.tsx +0 -11
- package/src/components/SeoPreview.tsx +0 -178
- package/src/components/meta/MetaDescription.tsx +0 -39
- package/src/components/meta/MetaTitle.tsx +0 -44
- package/src/components/openGraph/OgDescription.tsx +0 -46
- package/src/components/openGraph/OgTitle.tsx +0 -45
- package/src/components/twitter/twitterDescription.tsx +0 -45
- package/src/components/twitter/twitterTitle.tsx +0 -45
- package/src/helpers/SeoMetaTags.tsx +0 -154
- package/src/helpers/seoMeta.ts +0 -283
- package/src/index.ts +0 -26
- package/src/next.ts +0 -12
- package/src/plugin.ts +0 -344
- package/src/schemas/index.ts +0 -121
- package/src/schemas/types/index.ts +0 -20
- package/src/schemas/types/metaAttribute/index.ts +0 -60
- package/src/schemas/types/metaTag/index.ts +0 -17
- package/src/schemas/types/openGraph/index.ts +0 -114
- package/src/schemas/types/robots/index.ts +0 -26
- package/src/schemas/types/twitter/index.ts +0 -108
- package/src/types.ts +0 -108
- package/src/utils/fieldsUtils.ts +0 -160
- package/src/utils/seoUtils.ts +0 -423
- package/src/utils/utils.ts +0 -9
- package/v2-incompatible.js +0 -11
|
@@ -1,1568 +0,0 @@
|
|
|
1
|
-
import React, {useCallback, useEffect, useMemo, useState} from 'react'
|
|
2
|
-
import {useClient, useWorkspace} from 'sanity'
|
|
3
|
-
import {useIntentLink} from 'sanity/router'
|
|
4
|
-
import {usePaneRouter} from 'sanity/structure'
|
|
5
|
-
import styled, {keyframes} from 'styled-components'
|
|
6
|
-
|
|
7
|
-
import {DocumentWithSeoHealth, SeoHealthMetrics} from '../types'
|
|
8
|
-
|
|
9
|
-
const DashboardContainer = styled.div`
|
|
10
|
-
width: 100%;
|
|
11
|
-
min-height: 100%;
|
|
12
|
-
background: #f0f2f5;
|
|
13
|
-
padding: 28px 32px;
|
|
14
|
-
box-sizing: border-box;
|
|
15
|
-
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
16
|
-
`
|
|
17
|
-
|
|
18
|
-
const PageHeader = styled.div`
|
|
19
|
-
margin-bottom: 28px;
|
|
20
|
-
`
|
|
21
|
-
|
|
22
|
-
const PageTitle = styled.h1`
|
|
23
|
-
margin: 0 0 6px 0;
|
|
24
|
-
font-size: 22px;
|
|
25
|
-
font-weight: 700;
|
|
26
|
-
color: #111827;
|
|
27
|
-
letter-spacing: -0.3px;
|
|
28
|
-
display: flex;
|
|
29
|
-
align-items: center;
|
|
30
|
-
gap: 10px;
|
|
31
|
-
`
|
|
32
|
-
|
|
33
|
-
const PreviewBadge = styled.span`
|
|
34
|
-
display: inline-block;
|
|
35
|
-
background: #fef3c7;
|
|
36
|
-
color: #92400e;
|
|
37
|
-
font-size: 11px;
|
|
38
|
-
font-weight: 600;
|
|
39
|
-
padding: 4px 8px;
|
|
40
|
-
border-radius: 4px;
|
|
41
|
-
text-transform: uppercase;
|
|
42
|
-
letter-spacing: 0.5px;
|
|
43
|
-
margin-left: 8px;
|
|
44
|
-
`
|
|
45
|
-
|
|
46
|
-
const PageSubtitle = styled.p`
|
|
47
|
-
margin: 0;
|
|
48
|
-
font-size: 13px;
|
|
49
|
-
color: #6b7280;
|
|
50
|
-
`
|
|
51
|
-
|
|
52
|
-
const StatsGrid = styled.div`
|
|
53
|
-
display: grid;
|
|
54
|
-
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
|
55
|
-
gap: 14px;
|
|
56
|
-
margin-bottom: 20px;
|
|
57
|
-
`
|
|
58
|
-
|
|
59
|
-
const StatCard = styled.div<{$accent?: string}>`
|
|
60
|
-
background: #ffffff;
|
|
61
|
-
border-radius: 10px;
|
|
62
|
-
padding: 16px 18px;
|
|
63
|
-
box-shadow:
|
|
64
|
-
0 1px 3px rgba(0, 0, 0, 0.07),
|
|
65
|
-
0 1px 2px rgba(0, 0, 0, 0.05);
|
|
66
|
-
border-left: ${(p) => (p.$accent ? `4px solid ${p.$accent}` : '4px solid transparent')};
|
|
67
|
-
transition: box-shadow 0.15s ease;
|
|
68
|
-
|
|
69
|
-
&:hover {
|
|
70
|
-
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
71
|
-
}
|
|
72
|
-
`
|
|
73
|
-
|
|
74
|
-
const StatLabel = styled.div`
|
|
75
|
-
font-size: 11px;
|
|
76
|
-
font-weight: 500;
|
|
77
|
-
color: #9ca3af;
|
|
78
|
-
text-transform: uppercase;
|
|
79
|
-
letter-spacing: 0.5px;
|
|
80
|
-
margin-bottom: 8px;
|
|
81
|
-
`
|
|
82
|
-
|
|
83
|
-
const StatValue = styled.div`
|
|
84
|
-
font-size: 26px;
|
|
85
|
-
font-weight: 700;
|
|
86
|
-
color: #111827;
|
|
87
|
-
line-height: 1;
|
|
88
|
-
`
|
|
89
|
-
|
|
90
|
-
const ControlsBar = styled.div`
|
|
91
|
-
background: #ffffff;
|
|
92
|
-
border-radius: 10px;
|
|
93
|
-
padding: 14px 18px;
|
|
94
|
-
display: flex;
|
|
95
|
-
align-items: center;
|
|
96
|
-
gap: 12px;
|
|
97
|
-
flex-wrap: wrap;
|
|
98
|
-
margin-bottom: 20px;
|
|
99
|
-
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.07);
|
|
100
|
-
`
|
|
101
|
-
|
|
102
|
-
const SearchWrapper = styled.div`
|
|
103
|
-
position: relative;
|
|
104
|
-
flex: 1;
|
|
105
|
-
min-width: 220px;
|
|
106
|
-
`
|
|
107
|
-
|
|
108
|
-
const SearchIconSvg = styled.span`
|
|
109
|
-
position: absolute;
|
|
110
|
-
left: 11px;
|
|
111
|
-
top: 50%;
|
|
112
|
-
transform: translateY(-50%);
|
|
113
|
-
color: #9ca3af;
|
|
114
|
-
display: flex;
|
|
115
|
-
align-items: center;
|
|
116
|
-
pointer-events: none;
|
|
117
|
-
`
|
|
118
|
-
|
|
119
|
-
const SearchInput = styled.input`
|
|
120
|
-
width: 100%;
|
|
121
|
-
height: 36px;
|
|
122
|
-
padding: 0 12px 0 34px;
|
|
123
|
-
border: 1px solid #e5e7eb;
|
|
124
|
-
border-radius: 7px;
|
|
125
|
-
font-size: 13px;
|
|
126
|
-
color: #111827;
|
|
127
|
-
background: #f9fafb;
|
|
128
|
-
box-sizing: border-box;
|
|
129
|
-
outline: none;
|
|
130
|
-
transition:
|
|
131
|
-
border-color 0.15s,
|
|
132
|
-
background 0.15s;
|
|
133
|
-
|
|
134
|
-
&::placeholder {
|
|
135
|
-
color: #9ca3af;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
&:focus {
|
|
139
|
-
border-color: #6366f1;
|
|
140
|
-
background: #fff;
|
|
141
|
-
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
|
142
|
-
}
|
|
143
|
-
`
|
|
144
|
-
|
|
145
|
-
const StyledSelect = styled.select`
|
|
146
|
-
height: 36px;
|
|
147
|
-
padding: 0 32px 0 12px;
|
|
148
|
-
border: 1px solid #e5e7eb;
|
|
149
|
-
border-radius: 7px;
|
|
150
|
-
font-size: 13px;
|
|
151
|
-
color: #374151;
|
|
152
|
-
background: #f9fafb
|
|
153
|
-
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236b7280' d='M6 8L1 3h10z'/%3E%3C/svg%3E")
|
|
154
|
-
no-repeat right 10px center;
|
|
155
|
-
appearance: none;
|
|
156
|
-
outline: none;
|
|
157
|
-
cursor: pointer;
|
|
158
|
-
transition: border-color 0.15s;
|
|
159
|
-
|
|
160
|
-
&:focus {
|
|
161
|
-
border-color: #6366f1;
|
|
162
|
-
background-color: #fff;
|
|
163
|
-
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
|
164
|
-
}
|
|
165
|
-
`
|
|
166
|
-
|
|
167
|
-
const TableCard = styled.div`
|
|
168
|
-
background: #ffffff;
|
|
169
|
-
border-radius: 10px;
|
|
170
|
-
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.07);
|
|
171
|
-
overflow: hidden;
|
|
172
|
-
`
|
|
173
|
-
|
|
174
|
-
const TableHeader = styled.div`
|
|
175
|
-
display: flex;
|
|
176
|
-
align-items: center;
|
|
177
|
-
padding: 11px 20px;
|
|
178
|
-
background: #f9fafb;
|
|
179
|
-
border-bottom: 1px solid #e5e7eb;
|
|
180
|
-
font-size: 11px;
|
|
181
|
-
font-weight: 600;
|
|
182
|
-
color: #6b7280;
|
|
183
|
-
text-transform: uppercase;
|
|
184
|
-
letter-spacing: 0.5px;
|
|
185
|
-
gap: 12px;
|
|
186
|
-
`
|
|
187
|
-
|
|
188
|
-
const TableRow = styled.div`
|
|
189
|
-
display: flex;
|
|
190
|
-
align-items: center;
|
|
191
|
-
padding: 13px 20px;
|
|
192
|
-
border-bottom: 1px solid #f3f4f6;
|
|
193
|
-
gap: 12px;
|
|
194
|
-
transition: background 0.1s;
|
|
195
|
-
|
|
196
|
-
&:last-child {
|
|
197
|
-
border-bottom: none;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
&:hover {
|
|
201
|
-
background: #fafafa;
|
|
202
|
-
}
|
|
203
|
-
`
|
|
204
|
-
|
|
205
|
-
const ColTitle = styled.div`
|
|
206
|
-
flex: 2;
|
|
207
|
-
min-width: 0;
|
|
208
|
-
`
|
|
209
|
-
|
|
210
|
-
const TitleWrapper = styled.div`
|
|
211
|
-
display: flex;
|
|
212
|
-
align-items: center;
|
|
213
|
-
gap: 4px;
|
|
214
|
-
flex-wrap: wrap;
|
|
215
|
-
min-width: 0;
|
|
216
|
-
`
|
|
217
|
-
|
|
218
|
-
/* Constrains the title + doc-id block so text-overflow works inside flex */
|
|
219
|
-
const TitleCell = styled.div`
|
|
220
|
-
min-width: 0;
|
|
221
|
-
overflow: hidden;
|
|
222
|
-
flex: 1;
|
|
223
|
-
`
|
|
224
|
-
|
|
225
|
-
const ColType = styled.div`
|
|
226
|
-
flex: 0.8;
|
|
227
|
-
min-width: 80px;
|
|
228
|
-
`
|
|
229
|
-
|
|
230
|
-
const ColScore = styled.div`
|
|
231
|
-
flex: 0.6;
|
|
232
|
-
min-width: 70px;
|
|
233
|
-
`
|
|
234
|
-
|
|
235
|
-
const ColIssues = styled.div`
|
|
236
|
-
flex: 2;
|
|
237
|
-
min-width: 0;
|
|
238
|
-
`
|
|
239
|
-
|
|
240
|
-
const DocTitleLink = styled.a`
|
|
241
|
-
font-size: 13px;
|
|
242
|
-
font-weight: 600;
|
|
243
|
-
color: #4f46e5;
|
|
244
|
-
white-space: nowrap;
|
|
245
|
-
overflow: hidden;
|
|
246
|
-
text-overflow: ellipsis;
|
|
247
|
-
text-decoration: none;
|
|
248
|
-
display: block;
|
|
249
|
-
transition: color 0.15s;
|
|
250
|
-
|
|
251
|
-
&:hover {
|
|
252
|
-
color: #4338ca;
|
|
253
|
-
text-decoration: underline;
|
|
254
|
-
}
|
|
255
|
-
`
|
|
256
|
-
|
|
257
|
-
const DocId = styled.div`
|
|
258
|
-
font-size: 11px;
|
|
259
|
-
color: #9ca3af;
|
|
260
|
-
margin-top: 2px;
|
|
261
|
-
white-space: nowrap;
|
|
262
|
-
overflow: hidden;
|
|
263
|
-
text-overflow: ellipsis;
|
|
264
|
-
`
|
|
265
|
-
|
|
266
|
-
const TypeBadge = styled.span<{$bgColor?: string; $textColor?: string}>`
|
|
267
|
-
display: inline-block;
|
|
268
|
-
padding: 3px 8px;
|
|
269
|
-
border-radius: 5px;
|
|
270
|
-
font-size: 11px;
|
|
271
|
-
font-weight: 500;
|
|
272
|
-
background: ${(p) => p.$bgColor || '#ede9fe'};
|
|
273
|
-
color: ${(p) => p.$textColor || '#5b21b6'};
|
|
274
|
-
`
|
|
275
|
-
|
|
276
|
-
const TypeText = styled.span`
|
|
277
|
-
font-size: 12px;
|
|
278
|
-
font-weight: 500;
|
|
279
|
-
color: #374151;
|
|
280
|
-
`
|
|
281
|
-
|
|
282
|
-
const CustomBadge = styled.span<{$bgColor?: string; $textColor?: string; $fontSize?: string}>`
|
|
283
|
-
display: inline-block;
|
|
284
|
-
padding: 2px 6px;
|
|
285
|
-
border-radius: 4px;
|
|
286
|
-
font-size: ${(p) => p.$fontSize || '10px'};
|
|
287
|
-
font-weight: 600;
|
|
288
|
-
margin-left: 6px;
|
|
289
|
-
background: ${(p) => p.$bgColor || '#e0e7ff'};
|
|
290
|
-
color: ${(p) => p.$textColor || '#3730a3'};
|
|
291
|
-
white-space: nowrap;
|
|
292
|
-
`
|
|
293
|
-
|
|
294
|
-
const ScoreBadge = styled.span<{$score: number}>`
|
|
295
|
-
display: inline-block;
|
|
296
|
-
padding: 4px 10px;
|
|
297
|
-
border-radius: 6px;
|
|
298
|
-
font-size: 12px;
|
|
299
|
-
font-weight: 700;
|
|
300
|
-
background: ${(p) => {
|
|
301
|
-
if (p.$score >= 80) return '#d1fae5'
|
|
302
|
-
if (p.$score >= 60) return '#fef3c7'
|
|
303
|
-
if (p.$score >= 40) return '#ffedd5'
|
|
304
|
-
return '#fee2e2'
|
|
305
|
-
}};
|
|
306
|
-
color: ${(p) => {
|
|
307
|
-
if (p.$score >= 80) return '#065f46'
|
|
308
|
-
if (p.$score >= 60) return '#92400e'
|
|
309
|
-
if (p.$score >= 40) return '#9a3412'
|
|
310
|
-
return '#991b1b'
|
|
311
|
-
}};
|
|
312
|
-
`
|
|
313
|
-
|
|
314
|
-
const IssueTag = styled.div`
|
|
315
|
-
font-size: 11px;
|
|
316
|
-
color: #ef4444;
|
|
317
|
-
line-height: 1.5;
|
|
318
|
-
white-space: nowrap;
|
|
319
|
-
overflow: hidden;
|
|
320
|
-
text-overflow: ellipsis;
|
|
321
|
-
`
|
|
322
|
-
|
|
323
|
-
const MoreIssues = styled.div`
|
|
324
|
-
font-size: 11px;
|
|
325
|
-
color: #6b7280;
|
|
326
|
-
cursor: pointer;
|
|
327
|
-
transition: color 0.15s;
|
|
328
|
-
|
|
329
|
-
&:hover {
|
|
330
|
-
color: #374151;
|
|
331
|
-
}
|
|
332
|
-
`
|
|
333
|
-
|
|
334
|
-
const MoreIssuesWrapper = styled.div`
|
|
335
|
-
position: relative;
|
|
336
|
-
display: inline-block;
|
|
337
|
-
`
|
|
338
|
-
|
|
339
|
-
const IssuesPopover = styled.div<{
|
|
340
|
-
$left?: number
|
|
341
|
-
}>`
|
|
342
|
-
position: absolute;
|
|
343
|
-
bottom: auto;
|
|
344
|
-
left: 0;
|
|
345
|
-
transform: translateY(calc(-100% - 14px));
|
|
346
|
-
background: #1f2937;
|
|
347
|
-
color: #ffffff;
|
|
348
|
-
padding: 12px;
|
|
349
|
-
border-radius: 8px;
|
|
350
|
-
font-size: 12px;
|
|
351
|
-
z-index: 50;
|
|
352
|
-
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
|
353
|
-
width: 280px;
|
|
354
|
-
word-break: break-word;
|
|
355
|
-
line-height: 1.5;
|
|
356
|
-
|
|
357
|
-
&::after {
|
|
358
|
-
content: '';
|
|
359
|
-
position: absolute;
|
|
360
|
-
bottom: -6px;
|
|
361
|
-
left: 12px;
|
|
362
|
-
width: 0;
|
|
363
|
-
height: 0;
|
|
364
|
-
border-left: 6px solid transparent;
|
|
365
|
-
border-right: 6px solid transparent;
|
|
366
|
-
border-top: 6px solid #1f2937;
|
|
367
|
-
}
|
|
368
|
-
`
|
|
369
|
-
|
|
370
|
-
const PopoverIssueItem = styled.div`
|
|
371
|
-
display: flex;
|
|
372
|
-
gap: 6px;
|
|
373
|
-
margin-bottom: 6px;
|
|
374
|
-
|
|
375
|
-
&:last-child {
|
|
376
|
-
margin-bottom: 0;
|
|
377
|
-
}
|
|
378
|
-
`
|
|
379
|
-
|
|
380
|
-
const UpgradeContainer = styled.div`
|
|
381
|
-
display: flex;
|
|
382
|
-
align-items: center;
|
|
383
|
-
justify-content: center;
|
|
384
|
-
min-height: 100%;
|
|
385
|
-
padding: 60px 24px;
|
|
386
|
-
`
|
|
387
|
-
|
|
388
|
-
const UpgradeBox = styled.div`
|
|
389
|
-
background: #ffffff;
|
|
390
|
-
border-radius: 16px;
|
|
391
|
-
padding: 48px 40px;
|
|
392
|
-
max-width: 480px;
|
|
393
|
-
width: 100%;
|
|
394
|
-
text-align: center;
|
|
395
|
-
box-shadow:
|
|
396
|
-
0 4px 24px rgba(0, 0, 0, 0.08),
|
|
397
|
-
0 1px 4px rgba(0, 0, 0, 0.05);
|
|
398
|
-
border: 1px solid #e5e7eb;
|
|
399
|
-
`
|
|
400
|
-
|
|
401
|
-
const UpgradeLock = styled.div`
|
|
402
|
-
font-size: 40px;
|
|
403
|
-
margin-bottom: 16px;
|
|
404
|
-
`
|
|
405
|
-
|
|
406
|
-
const UpgradeTitle = styled.h2`
|
|
407
|
-
margin: 0 0 10px;
|
|
408
|
-
font-size: 20px;
|
|
409
|
-
font-weight: 700;
|
|
410
|
-
color: #111827;
|
|
411
|
-
`
|
|
412
|
-
|
|
413
|
-
const UpgradeText = styled.p`
|
|
414
|
-
margin: 0 0 20px;
|
|
415
|
-
font-size: 14px;
|
|
416
|
-
color: #6b7280;
|
|
417
|
-
line-height: 1.6;
|
|
418
|
-
`
|
|
419
|
-
|
|
420
|
-
const UpgradeCode = styled.pre`
|
|
421
|
-
background: #f3f4f6;
|
|
422
|
-
border-radius: 8px;
|
|
423
|
-
padding: 14px 16px;
|
|
424
|
-
font-size: 12px;
|
|
425
|
-
color: #374151;
|
|
426
|
-
text-align: left;
|
|
427
|
-
margin: 0 0 24px;
|
|
428
|
-
overflow-x: auto;
|
|
429
|
-
line-height: 1.6;
|
|
430
|
-
border: 1px solid #e5e7eb;
|
|
431
|
-
`
|
|
432
|
-
|
|
433
|
-
const UpgradeButton = styled.a`
|
|
434
|
-
display: inline-block;
|
|
435
|
-
background: #4f46e5;
|
|
436
|
-
color: #ffffff;
|
|
437
|
-
font-size: 14px;
|
|
438
|
-
font-weight: 600;
|
|
439
|
-
padding: 10px 24px;
|
|
440
|
-
border-radius: 8px;
|
|
441
|
-
text-decoration: none;
|
|
442
|
-
transition: background 0.15s;
|
|
443
|
-
|
|
444
|
-
&:hover {
|
|
445
|
-
background: #4338ca;
|
|
446
|
-
}
|
|
447
|
-
`
|
|
448
|
-
|
|
449
|
-
const ReloadButton = styled.button`
|
|
450
|
-
display: inline-block;
|
|
451
|
-
background: transparent;
|
|
452
|
-
color: #6b7280;
|
|
453
|
-
font-size: 13px;
|
|
454
|
-
font-weight: 500;
|
|
455
|
-
padding: 8px 20px;
|
|
456
|
-
border-radius: 8px;
|
|
457
|
-
border: 1px solid #d1d5db;
|
|
458
|
-
cursor: pointer;
|
|
459
|
-
margin-top: 10px;
|
|
460
|
-
transition:
|
|
461
|
-
background 0.15s,
|
|
462
|
-
color 0.15s,
|
|
463
|
-
border-color 0.15s;
|
|
464
|
-
|
|
465
|
-
&:hover {
|
|
466
|
-
background: #f3f4f6;
|
|
467
|
-
color: #374151;
|
|
468
|
-
border-color: #9ca3af;
|
|
469
|
-
}
|
|
470
|
-
`
|
|
471
|
-
|
|
472
|
-
// Sub-component so useIntentLink can be called at the top level of a component (not inside .map)
|
|
473
|
-
const DocTitleAnchor: React.FC<{
|
|
474
|
-
id: string
|
|
475
|
-
type: string
|
|
476
|
-
structureTool?: string
|
|
477
|
-
children: React.ReactNode
|
|
478
|
-
}> = ({id, type, structureTool, children}) => {
|
|
479
|
-
const {basePath} = useWorkspace()
|
|
480
|
-
const {onClick: intentOnClick, href: intentHref} = useIntentLink({intent: 'edit', params: {id, type}})
|
|
481
|
-
// When a specific structure tool name is provided, build a tool-scoped intent URL so that
|
|
482
|
-
// Sanity routes directly to that tool instead of letting the router pick the first match.
|
|
483
|
-
const href = structureTool
|
|
484
|
-
? `${basePath}/${structureTool}/intent/edit/id=${id};type=${type}/`
|
|
485
|
-
: intentHref
|
|
486
|
-
const onClick = structureTool ? undefined : intentOnClick
|
|
487
|
-
return (
|
|
488
|
-
<DocTitleLink href={href} onClick={onClick} title="Open document">
|
|
489
|
-
{children}
|
|
490
|
-
</DocTitleLink>
|
|
491
|
-
)
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
// Wrapper that applies DocTitleLink styles to the ChildLink <a> rendered by Sanity's pane router
|
|
495
|
-
const PaneLinkWrapper = styled.span`
|
|
496
|
-
display: block;
|
|
497
|
-
min-width: 0;
|
|
498
|
-
overflow: hidden;
|
|
499
|
-
|
|
500
|
-
a {
|
|
501
|
-
font-size: 13px;
|
|
502
|
-
font-weight: 600;
|
|
503
|
-
color: #4f46e5;
|
|
504
|
-
white-space: nowrap;
|
|
505
|
-
overflow: hidden;
|
|
506
|
-
text-overflow: ellipsis;
|
|
507
|
-
text-decoration: none;
|
|
508
|
-
display: block;
|
|
509
|
-
transition: color 0.15s;
|
|
510
|
-
|
|
511
|
-
&:hover {
|
|
512
|
-
color: #4338ca;
|
|
513
|
-
text-decoration: underline;
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
`
|
|
517
|
-
|
|
518
|
-
// Sub-component for desk-structure split-pane navigation.
|
|
519
|
-
// Uses ChildLink from usePaneRouter to open the document editor to the right
|
|
520
|
-
// while keeping the SEO Health pane visible on the left.
|
|
521
|
-
const DocTitleAnchorPane: React.FC<{id: string; type: string; children: React.ReactNode}> = ({
|
|
522
|
-
id,
|
|
523
|
-
type,
|
|
524
|
-
children,
|
|
525
|
-
}) => {
|
|
526
|
-
const {ChildLink} = usePaneRouter()
|
|
527
|
-
return (
|
|
528
|
-
<PaneLinkWrapper>
|
|
529
|
-
<ChildLink childId={id} childParameters={{type}}>
|
|
530
|
-
{children}
|
|
531
|
-
</ChildLink>
|
|
532
|
-
</PaneLinkWrapper>
|
|
533
|
-
)
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
// Sub-component to safely call docBadge outside a .map expression
|
|
537
|
-
const DocBadgeRenderer: React.FC<{
|
|
538
|
-
doc: DocumentWithSeoHealth & Record<string, unknown>
|
|
539
|
-
docBadge: (
|
|
540
|
-
doc: DocumentWithSeoHealth & Record<string, unknown>,
|
|
541
|
-
) => {label: string; bgColor?: string; textColor?: string; fontSize?: string} | undefined
|
|
542
|
-
}> = ({doc, docBadge}) => {
|
|
543
|
-
const badge = docBadge(doc)
|
|
544
|
-
if (!badge) return null
|
|
545
|
-
return (
|
|
546
|
-
<CustomBadge $bgColor={badge.bgColor} $textColor={badge.textColor} $fontSize={badge.fontSize}>
|
|
547
|
-
{badge.label}
|
|
548
|
-
</CustomBadge>
|
|
549
|
-
)
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
const spin = keyframes`
|
|
553
|
-
to { transform: rotate(360deg); }
|
|
554
|
-
`
|
|
555
|
-
|
|
556
|
-
const Spinner = styled.div`
|
|
557
|
-
width: 28px;
|
|
558
|
-
height: 28px;
|
|
559
|
-
border: 3px solid #e5e7eb;
|
|
560
|
-
border-top-color: #6366f1;
|
|
561
|
-
border-radius: 50%;
|
|
562
|
-
animation: ${spin} 0.7s linear infinite;
|
|
563
|
-
margin: 0 auto 12px;
|
|
564
|
-
`
|
|
565
|
-
|
|
566
|
-
const LoadingState = styled.div`
|
|
567
|
-
padding: 48px 24px;
|
|
568
|
-
text-align: center;
|
|
569
|
-
color: #6b7280;
|
|
570
|
-
font-size: 13px;
|
|
571
|
-
`
|
|
572
|
-
|
|
573
|
-
const EmptyState = styled.div`
|
|
574
|
-
padding: 48px 24px;
|
|
575
|
-
text-align: center;
|
|
576
|
-
color: #9ca3af;
|
|
577
|
-
font-size: 13px;
|
|
578
|
-
`
|
|
579
|
-
|
|
580
|
-
/**
|
|
581
|
-
* Color palette for dynamic document type badges
|
|
582
|
-
* Colors are randomly assigned based on type hash for visual variety
|
|
583
|
-
* while maintaining consistency across sessions
|
|
584
|
-
*/
|
|
585
|
-
const TYPE_COLOR_PALETTE: Array<{bg: string; text: string}> = [
|
|
586
|
-
{bg: '#dbeafe', text: '#0c4a6e'}, // Blue
|
|
587
|
-
{bg: '#dcfce7', text: '#14532d'}, // Green
|
|
588
|
-
{bg: '#fce7f3', text: '#500724'}, // Pink
|
|
589
|
-
{bg: '#fed7aa', text: '#7c2d12'}, // Orange
|
|
590
|
-
{bg: '#e9d5ff', text: '#581c87'}, // Purple
|
|
591
|
-
{bg: '#f3e8ff', text: '#3f0f5c'}, // Deep Purple
|
|
592
|
-
{bg: '#ccfbf1', text: '#134e4a'}, // Teal
|
|
593
|
-
{bg: '#ddd6fe', text: '#3730a3'}, // Indigo
|
|
594
|
-
{bg: '#fca5a5', text: '#7f1d1d'}, // Red
|
|
595
|
-
{bg: '#a7f3d0', text: '#065f46'}, // Emerald
|
|
596
|
-
{bg: '#fbbf24', text: '#78350f'}, // Amber
|
|
597
|
-
{bg: '#c4b5fd', text: '#3b0764'}, // Violet
|
|
598
|
-
{bg: '#f0fdf4', text: '#15803d'}, // Light Green
|
|
599
|
-
{bg: '#fef2f2', text: '#991b1b'}, // Light Red
|
|
600
|
-
{bg: '#f5f3ff', text: '#5b21b6'}, // Light Purple
|
|
601
|
-
{bg: '#fffbeb', text: '#92400e'}, // Light Amber
|
|
602
|
-
]
|
|
603
|
-
|
|
604
|
-
/**
|
|
605
|
-
* Get dynamic color for a document type based on type name hash
|
|
606
|
-
* Same type always gets the same color, but assignment is not fixed
|
|
607
|
-
*/
|
|
608
|
-
const getTypeColor = (type: string): {bg: string; text: string} => {
|
|
609
|
-
// Generate consistent hash from type string using simple arithmetic
|
|
610
|
-
let hash = 0
|
|
611
|
-
for (let i = 0; i < type.length; i += 1) {
|
|
612
|
-
const char = type.charCodeAt(i)
|
|
613
|
-
hash = Math.abs(hash * 31 + char)
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
// Use modulo to get index within palette range
|
|
617
|
-
const colorIndex = hash % TYPE_COLOR_PALETTE.length
|
|
618
|
-
return TYPE_COLOR_PALETTE[colorIndex]
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
const getStatusCategory = (score: number): SeoHealthMetrics['status'] => {
|
|
622
|
-
if (score >= 80) return 'excellent'
|
|
623
|
-
if (score >= 60) return 'good'
|
|
624
|
-
if (score >= 40) return 'fair'
|
|
625
|
-
if (score > 0) return 'poor'
|
|
626
|
-
return 'missing'
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
const scoreMetaTitle = (title?: string): {score: number; issues: string[]} => {
|
|
630
|
-
const issues: string[] = []
|
|
631
|
-
let score = 0
|
|
632
|
-
|
|
633
|
-
if (title && title.length >= 50 && title.length <= 60) {
|
|
634
|
-
score = 15
|
|
635
|
-
} else if (title && title.length > 0) {
|
|
636
|
-
score = 10
|
|
637
|
-
if (title.length < 50) issues.push('Meta title too short (< 50 chars)')
|
|
638
|
-
if (title.length > 60) issues.push('Meta title too long (> 60 chars)')
|
|
639
|
-
} else {
|
|
640
|
-
issues.push('Missing meta title')
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
return {score, issues}
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
const scoreMetaDescription = (description?: string): {score: number; issues: string[]} => {
|
|
647
|
-
const issues: string[] = []
|
|
648
|
-
let score = 0
|
|
649
|
-
|
|
650
|
-
if (description && description.length >= 120 && description.length <= 160) {
|
|
651
|
-
score = 15
|
|
652
|
-
} else if (description && description.length > 0) {
|
|
653
|
-
score = 10
|
|
654
|
-
if (description.length < 120) issues.push('Meta description too short (< 120 chars)')
|
|
655
|
-
if (description.length > 160) issues.push('Meta description too long (> 160 chars)')
|
|
656
|
-
} else {
|
|
657
|
-
issues.push('Missing meta description')
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
return {score, issues}
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
const scoreOpenGraph = (openGraph?: Record<string, unknown>): {score: number; issues: string[]} => {
|
|
664
|
-
const issues: string[] = []
|
|
665
|
-
let score = 0
|
|
666
|
-
|
|
667
|
-
if (openGraph) {
|
|
668
|
-
if (openGraph.title) score += 6
|
|
669
|
-
else issues.push('Missing OG title')
|
|
670
|
-
|
|
671
|
-
if (openGraph.description) score += 6
|
|
672
|
-
else issues.push('Missing OG description')
|
|
673
|
-
|
|
674
|
-
if (openGraph.image) score += 6
|
|
675
|
-
else issues.push('Missing OG image')
|
|
676
|
-
|
|
677
|
-
if (openGraph.type) score += 7
|
|
678
|
-
else issues.push('Missing OG type')
|
|
679
|
-
} else {
|
|
680
|
-
issues.push('Open Graph not configured')
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
return {score, issues}
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
const scoreTwitterCard = (twitter?: Record<string, unknown>): {score: number; issues: string[]} => {
|
|
687
|
-
const issues: string[] = []
|
|
688
|
-
let score = 0
|
|
689
|
-
|
|
690
|
-
if (twitter) {
|
|
691
|
-
if (twitter.title) score += 5
|
|
692
|
-
else issues.push('Missing Twitter title')
|
|
693
|
-
|
|
694
|
-
if (twitter.description) score += 5
|
|
695
|
-
else issues.push('Missing Twitter description')
|
|
696
|
-
|
|
697
|
-
if (twitter.image) score += 5
|
|
698
|
-
else issues.push('Missing Twitter image')
|
|
699
|
-
} else {
|
|
700
|
-
issues.push('Twitter Card not configured')
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
return {score, issues}
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
707
|
-
const calculateHealthScore = (doc: any): SeoHealthMetrics => {
|
|
708
|
-
let totalScore = 0
|
|
709
|
-
const allIssues: string[] = []
|
|
710
|
-
|
|
711
|
-
if (!doc.seo) {
|
|
712
|
-
return {score: 0, status: 'missing', issues: ['SEO fields not configured']}
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
const {title, description, metaImage, keywords, robots, openGraph, twitter} = doc.seo
|
|
716
|
-
|
|
717
|
-
// Meta Title
|
|
718
|
-
const titleScore = scoreMetaTitle(title)
|
|
719
|
-
totalScore += titleScore.score
|
|
720
|
-
allIssues.push(...titleScore.issues)
|
|
721
|
-
|
|
722
|
-
// Meta Description
|
|
723
|
-
const descriptionScore = scoreMetaDescription(description)
|
|
724
|
-
totalScore += descriptionScore.score
|
|
725
|
-
allIssues.push(...descriptionScore.issues)
|
|
726
|
-
|
|
727
|
-
// Meta Image
|
|
728
|
-
if (metaImage) {
|
|
729
|
-
totalScore += 10
|
|
730
|
-
} else {
|
|
731
|
-
allIssues.push('Missing meta image')
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
// Keywords
|
|
735
|
-
if (keywords && keywords.length > 0) {
|
|
736
|
-
totalScore += 10
|
|
737
|
-
} else {
|
|
738
|
-
allIssues.push('No keywords defined')
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
// Open Graph
|
|
742
|
-
const ogScore = scoreOpenGraph(openGraph as Record<string, unknown> | undefined)
|
|
743
|
-
totalScore += ogScore.score
|
|
744
|
-
allIssues.push(...ogScore.issues)
|
|
745
|
-
|
|
746
|
-
// Twitter Card
|
|
747
|
-
const twitterScore = scoreTwitterCard(twitter as Record<string, unknown> | undefined)
|
|
748
|
-
totalScore += twitterScore.score
|
|
749
|
-
allIssues.push(...twitterScore.issues)
|
|
750
|
-
|
|
751
|
-
// Robots settings
|
|
752
|
-
if (robots && !robots.noIndex) {
|
|
753
|
-
totalScore += 5
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
const status = getStatusCategory(totalScore)
|
|
757
|
-
|
|
758
|
-
return {score: totalScore, status, issues: allIssues}
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
const resolveTypeLabel = (type: string, typeLabels?: Record<string, string>): string =>
|
|
762
|
-
typeLabels?.[type] ?? type
|
|
763
|
-
|
|
764
|
-
/**
|
|
765
|
-
* Builds the GROQ projection snippet for the title field.
|
|
766
|
-
* - undefined / 'title' → `title`
|
|
767
|
-
* - 'name' → `"title": name`
|
|
768
|
-
* - { post: 'title', product: 'name' } → `"title": select(_type == "post" => title, _type == "product" => name, title)`
|
|
769
|
-
*/
|
|
770
|
-
const buildTitleProjection = (titleField?: string | Record<string, string>): string => {
|
|
771
|
-
if (!titleField || titleField === 'title') return 'title'
|
|
772
|
-
if (typeof titleField === 'string') return `"title": ${titleField}`
|
|
773
|
-
const cases = Object.entries(titleField)
|
|
774
|
-
.map(([type, field]) => `_type == "${type}" => ${field}`)
|
|
775
|
-
.join(', ')
|
|
776
|
-
return `"title": select(${cases}, title)`
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
export interface SeoHealthDashboardProps {
|
|
780
|
-
icon?: string
|
|
781
|
-
title?: string
|
|
782
|
-
description?: string
|
|
783
|
-
showTypeColumn?: boolean
|
|
784
|
-
showDocumentId?: boolean
|
|
785
|
-
/**
|
|
786
|
-
* Limit the dashboard to specific document type names.
|
|
787
|
-
* If both queryTypes and customQuery are provided, customQuery takes precedence.
|
|
788
|
-
*/
|
|
789
|
-
queryTypes?: string[]
|
|
790
|
-
/**
|
|
791
|
-
* When using `queryTypes`, also filter by `seo != null`.
|
|
792
|
-
* Set to `false` to include documents of those types even without an seo field.
|
|
793
|
-
* Defaults to `true`.
|
|
794
|
-
*/
|
|
795
|
-
queryRequireSeo?: boolean
|
|
796
|
-
/**
|
|
797
|
-
* A fully custom GROQ query used to fetch documents.
|
|
798
|
-
* Must return objects with at least: _id, _type, title, seo, _updatedAt
|
|
799
|
-
* Takes precedence over queryTypes.
|
|
800
|
-
*/
|
|
801
|
-
customQuery?: string
|
|
802
|
-
/**
|
|
803
|
-
* The Sanity API version to use for the client (e.g. '2023-01-01').
|
|
804
|
-
* Defaults to '2023-01-01'.
|
|
805
|
-
*/
|
|
806
|
-
apiVersion?: string
|
|
807
|
-
/**
|
|
808
|
-
* License key for the SEO Health Dashboard.
|
|
809
|
-
* Obtain a key at https://sanity-plugin-seofields.thehardik.in
|
|
810
|
-
*/
|
|
811
|
-
licenseKey?: string
|
|
812
|
-
/**
|
|
813
|
-
* Map raw `_type` values to human-readable display labels used in the
|
|
814
|
-
* Type column and the Type filter dropdown.
|
|
815
|
-
* Any type without an entry falls back to the raw `_type` string.
|
|
816
|
-
*
|
|
817
|
-
* @example
|
|
818
|
-
* typeLabels={{ productDrug: 'Products', singleCondition: 'Condition' }}
|
|
819
|
-
*/
|
|
820
|
-
typeLabels?: Record<string, string>
|
|
821
|
-
/**
|
|
822
|
-
* Controls how the type is rendered in the Type column.
|
|
823
|
-
* - `'badge'` (default) — coloured pill, consistent with score badges
|
|
824
|
-
* - `'text'` — plain text, useful for dense layouts
|
|
825
|
-
*/
|
|
826
|
-
typeColumnMode?: 'badge' | 'text'
|
|
827
|
-
/**
|
|
828
|
-
* The document field to use as the display title.
|
|
829
|
-
*
|
|
830
|
-
* - `string` — use this field for every document type (e.g. `'name'`)
|
|
831
|
-
* - `Record<string, string>` — per-type mapping; unmapped types fall back to `title`
|
|
832
|
-
*
|
|
833
|
-
* @example
|
|
834
|
-
* // Same field for all types
|
|
835
|
-
* titleField: 'name'
|
|
836
|
-
*
|
|
837
|
-
* @example
|
|
838
|
-
* // Different field per type
|
|
839
|
-
* titleField: { post: 'title', product: 'name', category: 'label' }
|
|
840
|
-
*/
|
|
841
|
-
titleField?: string | Record<string, string>
|
|
842
|
-
/**
|
|
843
|
-
* Callback function to render a custom badge next to the document title.
|
|
844
|
-
* Receives the full document and should return badge data or undefined.
|
|
845
|
-
*
|
|
846
|
-
* @example
|
|
847
|
-
* docBadge: (doc) => {
|
|
848
|
-
* if (doc.services === 'NHS')
|
|
849
|
-
* return { label: 'NHS', bgColor: '#e0f2fe', textColor: '#0369a1' }
|
|
850
|
-
* if (doc.services === 'Private')
|
|
851
|
-
* return { label: 'Private', bgColor: '#fef3c7', textColor: '#92400e' }
|
|
852
|
-
* }
|
|
853
|
-
*/
|
|
854
|
-
docBadge?: (
|
|
855
|
-
doc: DocumentWithSeoHealth & Record<string, unknown>,
|
|
856
|
-
) => {label: string; bgColor?: string; textColor?: string; fontSize?: string} | undefined
|
|
857
|
-
/**
|
|
858
|
-
* Custom text shown while the license key is being verified.
|
|
859
|
-
* Defaults to `"Verifying license…"`.
|
|
860
|
-
*/
|
|
861
|
-
loadingLicense?: React.ReactNode
|
|
862
|
-
/**
|
|
863
|
-
* Custom text shown while documents are being fetched.
|
|
864
|
-
* Defaults to `"Loading documents…"`.
|
|
865
|
-
*/
|
|
866
|
-
loadingDocuments?: React.ReactNode
|
|
867
|
-
/**
|
|
868
|
-
* Custom text shown when the query returns zero results.
|
|
869
|
-
* Defaults to `"No documents found"`.
|
|
870
|
-
*/
|
|
871
|
-
noDocuments?: React.ReactNode
|
|
872
|
-
/**
|
|
873
|
-
* Enable preview/demo mode to show dummy data.
|
|
874
|
-
* Useful for testing, documentation, or showcasing the dashboard.
|
|
875
|
-
* When enabled, displays realistic sample documents with various SEO scores.
|
|
876
|
-
* Defaults to `false`.
|
|
877
|
-
*/
|
|
878
|
-
previewMode?: boolean
|
|
879
|
-
/**
|
|
880
|
-
* When `true`, clicking a document title opens the document editor as a split
|
|
881
|
-
* pane to the right, keeping the SEO Health pane visible on the left.
|
|
882
|
-
* This uses Sanity's pane router and requires the component to be rendered
|
|
883
|
-
* inside a desk-structure pane context (i.e. via `createSeoHealthPane`).
|
|
884
|
-
*
|
|
885
|
-
* When `false` (default), clicking navigates to the document via the standard
|
|
886
|
-
* intent-link system (full navigation).
|
|
887
|
-
*
|
|
888
|
-
* This is set to `true` automatically by `createSeoHealthPane`.
|
|
889
|
-
*/
|
|
890
|
-
openInPane?: boolean
|
|
891
|
-
/**
|
|
892
|
-
* The `name` of the Sanity structure tool that contains the monitored documents.
|
|
893
|
-
* When provided, clicking a document title navigates directly to that tool's
|
|
894
|
-
* intent URL (`/{basePath}/{structureTool}/intent/edit/id=…;type=…/`) instead of
|
|
895
|
-
* using the generic intent resolver, which always picks the first registered tool.
|
|
896
|
-
*
|
|
897
|
-
* Required when you have multiple structure tools and the documents live in a
|
|
898
|
-
* non-default one (e.g. `name: 'common'`).
|
|
899
|
-
*
|
|
900
|
-
* @example
|
|
901
|
-
* structureTool: 'common'
|
|
902
|
-
*/
|
|
903
|
-
structureTool?: string
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
/**
|
|
907
|
-
* Generate dummy data for preview mode showing various SEO health scenarios
|
|
908
|
-
*/
|
|
909
|
-
const generateDummyData = (): DocumentWithSeoHealth[] => {
|
|
910
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
911
|
-
const dummyDocs: any[] = [
|
|
912
|
-
{
|
|
913
|
-
_id: 'preview-post-1',
|
|
914
|
-
_type: 'post',
|
|
915
|
-
title: 'Getting Started with SEO Best Practices',
|
|
916
|
-
slug: {current: 'getting-started-seo'},
|
|
917
|
-
_updatedAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
|
|
918
|
-
seo: {
|
|
919
|
-
title: 'Getting Started with SEO Best Practices | My Blog',
|
|
920
|
-
description:
|
|
921
|
-
'Learn the fundamentals of SEO optimization to improve your website visibility and search rankings.',
|
|
922
|
-
keywords: ['seo', 'best practices', 'optimization'],
|
|
923
|
-
metaImage: {_type: 'image', asset: {_ref: 'image-123', _type: 'reference'}},
|
|
924
|
-
openGraph: {
|
|
925
|
-
title: 'SEO Best Practices Guide',
|
|
926
|
-
description: 'Master SEO optimization',
|
|
927
|
-
image: {_type: 'image', asset: {_ref: 'image-123', _type: 'reference'}, alt: 'SEO Guide'},
|
|
928
|
-
type: 'article',
|
|
929
|
-
},
|
|
930
|
-
twitter: {
|
|
931
|
-
title: 'SEO Best Practices',
|
|
932
|
-
description: 'Learn SEO optimization',
|
|
933
|
-
image: {_type: 'image', asset: {_ref: 'image-123', _type: 'reference'}, alt: 'Guide'},
|
|
934
|
-
card: 'summary_large_image',
|
|
935
|
-
},
|
|
936
|
-
},
|
|
937
|
-
},
|
|
938
|
-
{
|
|
939
|
-
_id: 'preview-post-2',
|
|
940
|
-
_type: 'post',
|
|
941
|
-
title: 'Advanced Analytics Strategy',
|
|
942
|
-
slug: {current: 'advanced-analytics'},
|
|
943
|
-
_updatedAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
|
|
944
|
-
seo: {
|
|
945
|
-
title: 'Advanced Analytics',
|
|
946
|
-
description: 'Strategy tips',
|
|
947
|
-
keywords: ['analytics', 'data'],
|
|
948
|
-
openGraph: {
|
|
949
|
-
title: 'Analytics Guide',
|
|
950
|
-
},
|
|
951
|
-
},
|
|
952
|
-
},
|
|
953
|
-
{
|
|
954
|
-
_id: 'preview-page-1',
|
|
955
|
-
_type: 'page',
|
|
956
|
-
title: 'About Us',
|
|
957
|
-
slug: {current: 'about'},
|
|
958
|
-
_updatedAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(),
|
|
959
|
-
seo: {
|
|
960
|
-
title: 'About',
|
|
961
|
-
keywords: ['company', 'team'],
|
|
962
|
-
metaImage: {_type: 'image', asset: {_ref: 'image-456', _type: 'reference'}},
|
|
963
|
-
},
|
|
964
|
-
},
|
|
965
|
-
{
|
|
966
|
-
_id: 'preview-post-3',
|
|
967
|
-
_type: 'post',
|
|
968
|
-
title: 'Content Marketing Trends for 2024',
|
|
969
|
-
slug: {current: 'content-marketing-trends'},
|
|
970
|
-
_updatedAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
|
|
971
|
-
seo: {
|
|
972
|
-
title: 'Content Marketing Trends 2024',
|
|
973
|
-
description:
|
|
974
|
-
'Discover the latest content marketing trends and strategies to engage your audience effectively.',
|
|
975
|
-
keywords: ['content marketing', 'trends', 'strategy', 'engagement'],
|
|
976
|
-
metaImage: {_type: 'image', asset: {_ref: 'image-789', _type: 'reference'}},
|
|
977
|
-
openGraph: {
|
|
978
|
-
title: 'Content Marketing Trends 2024',
|
|
979
|
-
description: 'Latest trends in content marketing',
|
|
980
|
-
image: {_type: 'image', asset: {_ref: 'image-789', _type: 'reference'}, alt: 'Trends'},
|
|
981
|
-
type: 'article',
|
|
982
|
-
},
|
|
983
|
-
twitter: {
|
|
984
|
-
title: 'Content Marketing Trends',
|
|
985
|
-
description: 'Discover the latest trends',
|
|
986
|
-
card: 'summary',
|
|
987
|
-
},
|
|
988
|
-
},
|
|
989
|
-
},
|
|
990
|
-
{
|
|
991
|
-
_id: 'preview-post-4',
|
|
992
|
-
_type: 'product',
|
|
993
|
-
title: 'Pro Plan',
|
|
994
|
-
slug: {current: 'pro-plan'},
|
|
995
|
-
_updatedAt: new Date(Date.now() - 15 * 24 * 60 * 60 * 1000).toISOString(),
|
|
996
|
-
seo: {
|
|
997
|
-
title: 'Pro',
|
|
998
|
-
keywords: ['pricing'],
|
|
999
|
-
},
|
|
1000
|
-
},
|
|
1001
|
-
{
|
|
1002
|
-
_id: 'preview-page-2',
|
|
1003
|
-
_type: 'page',
|
|
1004
|
-
title: 'Contact',
|
|
1005
|
-
slug: {current: 'contact'},
|
|
1006
|
-
_updatedAt: new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(),
|
|
1007
|
-
seo: {
|
|
1008
|
-
openGraph: {
|
|
1009
|
-
title: 'Get in Touch',
|
|
1010
|
-
},
|
|
1011
|
-
},
|
|
1012
|
-
},
|
|
1013
|
-
{
|
|
1014
|
-
_id: 'preview-post-5',
|
|
1015
|
-
_type: 'post',
|
|
1016
|
-
title: 'Mobile Optimization Guide',
|
|
1017
|
-
slug: {current: 'mobile-optimization'},
|
|
1018
|
-
_updatedAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
|
|
1019
|
-
seo: {
|
|
1020
|
-
title: 'Mobile Optimization Guide: Best Practices for Responsive Design',
|
|
1021
|
-
description:
|
|
1022
|
-
'Complete guide to mobile optimization including responsive design, performance tips, and user experience best practices for modern web development.',
|
|
1023
|
-
keywords: ['mobile', 'optimization', 'responsive', 'performance'],
|
|
1024
|
-
metaImage: {_type: 'image', asset: {_ref: 'image-mobile', _type: 'reference'}},
|
|
1025
|
-
openGraph: {
|
|
1026
|
-
title: 'Mobile Optimization Best Practices',
|
|
1027
|
-
description: 'Master mobile web optimization',
|
|
1028
|
-
image: {_type: 'image', asset: {_ref: 'image-mobile', _type: 'reference'}, alt: 'Mobile'},
|
|
1029
|
-
type: 'article',
|
|
1030
|
-
},
|
|
1031
|
-
twitter: {
|
|
1032
|
-
title: 'Mobile Optimization Tips',
|
|
1033
|
-
description: 'Responsive design best practices',
|
|
1034
|
-
image: {_type: 'image', asset: {_ref: 'image-mobile', _type: 'reference'}, alt: 'Mobile'},
|
|
1035
|
-
card: 'summary_large_image',
|
|
1036
|
-
},
|
|
1037
|
-
},
|
|
1038
|
-
},
|
|
1039
|
-
]
|
|
1040
|
-
|
|
1041
|
-
// Calculate health scores and return
|
|
1042
|
-
return dummyDocs.map((doc) => ({
|
|
1043
|
-
...doc,
|
|
1044
|
-
health: calculateHealthScore(doc),
|
|
1045
|
-
}))
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
const SeoHealthDashboard: React.FC<SeoHealthDashboardProps> = ({
|
|
1049
|
-
icon = '📊',
|
|
1050
|
-
title = 'SEO Health Dashboard',
|
|
1051
|
-
description = 'Monitor and optimize SEO fields across all your documents',
|
|
1052
|
-
showTypeColumn = true,
|
|
1053
|
-
showDocumentId = true,
|
|
1054
|
-
queryTypes,
|
|
1055
|
-
queryRequireSeo = true,
|
|
1056
|
-
customQuery,
|
|
1057
|
-
apiVersion = '2023-01-01',
|
|
1058
|
-
licenseKey,
|
|
1059
|
-
typeLabels,
|
|
1060
|
-
typeColumnMode = 'badge',
|
|
1061
|
-
titleField,
|
|
1062
|
-
docBadge,
|
|
1063
|
-
loadingLicense,
|
|
1064
|
-
loadingDocuments,
|
|
1065
|
-
noDocuments,
|
|
1066
|
-
previewMode = false,
|
|
1067
|
-
openInPane = false,
|
|
1068
|
-
structureTool,
|
|
1069
|
-
}) => {
|
|
1070
|
-
const client = useClient({apiVersion})
|
|
1071
|
-
const [licenseStatus, setLicenseStatus] = useState<'loading' | 'valid' | 'invalid'>('loading')
|
|
1072
|
-
const [documents, setDocuments] = useState<DocumentWithSeoHealth[]>([])
|
|
1073
|
-
const [loading, setLoading] = useState(true)
|
|
1074
|
-
const [searchQuery, setSearchQuery] = useState('')
|
|
1075
|
-
const [filterStatus, setFilterStatus] = useState<string>('all')
|
|
1076
|
-
const [filterType, setFilterType] = useState<string>('all')
|
|
1077
|
-
const [sortBy, setSortBy] = useState<'score' | 'title'>('score')
|
|
1078
|
-
const [activePopover, setActivePopover] = useState<{
|
|
1079
|
-
top: number
|
|
1080
|
-
left: number
|
|
1081
|
-
issues: string[]
|
|
1082
|
-
} | null>(null)
|
|
1083
|
-
|
|
1084
|
-
const VALIDATION_ENDPOINT = 'https://sanity-plugin-seofields.thehardik.in/api/validate-license'
|
|
1085
|
-
const CACHE_TTL_MS = 60 * 60 * 1000 // 1 hour
|
|
1086
|
-
|
|
1087
|
-
const validateLicense = useCallback(
|
|
1088
|
-
async (forceRefresh = false) => {
|
|
1089
|
-
// Preview mode bypasses license validation
|
|
1090
|
-
if (previewMode) {
|
|
1091
|
-
setLicenseStatus('valid')
|
|
1092
|
-
return
|
|
1093
|
-
}
|
|
1094
|
-
|
|
1095
|
-
// No key provided
|
|
1096
|
-
if (!licenseKey) {
|
|
1097
|
-
setLicenseStatus('invalid')
|
|
1098
|
-
return
|
|
1099
|
-
}
|
|
1100
|
-
|
|
1101
|
-
const projectId = client.config().projectId ?? ''
|
|
1102
|
-
const cacheKey = `seofields_license_${projectId}`
|
|
1103
|
-
|
|
1104
|
-
if (forceRefresh) {
|
|
1105
|
-
try {
|
|
1106
|
-
sessionStorage.removeItem(cacheKey)
|
|
1107
|
-
} catch {
|
|
1108
|
-
// ignore storage errors
|
|
1109
|
-
}
|
|
1110
|
-
}
|
|
1111
|
-
|
|
1112
|
-
// Check sessionStorage cache
|
|
1113
|
-
if (!forceRefresh) {
|
|
1114
|
-
try {
|
|
1115
|
-
const cached = sessionStorage.getItem(cacheKey)
|
|
1116
|
-
if (cached) {
|
|
1117
|
-
const {valid, ts} = JSON.parse(cached) as {valid: boolean; ts: number}
|
|
1118
|
-
if (Date.now() - ts < CACHE_TTL_MS) {
|
|
1119
|
-
setLicenseStatus(valid ? 'valid' : 'invalid')
|
|
1120
|
-
return
|
|
1121
|
-
}
|
|
1122
|
-
}
|
|
1123
|
-
} catch {
|
|
1124
|
-
// ignore storage errors
|
|
1125
|
-
}
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
setLicenseStatus('loading')
|
|
1129
|
-
|
|
1130
|
-
try {
|
|
1131
|
-
const res = await fetch(VALIDATION_ENDPOINT, {
|
|
1132
|
-
method: 'POST',
|
|
1133
|
-
headers: {'Content-Type': 'application/json'},
|
|
1134
|
-
body: JSON.stringify({licenseKey, projectId}),
|
|
1135
|
-
})
|
|
1136
|
-
const valid = res.ok
|
|
1137
|
-
setLicenseStatus(valid ? 'valid' : 'invalid')
|
|
1138
|
-
try {
|
|
1139
|
-
sessionStorage.setItem(cacheKey, JSON.stringify({valid, ts: Date.now()}))
|
|
1140
|
-
} catch {
|
|
1141
|
-
// ignore storage errors
|
|
1142
|
-
}
|
|
1143
|
-
} catch {
|
|
1144
|
-
// Network error — fail open to avoid blocking legitimate users
|
|
1145
|
-
setLicenseStatus('valid')
|
|
1146
|
-
}
|
|
1147
|
-
},
|
|
1148
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1149
|
-
[licenseKey, previewMode],
|
|
1150
|
-
)
|
|
1151
|
-
|
|
1152
|
-
useEffect(() => {
|
|
1153
|
-
validateLicense()
|
|
1154
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1155
|
-
}, [licenseKey, previewMode])
|
|
1156
|
-
|
|
1157
|
-
const handleMouseEnterIssues = (el: HTMLDivElement | null, issues: string[]) => {
|
|
1158
|
-
if (!el) return
|
|
1159
|
-
const rect = el.getBoundingClientRect()
|
|
1160
|
-
const popoverWidth = 280
|
|
1161
|
-
const viewportWidth = window.innerWidth
|
|
1162
|
-
|
|
1163
|
-
// Align popover left edge to trigger left edge, clamp within viewport
|
|
1164
|
-
let left = rect.left
|
|
1165
|
-
if (left + popoverWidth > viewportWidth - 10) left = viewportWidth - popoverWidth - 10
|
|
1166
|
-
if (left < 10) left = 10
|
|
1167
|
-
|
|
1168
|
-
setActivePopover({top: rect.top, left, issues})
|
|
1169
|
-
}
|
|
1170
|
-
|
|
1171
|
-
useEffect(() => {
|
|
1172
|
-
const fetchDocuments = async () => {
|
|
1173
|
-
try {
|
|
1174
|
-
setLoading(true)
|
|
1175
|
-
|
|
1176
|
-
// Use dummy data in preview mode
|
|
1177
|
-
if (previewMode) {
|
|
1178
|
-
setDocuments(generateDummyData())
|
|
1179
|
-
return
|
|
1180
|
-
}
|
|
1181
|
-
|
|
1182
|
-
let groqQuery: string
|
|
1183
|
-
let params: Record<string, unknown> = {}
|
|
1184
|
-
|
|
1185
|
-
if (customQuery) {
|
|
1186
|
-
// Mode 3: fully custom GROQ (user-provided)
|
|
1187
|
-
groqQuery = customQuery
|
|
1188
|
-
} else if (queryTypes && queryTypes.length > 0) {
|
|
1189
|
-
// Mode 2: filter by specific document types (excluding drafts)
|
|
1190
|
-
const seoFilter = queryRequireSeo ? ' && seo != null' : ''
|
|
1191
|
-
const titleProj = buildTitleProjection(titleField)
|
|
1192
|
-
groqQuery = `*[_type in $types${seoFilter} && !(_id in path("drafts.**"))]{
|
|
1193
|
-
_id,
|
|
1194
|
-
_type,
|
|
1195
|
-
${titleProj},
|
|
1196
|
-
slug,
|
|
1197
|
-
seo,
|
|
1198
|
-
_updatedAt
|
|
1199
|
-
}`
|
|
1200
|
-
params = {types: queryTypes}
|
|
1201
|
-
} else {
|
|
1202
|
-
// Mode 1: default — all documents with an seo field (excluding drafts)
|
|
1203
|
-
const titleProj = buildTitleProjection(titleField)
|
|
1204
|
-
groqQuery = `*[seo != null && !(_id in path("drafts.**"))]{
|
|
1205
|
-
_id,
|
|
1206
|
-
_type,
|
|
1207
|
-
${titleProj},
|
|
1208
|
-
slug,
|
|
1209
|
-
seo,
|
|
1210
|
-
_updatedAt
|
|
1211
|
-
}`
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1215
|
-
const result = await client.fetch(groqQuery, params, {perspective: 'published'})
|
|
1216
|
-
|
|
1217
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1218
|
-
const docsWithHealth: DocumentWithSeoHealth[] = result.map((doc: any) => ({
|
|
1219
|
-
...doc,
|
|
1220
|
-
health: calculateHealthScore(doc),
|
|
1221
|
-
}))
|
|
1222
|
-
|
|
1223
|
-
setDocuments(docsWithHealth)
|
|
1224
|
-
} catch (error) {
|
|
1225
|
-
console.error('Error fetching documents:', error)
|
|
1226
|
-
} finally {
|
|
1227
|
-
setLoading(false)
|
|
1228
|
-
}
|
|
1229
|
-
}
|
|
1230
|
-
|
|
1231
|
-
fetchDocuments()
|
|
1232
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1233
|
-
}, [
|
|
1234
|
-
client,
|
|
1235
|
-
customQuery,
|
|
1236
|
-
queryRequireSeo,
|
|
1237
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1238
|
-
JSON.stringify(queryTypes),
|
|
1239
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1240
|
-
JSON.stringify(titleField),
|
|
1241
|
-
previewMode,
|
|
1242
|
-
])
|
|
1243
|
-
|
|
1244
|
-
const uniqueDocumentTypes = useMemo(() => {
|
|
1245
|
-
const types = new Set(documents.map((doc) => doc._type))
|
|
1246
|
-
return Array.from(types).sort()
|
|
1247
|
-
}, [documents])
|
|
1248
|
-
|
|
1249
|
-
const filteredAndSortedDocs = useMemo(() => {
|
|
1250
|
-
let filtered = documents
|
|
1251
|
-
|
|
1252
|
-
if (searchQuery) {
|
|
1253
|
-
filtered = filtered.filter(
|
|
1254
|
-
(doc) =>
|
|
1255
|
-
doc.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
1256
|
-
doc._id?.toLowerCase().includes(searchQuery.toLowerCase()),
|
|
1257
|
-
)
|
|
1258
|
-
}
|
|
1259
|
-
|
|
1260
|
-
if (filterStatus !== 'all') {
|
|
1261
|
-
filtered = filtered.filter((doc) => doc.health.status === filterStatus)
|
|
1262
|
-
}
|
|
1263
|
-
|
|
1264
|
-
if (filterType !== 'all') {
|
|
1265
|
-
filtered = filtered.filter((doc) => doc._type === filterType)
|
|
1266
|
-
}
|
|
1267
|
-
|
|
1268
|
-
const sorted = [...filtered].sort((a, b) => {
|
|
1269
|
-
if (sortBy === 'score') {
|
|
1270
|
-
return b.health.score - a.health.score
|
|
1271
|
-
}
|
|
1272
|
-
return (a.title || '').localeCompare(b.title || '')
|
|
1273
|
-
})
|
|
1274
|
-
|
|
1275
|
-
return sorted
|
|
1276
|
-
}, [documents, searchQuery, filterStatus, filterType, sortBy])
|
|
1277
|
-
|
|
1278
|
-
const stats = useMemo(() => {
|
|
1279
|
-
const total = documents.length
|
|
1280
|
-
const excellent = documents.filter((d) => d.health.score >= 80).length
|
|
1281
|
-
const good = documents.filter((d) => d.health.score >= 60 && d.health.score < 80).length
|
|
1282
|
-
const fair = documents.filter((d) => d.health.score >= 40 && d.health.score < 60).length
|
|
1283
|
-
const poor = documents.filter((d) => d.health.score > 0 && d.health.score < 40).length
|
|
1284
|
-
const missing = documents.filter((d) => d.health.score === 0).length
|
|
1285
|
-
|
|
1286
|
-
const avgScore =
|
|
1287
|
-
total > 0 ? Math.round(documents.reduce((sum, d) => sum + d.health.score, 0) / total) : 0
|
|
1288
|
-
|
|
1289
|
-
return {total, excellent, good, fair, poor, missing, avgScore}
|
|
1290
|
-
}, [documents])
|
|
1291
|
-
|
|
1292
|
-
const handleMouseLeave = useCallback(() => {
|
|
1293
|
-
setActivePopover(null)
|
|
1294
|
-
}, [])
|
|
1295
|
-
|
|
1296
|
-
return (
|
|
1297
|
-
<DashboardContainer>
|
|
1298
|
-
{licenseStatus === 'loading' && (
|
|
1299
|
-
<LoadingState style={{padding: '80px 24px'}}>
|
|
1300
|
-
<Spinner />
|
|
1301
|
-
{loadingLicense ?? 'Verifying license…'}
|
|
1302
|
-
</LoadingState>
|
|
1303
|
-
)}
|
|
1304
|
-
{licenseStatus === 'invalid' && (
|
|
1305
|
-
<UpgradeContainer>
|
|
1306
|
-
<UpgradeBox>
|
|
1307
|
-
{licenseKey ? (
|
|
1308
|
-
<>
|
|
1309
|
-
<UpgradeLock>❌</UpgradeLock>
|
|
1310
|
-
<UpgradeTitle>Invalid License Key</UpgradeTitle>
|
|
1311
|
-
<UpgradeText>
|
|
1312
|
-
The license key you provided is invalid or has been revoked. Please check your key
|
|
1313
|
-
and update it in the plugin config.
|
|
1314
|
-
</UpgradeText>
|
|
1315
|
-
<UpgradeCode>{`seofields({
|
|
1316
|
-
healthDashboard: {
|
|
1317
|
-
licenseKey: 'YOUR_LICENSE_KEY', // ← replace with a valid key
|
|
1318
|
-
},
|
|
1319
|
-
})`}</UpgradeCode>
|
|
1320
|
-
<UpgradeButton
|
|
1321
|
-
href="https://sanity-plugin-seofields.thehardik.in"
|
|
1322
|
-
target="_blank"
|
|
1323
|
-
rel="noopener noreferrer"
|
|
1324
|
-
>
|
|
1325
|
-
Get a New License Key →
|
|
1326
|
-
</UpgradeButton>
|
|
1327
|
-
<br />
|
|
1328
|
-
{/* eslint-disable-next-line react/jsx-no-bind */}
|
|
1329
|
-
<ReloadButton onClick={() => validateLicense(true)}>
|
|
1330
|
-
Click here If You Just Updated Your Key
|
|
1331
|
-
</ReloadButton>
|
|
1332
|
-
</>
|
|
1333
|
-
) : (
|
|
1334
|
-
<>
|
|
1335
|
-
<UpgradeLock>🔒</UpgradeLock>
|
|
1336
|
-
<UpgradeTitle>SEO Health Dashboard</UpgradeTitle>
|
|
1337
|
-
<UpgradeText>
|
|
1338
|
-
This feature requires a license key. Add your key to the plugin config to unlock
|
|
1339
|
-
the full dashboard.
|
|
1340
|
-
</UpgradeText>
|
|
1341
|
-
<UpgradeCode>{`// sanity.config.ts
|
|
1342
|
-
import { seofields } from 'sanity-plugin-seofields'
|
|
1343
|
-
|
|
1344
|
-
export default defineConfig({
|
|
1345
|
-
plugins: [
|
|
1346
|
-
seofields({
|
|
1347
|
-
healthDashboard: {
|
|
1348
|
-
licenseKey: 'SEOF-XXXX-XXXX-XXXX',
|
|
1349
|
-
},
|
|
1350
|
-
}),
|
|
1351
|
-
],
|
|
1352
|
-
})`}</UpgradeCode>
|
|
1353
|
-
<UpgradeButton
|
|
1354
|
-
href="https://sanity-plugin-seofields.thehardik.in"
|
|
1355
|
-
target="_blank"
|
|
1356
|
-
rel="noopener noreferrer"
|
|
1357
|
-
>
|
|
1358
|
-
Get a License Key →
|
|
1359
|
-
</UpgradeButton>
|
|
1360
|
-
</>
|
|
1361
|
-
)}
|
|
1362
|
-
</UpgradeBox>
|
|
1363
|
-
</UpgradeContainer>
|
|
1364
|
-
)}
|
|
1365
|
-
{licenseStatus === 'valid' && (
|
|
1366
|
-
<>
|
|
1367
|
-
{/* Header */}
|
|
1368
|
-
<PageHeader>
|
|
1369
|
-
<PageTitle>
|
|
1370
|
-
<span>
|
|
1371
|
-
{icon} {title}
|
|
1372
|
-
</span>
|
|
1373
|
-
{previewMode && <PreviewBadge>Preview Mode</PreviewBadge>}
|
|
1374
|
-
</PageTitle>
|
|
1375
|
-
<PageSubtitle>{description}</PageSubtitle>
|
|
1376
|
-
</PageHeader>
|
|
1377
|
-
{/* Stats Grid */}
|
|
1378
|
-
{!loading && (
|
|
1379
|
-
<StatsGrid>
|
|
1380
|
-
<StatCard>
|
|
1381
|
-
<StatLabel>Total Docs</StatLabel>
|
|
1382
|
-
<StatValue>{stats.total}</StatValue>
|
|
1383
|
-
</StatCard>
|
|
1384
|
-
<StatCard>
|
|
1385
|
-
<StatLabel>Avg Score</StatLabel>
|
|
1386
|
-
<StatValue>{stats.avgScore}%</StatValue>
|
|
1387
|
-
</StatCard>
|
|
1388
|
-
<StatCard $accent="#10b981">
|
|
1389
|
-
<StatLabel>Excellent (80+)</StatLabel>
|
|
1390
|
-
<StatValue>{stats.excellent}</StatValue>
|
|
1391
|
-
</StatCard>
|
|
1392
|
-
<StatCard $accent="#f59e0b">
|
|
1393
|
-
<StatLabel>Good (60–79)</StatLabel>
|
|
1394
|
-
<StatValue>{stats.good}</StatValue>
|
|
1395
|
-
</StatCard>
|
|
1396
|
-
<StatCard $accent="#f97316">
|
|
1397
|
-
<StatLabel>Fair (40–59)</StatLabel>
|
|
1398
|
-
<StatValue>{stats.fair}</StatValue>
|
|
1399
|
-
</StatCard>
|
|
1400
|
-
<StatCard $accent="#ef4444">
|
|
1401
|
-
<StatLabel>Poor / Missing</StatLabel>
|
|
1402
|
-
<StatValue>{stats.poor + stats.missing}</StatValue>
|
|
1403
|
-
</StatCard>
|
|
1404
|
-
</StatsGrid>
|
|
1405
|
-
)}
|
|
1406
|
-
{/* Controls */}
|
|
1407
|
-
<ControlsBar>
|
|
1408
|
-
<SearchWrapper>
|
|
1409
|
-
<SearchIconSvg>
|
|
1410
|
-
<svg width="14" height="14" viewBox="0 0 20 20" fill="currentColor">
|
|
1411
|
-
<path
|
|
1412
|
-
fillRule="evenodd"
|
|
1413
|
-
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
|
|
1414
|
-
clipRule="evenodd"
|
|
1415
|
-
/>
|
|
1416
|
-
</svg>
|
|
1417
|
-
</SearchIconSvg>
|
|
1418
|
-
<SearchInput
|
|
1419
|
-
placeholder="Search documents..."
|
|
1420
|
-
value={searchQuery}
|
|
1421
|
-
// eslint-disable-next-line react/jsx-no-bind
|
|
1422
|
-
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
|
1423
|
-
/>
|
|
1424
|
-
</SearchWrapper>
|
|
1425
|
-
<StyledSelect
|
|
1426
|
-
value={filterStatus}
|
|
1427
|
-
// eslint-disable-next-line react/jsx-no-bind
|
|
1428
|
-
onChange={(e) => setFilterStatus(e.currentTarget.value)}
|
|
1429
|
-
>
|
|
1430
|
-
<option value="all">All Status</option>
|
|
1431
|
-
<option value="excellent">Excellent</option>
|
|
1432
|
-
<option value="good">Good</option>
|
|
1433
|
-
<option value="fair">Fair</option>
|
|
1434
|
-
<option value="poor">Poor</option>
|
|
1435
|
-
<option value="missing">Missing</option>
|
|
1436
|
-
</StyledSelect>
|
|
1437
|
-
{uniqueDocumentTypes.length > 1 && (
|
|
1438
|
-
<StyledSelect
|
|
1439
|
-
value={filterType}
|
|
1440
|
-
// eslint-disable-next-line react/jsx-no-bind
|
|
1441
|
-
onChange={(e) => setFilterType(e.currentTarget.value)}
|
|
1442
|
-
>
|
|
1443
|
-
<option value="all">All Types</option>
|
|
1444
|
-
{uniqueDocumentTypes.map((type) => (
|
|
1445
|
-
<option key={type} value={type}>
|
|
1446
|
-
{resolveTypeLabel(type, typeLabels)}
|
|
1447
|
-
</option>
|
|
1448
|
-
))}
|
|
1449
|
-
</StyledSelect>
|
|
1450
|
-
)}
|
|
1451
|
-
<StyledSelect
|
|
1452
|
-
value={sortBy}
|
|
1453
|
-
// eslint-disable-next-line react/jsx-no-bind
|
|
1454
|
-
onChange={(e) => setSortBy(e.currentTarget.value as 'score' | 'title')}
|
|
1455
|
-
>
|
|
1456
|
-
<option value="score">Sort by Score</option>
|
|
1457
|
-
<option value="title">Sort by Title</option>
|
|
1458
|
-
</StyledSelect>
|
|
1459
|
-
</ControlsBar>
|
|
1460
|
-
{/* Documents Table */}
|
|
1461
|
-
<TableCard>
|
|
1462
|
-
{loading && (
|
|
1463
|
-
<LoadingState>
|
|
1464
|
-
<Spinner />
|
|
1465
|
-
{loadingDocuments ?? 'Loading documents…'}
|
|
1466
|
-
</LoadingState>
|
|
1467
|
-
)}
|
|
1468
|
-
{!loading &&
|
|
1469
|
-
(filteredAndSortedDocs.length === 0 ? (
|
|
1470
|
-
<EmptyState>{noDocuments ?? 'No documents found'}</EmptyState>
|
|
1471
|
-
) : (
|
|
1472
|
-
<>
|
|
1473
|
-
<TableHeader>
|
|
1474
|
-
<ColTitle>Title</ColTitle>
|
|
1475
|
-
{showTypeColumn && <ColType>Type</ColType>}
|
|
1476
|
-
<ColScore>Score</ColScore>
|
|
1477
|
-
<ColIssues>Top Issues</ColIssues>
|
|
1478
|
-
</TableHeader>
|
|
1479
|
-
{filteredAndSortedDocs.map((doc) => {
|
|
1480
|
-
return (
|
|
1481
|
-
<TableRow key={doc._id}>
|
|
1482
|
-
<ColTitle>
|
|
1483
|
-
<TitleWrapper>
|
|
1484
|
-
<TitleCell>
|
|
1485
|
-
{openInPane ? (
|
|
1486
|
-
<DocTitleAnchorPane id={doc._id} type={doc._type}>
|
|
1487
|
-
{doc.title || 'Untitled'}
|
|
1488
|
-
</DocTitleAnchorPane>
|
|
1489
|
-
) : (
|
|
1490
|
-
<DocTitleAnchor id={doc._id} type={doc._type} structureTool={structureTool}>
|
|
1491
|
-
{doc.title || 'Untitled'}
|
|
1492
|
-
</DocTitleAnchor>
|
|
1493
|
-
)}
|
|
1494
|
-
{showDocumentId && <DocId>{doc._id}</DocId>}
|
|
1495
|
-
</TitleCell>
|
|
1496
|
-
{docBadge && (
|
|
1497
|
-
<DocBadgeRenderer
|
|
1498
|
-
doc={doc as DocumentWithSeoHealth & Record<string, unknown>}
|
|
1499
|
-
docBadge={docBadge}
|
|
1500
|
-
/>
|
|
1501
|
-
)}
|
|
1502
|
-
</TitleWrapper>
|
|
1503
|
-
</ColTitle>
|
|
1504
|
-
{showTypeColumn && (
|
|
1505
|
-
<ColType>
|
|
1506
|
-
{typeColumnMode === 'text' ? (
|
|
1507
|
-
<TypeText>{resolveTypeLabel(doc._type, typeLabels)}</TypeText>
|
|
1508
|
-
) : (
|
|
1509
|
-
(() => {
|
|
1510
|
-
const typeColor = getTypeColor(doc._type)
|
|
1511
|
-
return (
|
|
1512
|
-
<TypeBadge $bgColor={typeColor.bg} $textColor={typeColor.text}>
|
|
1513
|
-
{resolveTypeLabel(doc._type, typeLabels)}
|
|
1514
|
-
</TypeBadge>
|
|
1515
|
-
)
|
|
1516
|
-
})()
|
|
1517
|
-
)}
|
|
1518
|
-
</ColType>
|
|
1519
|
-
)}
|
|
1520
|
-
<ColScore>
|
|
1521
|
-
<ScoreBadge $score={doc.health.score}>{doc.health.score}%</ScoreBadge>
|
|
1522
|
-
</ColScore>
|
|
1523
|
-
<ColIssues>
|
|
1524
|
-
{doc.health.issues.slice(0, 2).map((issue) => (
|
|
1525
|
-
<IssueTag key={`issue-${doc._id}-${issue}`}>• {issue}</IssueTag>
|
|
1526
|
-
))}
|
|
1527
|
-
{doc.health.issues.length > 2 && (
|
|
1528
|
-
<MoreIssuesWrapper
|
|
1529
|
-
// eslint-disable-next-line react/jsx-no-bind
|
|
1530
|
-
onMouseEnter={function (e) {
|
|
1531
|
-
handleMouseEnterIssues(
|
|
1532
|
-
e.currentTarget as HTMLDivElement,
|
|
1533
|
-
doc.health.issues,
|
|
1534
|
-
)
|
|
1535
|
-
}}
|
|
1536
|
-
onMouseLeave={handleMouseLeave}
|
|
1537
|
-
>
|
|
1538
|
-
<MoreIssues>+{doc.health.issues.length - 2} more issues</MoreIssues>
|
|
1539
|
-
</MoreIssuesWrapper>
|
|
1540
|
-
)}
|
|
1541
|
-
</ColIssues>
|
|
1542
|
-
</TableRow>
|
|
1543
|
-
)
|
|
1544
|
-
})}
|
|
1545
|
-
</>
|
|
1546
|
-
))}
|
|
1547
|
-
</TableCard>
|
|
1548
|
-
{/* Single shared popover rendered outside the table */}
|
|
1549
|
-
{activePopover && (
|
|
1550
|
-
<IssuesPopover
|
|
1551
|
-
style={{
|
|
1552
|
-
top: activePopover.top,
|
|
1553
|
-
left: activePopover.left,
|
|
1554
|
-
transform: 'translateY(calc(-100% - 10px))',
|
|
1555
|
-
}}
|
|
1556
|
-
>
|
|
1557
|
-
{activePopover.issues.map((issue) => (
|
|
1558
|
-
<PopoverIssueItem key={issue}>⚠️ {issue}</PopoverIssueItem>
|
|
1559
|
-
))}
|
|
1560
|
-
</IssuesPopover>
|
|
1561
|
-
)}{' '}
|
|
1562
|
-
</>
|
|
1563
|
-
)}{' '}
|
|
1564
|
-
</DashboardContainer>
|
|
1565
|
-
)
|
|
1566
|
-
}
|
|
1567
|
-
|
|
1568
|
-
export default SeoHealthDashboard
|