@gscdump/nuxt 0.19.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 (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +138 -0
  3. package/app/assets/css/main.css +2 -0
  4. package/app/components/GscAnalyzerPanel.vue +94 -0
  5. package/app/components/GscAnonymizationBanner.vue +46 -0
  6. package/app/components/GscBootProgress.vue +297 -0
  7. package/app/components/GscCommandPalette.vue +77 -0
  8. package/app/components/GscDashboardPage.vue +26 -0
  9. package/app/components/GscEngineBadge.vue +28 -0
  10. package/app/components/GscHero.vue +215 -0
  11. package/app/components/GscLazyTopResult.vue +45 -0
  12. package/app/components/GscPerformanceChart.vue +532 -0
  13. package/app/components/GscPositionDistributionChart.vue +63 -0
  14. package/app/components/GscQueryLabel.vue +63 -0
  15. package/app/components/GscSitePageHeader.vue +40 -0
  16. package/app/components/GscSourceDebugPanel.vue +195 -0
  17. package/app/components/GscSyncStatusCell.vue +54 -0
  18. package/app/components/GscTopRollupCard.vue +90 -0
  19. package/app/composables/_useGscBackfill.ts +111 -0
  20. package/app/composables/_useGscQueryDispatcher.ts +122 -0
  21. package/app/composables/_useGscResource.ts +114 -0
  22. package/app/composables/_useGscSharedSiteResource.ts +136 -0
  23. package/app/composables/useGscAnalytics.ts +197 -0
  24. package/app/composables/useGscAnalyticsClient.ts +9 -0
  25. package/app/composables/useGscAnalyticsConfig.ts +8 -0
  26. package/app/composables/useGscAnalyticsSourceInfo.ts +143 -0
  27. package/app/composables/useGscAnalyzer.ts +374 -0
  28. package/app/composables/useGscAnalyzerBatch.ts +106 -0
  29. package/app/composables/useGscAnalyzerDefs.ts +118 -0
  30. package/app/composables/useGscAuth.ts +115 -0
  31. package/app/composables/useGscConsoleUrl.ts +45 -0
  32. package/app/composables/useGscCountries.ts +47 -0
  33. package/app/composables/useGscCurrentSite.ts +15 -0
  34. package/app/composables/useGscEngine.ts +16 -0
  35. package/app/composables/useGscInspectionHistory.ts +42 -0
  36. package/app/composables/useGscInspections.ts +52 -0
  37. package/app/composables/useGscPanelContext.ts +24 -0
  38. package/app/composables/useGscParquetTable.ts +199 -0
  39. package/app/composables/useGscPeriod.ts +243 -0
  40. package/app/composables/useGscQuery.ts +290 -0
  41. package/app/composables/useGscQueryDispatcher.ts +122 -0
  42. package/app/composables/useGscRequestIndexingInspect.ts +20 -0
  43. package/app/composables/useGscResource.ts +114 -0
  44. package/app/composables/useGscRollup.ts +252 -0
  45. package/app/composables/useGscRollupTable.ts +78 -0
  46. package/app/composables/useGscRowQuery.ts +56 -0
  47. package/app/composables/useGscSearchAppearance.ts +47 -0
  48. package/app/composables/useGscSitemapHistory.ts +41 -0
  49. package/app/composables/useGscSitemaps.ts +45 -0
  50. package/app/composables/useGscTableState.ts +119 -0
  51. package/app/plugins/analytics.ts +57 -0
  52. package/app/utils/anonymization.ts +24 -0
  53. package/app/utils/country-names.ts +56 -0
  54. package/app/utils/gsc-constants.ts +10 -0
  55. package/app/utils/gsc-error.ts +58 -0
  56. package/app/utils/gsc-fetch.ts +94 -0
  57. package/app/utils/gsc-filters.ts +32 -0
  58. package/app/utils/gsc-rows.ts +72 -0
  59. package/app/utils/position.ts +7 -0
  60. package/app/utils/setup-gsc-fetch-auth.ts +62 -0
  61. package/module.ts +81 -0
  62. package/nuxt.config.ts +36 -0
  63. package/package.json +75 -0
  64. package/types.ts +114 -0
@@ -0,0 +1,532 @@
1
+ <script lang="ts" setup>
2
+ // 4-metric overlay chart for GSC daily data. Ported from nuxtseo-pro's
3
+ // ProGraphGsc. Each metric lives in its own VisXYContainer stacked in the
4
+ // same grid cell so each uses the full chart height — Unovis's single
5
+ // container doesn't support independent y-domains per series.
6
+ //
7
+ // Key features:
8
+ // - Optional previous-period comparison: prev rows are merged by calendar
9
+ // date (prev.date + 1 year = current.date) and exposed via dashed lines.
10
+ // - Calendar-aware tick planner: picks per-span tick count + format
11
+ // ("Mon 12" / "Jan 12" / "Jan"+year) so x-axis labels don't crowd.
12
+ // - Estimated-region overlay: dims the last GSC_STABLE_LATENCY_DAYS so
13
+ // viewers don't mistake GSC's 3-day finalization lag for a real dip.
14
+ // - Optional `fullValue` / `fullPrevValue` keep y-domains anchored to the
15
+ // full period when the user zooms — scale doesn't jump per view.
16
+
17
+ import { Scale, TextAlign } from '@unovis/ts'
18
+ import { VisArea, VisAxis, VisCrosshair, VisLine, VisTooltip, VisXYContainer } from '@unovis/vue'
19
+ import { GSC_STABLE_LATENCY_DAYS } from '../utils/gsc-constants'
20
+
21
+ interface DataRow {
22
+ date: string
23
+ clicks: number
24
+ impressions: number
25
+ position?: number
26
+ ctr?: number
27
+ }
28
+
29
+ const {
30
+ value,
31
+ prevValue = null,
32
+ fullValue,
33
+ fullPrevValue,
34
+ columns = ['clicks', 'impressions'],
35
+ height,
36
+ loading: loadingProp = false,
37
+ } = defineProps<{
38
+ value: DataRow[]
39
+ prevValue?: DataRow[] | null
40
+ /** Which metric layers to render. Default renders clicks + impressions. */
41
+ columns?: string[]
42
+ height?: number | string
43
+ loading?: boolean
44
+ /** Full (unzoomed) series — when provided, y-domains stay anchored to the full range. */
45
+ fullValue?: DataRow[]
46
+ fullPrevValue?: DataRow[] | null
47
+ }>()
48
+
49
+ const emit = defineEmits<{
50
+ tooltip: [data: DataRow | null, prev: DataRow | null, isEstimated: boolean]
51
+ }>()
52
+
53
+ const loading = computed(() => loadingProp)
54
+
55
+ const gscColors = {
56
+ clicks: { line: 'rgba(59, 130, 246, 0.9)', area: 'url(#gradient-clicks)', prev: 'rgba(59, 130, 246, 0.3)' },
57
+ impressions: { line: 'rgba(168, 85, 247, 0.7)', area: 'url(#gradient-impressions)', prev: 'rgba(168, 85, 247, 0.3)' },
58
+ ctr: { line: 'rgba(34, 197, 94, 0.9)', prev: 'rgba(34, 197, 94, 0.3)' },
59
+ position: { line: 'rgba(251, 146, 60, 0.9)', prev: 'rgba(251, 146, 60, 0.3)' },
60
+ }
61
+
62
+ const margin = { left: 0, right: 0, top: 0, bottom: 36 }
63
+ const chartHeight = computed(() => Number(height) || 220)
64
+ const chartInnerHeight = computed(() => chartHeight.value - margin.top - margin.bottom)
65
+
66
+ const svgDefs = `
67
+ <linearGradient id="gradient-clicks" gradientTransform="rotate(90)">
68
+ <stop offset="0%" stop-color="rgba(59, 130, 246, 0.25)" />
69
+ <stop offset="100%" stop-color="rgba(59, 130, 246, 0.01)" />
70
+ </linearGradient>
71
+ <linearGradient id="gradient-impressions" gradientTransform="rotate(90)">
72
+ <stop offset="0%" stop-color="rgba(168, 85, 247, 0.2)" />
73
+ <stop offset="100%" stop-color="rgba(168, 85, 247, 0.01)" />
74
+ </linearGradient>`
75
+
76
+ type MergedRow = DataRow & { prev?: DataRow }
77
+ const mergedValue = computed<MergedRow[]>(() => {
78
+ if (!value.length)
79
+ return []
80
+ if (!prevValue?.length)
81
+ return value.slice()
82
+ const prevByDate = new Map<string, DataRow>()
83
+ for (const row of prevValue) prevByDate.set(row.date, row)
84
+ return value.map((row) => {
85
+ const [y, m, d] = row.date.split('-')
86
+ const key = `${Number(y) - 1}-${m}-${d}`
87
+ const match = prevByDate.get(key)
88
+ return match ? { ...row, prev: match } : row
89
+ })
90
+ })
91
+
92
+ const hasComparison = computed(() => mergedValue.value.some(r => r.prev))
93
+
94
+ // NaN y-values break the cubic curve (d3-shape's default `defined` skips them),
95
+ // so the dashed line only renders across indices with matching prev data.
96
+ const prevClicks = (d: MergedRow) => d.prev?.clicks ?? Number.NaN
97
+ const prevImpressions = (d: MergedRow) => d.prev?.impressions ?? Number.NaN
98
+ const prevCtr = (d: MergedRow) => d.prev?.ctr != null ? d.prev.ctr * 100 : Number.NaN
99
+ const prevPosition = (d: MergedRow) => d.prev?.position ?? Number.NaN
100
+
101
+ interface TickPlan {
102
+ indices: number[]
103
+ format: (date: Date, i: number, firstYear: number) => string
104
+ }
105
+
106
+ const weekdayDayFmt = new Intl.DateTimeFormat(undefined, { weekday: 'short', day: 'numeric' })
107
+ const monthDayFmt = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' })
108
+ const monthFmt = new Intl.DateTimeFormat(undefined, { month: 'short' })
109
+ const monthYearFmt = new Intl.DateTimeFormat(undefined, { month: 'short', year: '2-digit' })
110
+
111
+ const tickPlan = computed<TickPlan>(() => {
112
+ const len = value.length
113
+ if (len <= 1)
114
+ return { indices: [0], format: d => monthDayFmt.format(d) }
115
+
116
+ if (len <= 10)
117
+ return { indices: value.map((_, i) => i), format: d => weekdayDayFmt.format(d) }
118
+
119
+ if (len <= 35) {
120
+ const step = Math.max(1, Math.ceil(len / 5))
121
+ const indices: number[] = []
122
+ for (let i = 0; i < len; i += step) indices.push(i)
123
+ if (indices[indices.length - 1] !== len - 1)
124
+ indices.push(len - 1)
125
+ return { indices, format: d => monthDayFmt.format(d) }
126
+ }
127
+
128
+ if (len <= 120) {
129
+ const step = 14
130
+ const indices: number[] = []
131
+ for (let i = 0; i < len; i += step) indices.push(i)
132
+ return { indices, format: d => monthDayFmt.format(d) }
133
+ }
134
+
135
+ // Long span: first-of-month ticks.
136
+ const indices: number[] = []
137
+ let lastMonth = ''
138
+ for (let i = 0; i < len; i++) {
139
+ const m = value[i]?.date.slice(0, 7)
140
+ if (!m)
141
+ continue
142
+ if (m !== lastMonth) {
143
+ indices.push(i)
144
+ lastMonth = m
145
+ }
146
+ }
147
+ if (indices.length < 2)
148
+ return { indices: [0, Math.floor(len / 2), len - 1], format: d => monthDayFmt.format(d) }
149
+
150
+ if (indices.length > 14) {
151
+ const keep = Math.ceil(indices.length / 12)
152
+ const thinned = indices.filter((_, i) => i % keep === 0)
153
+ return {
154
+ indices: thinned,
155
+ format: (d, i, firstYear) => (d.getMonth() === 0 || i === 0) && d.getFullYear() !== firstYear
156
+ ? monthYearFmt.format(d)
157
+ : monthFmt.format(d),
158
+ }
159
+ }
160
+ return {
161
+ indices,
162
+ format: (d, i, _firstYear) => (d.getMonth() === 0 && i > 0) || (i === 0 && d.getMonth() !== 0 && indices.length > 6)
163
+ ? monthYearFmt.format(d)
164
+ : monthFmt.format(d),
165
+ }
166
+ })
167
+
168
+ const firstTickYear = computed(() => {
169
+ const first = tickPlan.value.indices[0]
170
+ const row = first == null ? undefined : value[first]
171
+ if (!row?.date)
172
+ return new Date().getFullYear()
173
+ return Number(row.date.slice(0, 4))
174
+ })
175
+
176
+ function tickFormat(d: number) {
177
+ const idx = Math.round(d)
178
+ if (idx < 0 || idx >= value.length)
179
+ return ''
180
+ const row = value[idx]
181
+ if (!row?.date)
182
+ return ''
183
+ const tickIdx = tickPlan.value.indices.indexOf(idx)
184
+ return tickPlan.value.format(new Date(row.date), tickIdx, firstTickYear.value)
185
+ }
186
+
187
+ // Unstable region: compare YYYY-MM-DD strings directly — avoid Date round-trips
188
+ // that shift across timezones (AEST → UTC can drop the date back a day).
189
+ const unstableCutoffDate = computed(() => {
190
+ const pstStr = new Date().toLocaleDateString('en-CA', { timeZone: 'America/Los_Angeles' })
191
+ const parts = pstStr.split('-').map(Number)
192
+ const y = parts[0] ?? new Date().getFullYear()
193
+ const m = parts[1] ?? 1
194
+ const d = parts[2] ?? 1
195
+ const cutoff = new Date(y, m - 1, d - GSC_STABLE_LATENCY_DAYS)
196
+ return `${cutoff.getFullYear()}-${String(cutoff.getMonth() + 1).padStart(2, '0')}-${String(cutoff.getDate()).padStart(2, '0')}`
197
+ })
198
+
199
+ const unstableCount = computed(() => {
200
+ if (!value.length)
201
+ return 0
202
+ const cutoff = unstableCutoffDate.value
203
+ let count = 0
204
+ for (let i = value.length - 1; i >= 0; i--) {
205
+ const row = value[i]
206
+ if (row && row.date > cutoff)
207
+ count++
208
+ else break
209
+ }
210
+ return count
211
+ })
212
+
213
+ const unstableWidthPct = computed(() => {
214
+ if (!unstableCount.value || value.length < 2)
215
+ return 0
216
+ return (unstableCount.value - 0.5) / (value.length - 1) * 100
217
+ })
218
+
219
+ function crosshairTemplate(d: DataRow) {
220
+ const idx = mergedValue.value.findIndex(r => r.date === d.date)
221
+ const prev = idx >= 0 ? (mergedValue.value[idx]?.prev ?? null) : null
222
+ const isEstimated = unstableCount.value > 0 && idx >= mergedValue.value.length - unstableCount.value
223
+ nextTick(() => emit('tooltip', d, prev, isEstimated))
224
+ return ''
225
+ }
226
+
227
+ function handleMouseLeave() {
228
+ emit('tooltip', null, null, false)
229
+ }
230
+
231
+ const allData = computed(() => {
232
+ const cur = fullValue ?? value
233
+ const prev = fullPrevValue !== undefined ? fullPrevValue : prevValue
234
+ return [...cur, ...(prev || [])]
235
+ })
236
+
237
+ const clicksDomain = computed<[number, number]>(() => {
238
+ if (!allData.value.length)
239
+ return [0, 1]
240
+ const max = Math.max(...allData.value.map(d => d.clicks), 1)
241
+ return [0, max * 1.1]
242
+ })
243
+
244
+ const impressionsDomain = computed<[number, number]>(() => {
245
+ if (!allData.value.length)
246
+ return [0, 1]
247
+ const max = Math.max(...allData.value.map(d => d.impressions), 1)
248
+ return [0, max * 1.1]
249
+ })
250
+
251
+ const ctrScale = computed(() => {
252
+ const ctrs = allData.value.map(d => (d.ctr ?? 0) * 100).filter(c => c > 0)
253
+ const min = ctrs.length ? Math.min(...ctrs) : 0
254
+ const max = ctrs.length ? Math.max(...ctrs) : 10
255
+ const padding = (max - min) * 0.1 || 1
256
+ return Scale.scaleLinear()
257
+ .domain([Math.max(0, min - padding), max + padding])
258
+ .range([0, chartInnerHeight.value])
259
+ })
260
+
261
+ // Position scale inverted: lower GSC position (better rank) = higher on chart.
262
+ const positionScale = computed(() => {
263
+ const positions = allData.value.map(d => d.position ?? 0).filter(p => p > 0)
264
+ const min = positions.length ? Math.min(...positions) : 1
265
+ const max = positions.length ? Math.max(...positions) : 100
266
+ const padding = (max - min) * 0.1 || 1
267
+ return Scale.scaleLinear()
268
+ .domain([Math.max(1, min - padding), max + padding])
269
+ .range([chartInnerHeight.value, 0])
270
+ })
271
+
272
+ // Force re-mount all containers when data shape changes — Unovis doesn't
273
+ // always rescale cleanly on same-length but different-range transitions.
274
+ const chartKey = computed(() => {
275
+ const len = value.length
276
+ const first = value[0]?.date ?? ''
277
+ const last = value[len - 1]?.date ?? ''
278
+ return `${len}-${first}-${last}`
279
+ })
280
+
281
+ const x = (_d: DataRow, i: number) => i
282
+ const clicks = (d: DataRow) => d.clicks
283
+ const impressions = (d: DataRow) => d.impressions
284
+ const ctr = (d: DataRow) => (d.ctr ?? 0) * 100
285
+ const position = (d: DataRow) => d.position ?? 0
286
+ </script>
287
+
288
+ <template>
289
+ <div
290
+ data-ui="GscPerformanceChart"
291
+ class="gsc-chart"
292
+ role="img"
293
+ aria-label="Search Console performance chart"
294
+ :style="{ height: `${chartHeight}px` }"
295
+ @mouseleave="handleMouseLeave"
296
+ >
297
+ <div v-if="loading" class="loading-skeleton">
298
+ <div class="flex-1 flex items-end gap-1">
299
+ <div v-for="i in 30" :key="i" class="flex-1 h-full flex">
300
+ <UiSkeleton type="bar" :index="i" />
301
+ </div>
302
+ </div>
303
+ <div class="flex justify-between mt-3">
304
+ <UiSkeleton v-for="i in 5" :key="i" class="h-3 w-12" />
305
+ </div>
306
+ </div>
307
+
308
+ <div v-else-if="value.length < 2" class="flex flex-col items-center justify-center h-full text-center gap-1">
309
+ <p class="text-sm text-muted">
310
+ {{ value.length === 0 ? 'No data for this period' : 'Only 1 day of data so far' }}
311
+ </p>
312
+ <p v-if="value.length === 1" class="text-xs text-dimmed">
313
+ The chart will appear once more days are available
314
+ </p>
315
+ </div>
316
+
317
+ <ClientOnly v-else>
318
+ <div
319
+ v-if="unstableWidthPct > 0"
320
+ class="estimated-region"
321
+ :style="{ width: `${unstableWidthPct}%`, bottom: `${margin.bottom}px` }"
322
+ >
323
+ <div class="estimated-tint" />
324
+ <div class="estimated-separator" />
325
+ </div>
326
+
327
+ <VisXYContainer
328
+ v-if="columns.includes('impressions')"
329
+ :key="`impressions-${chartKey}`"
330
+ :height="chartHeight"
331
+ :data="mergedValue"
332
+ :svg-defs="svgDefs"
333
+ :margin="margin"
334
+ :auto-margin="false"
335
+ class="chart-layer"
336
+ :y-domain="impressionsDomain"
337
+ >
338
+ <VisLine
339
+ v-if="hasComparison"
340
+ curve-type="monotoneX"
341
+ :x="x"
342
+ :y="prevImpressions"
343
+ :color="gscColors.impressions.prev"
344
+ :line-width="1.5"
345
+ :line-dash-array="[6, 4]"
346
+ />
347
+ <VisArea :color="gscColors.impressions.area" curve-type="monotoneX" :x="x" :y="impressions" />
348
+ <VisLine curve-type="monotoneX" :x="x" :y="impressions" :color="gscColors.impressions.line" :line-width="2" />
349
+ </VisXYContainer>
350
+
351
+ <VisXYContainer
352
+ v-if="columns.includes('clicks')"
353
+ :key="`clicks-${chartKey}`"
354
+ :height="chartHeight"
355
+ :data="mergedValue"
356
+ :svg-defs="svgDefs"
357
+ :margin="margin"
358
+ :auto-margin="false"
359
+ class="chart-layer"
360
+ :y-domain="clicksDomain"
361
+ >
362
+ <VisLine
363
+ v-if="hasComparison"
364
+ curve-type="monotoneX"
365
+ :x="x"
366
+ :y="prevClicks"
367
+ :color="gscColors.clicks.prev"
368
+ :line-width="1.5"
369
+ :line-dash-array="[6, 4]"
370
+ />
371
+ <VisArea :color="gscColors.clicks.area" curve-type="monotoneX" :x="x" :y="clicks" />
372
+ <VisLine curve-type="monotoneX" :x="x" :y="clicks" :color="gscColors.clicks.line" :line-width="2" />
373
+ </VisXYContainer>
374
+
375
+ <VisXYContainer
376
+ v-if="columns.includes('ctr')"
377
+ :key="`ctr-${chartKey}`"
378
+ :height="chartHeight"
379
+ :data="mergedValue"
380
+ :margin="margin"
381
+ :auto-margin="false"
382
+ class="chart-layer"
383
+ >
384
+ <VisLine
385
+ v-if="hasComparison"
386
+ curve-type="monotoneX"
387
+ :x="x"
388
+ :y="prevCtr"
389
+ :y-scale="ctrScale"
390
+ :color="gscColors.ctr.prev"
391
+ :line-width="1.5"
392
+ :line-dash-array="[6, 4]"
393
+ />
394
+ <VisLine curve-type="monotoneX" :x="x" :y="ctr" :y-scale="ctrScale" :color="gscColors.ctr.line" :line-width="2" />
395
+ </VisXYContainer>
396
+
397
+ <VisXYContainer
398
+ v-if="columns.includes('position')"
399
+ :key="`position-${chartKey}`"
400
+ :height="chartHeight"
401
+ :data="mergedValue"
402
+ :margin="margin"
403
+ :auto-margin="false"
404
+ class="chart-layer"
405
+ >
406
+ <VisLine
407
+ v-if="hasComparison"
408
+ curve-type="monotoneX"
409
+ :x="x"
410
+ :y="prevPosition"
411
+ :y-scale="positionScale"
412
+ :color="gscColors.position.prev"
413
+ :line-width="1.5"
414
+ :line-dash-array="[6, 4]"
415
+ />
416
+ <VisLine curve-type="monotoneX" :x="x" :y="position" :y-scale="positionScale" :color="gscColors.position.line" :line-width="2" />
417
+ </VisXYContainer>
418
+
419
+ <VisXYContainer
420
+ :key="`interactive-${chartKey}`"
421
+ :height="chartHeight"
422
+ :data="mergedValue"
423
+ :margin="margin"
424
+ :auto-margin="false"
425
+ class="chart-layer chart-layer--interactive"
426
+ >
427
+ <VisLine :x="x" :y="clicks" color="transparent" :line-width="0" />
428
+ <VisAxis
429
+ type="x"
430
+ :tick-line="false"
431
+ :grid-line="false"
432
+ :domain-line="false"
433
+ :tick-values="tickPlan.indices"
434
+ :tick-format="tickFormat"
435
+ :tick-text-align="TextAlign.Left"
436
+ tick-text-font-size="11px"
437
+ tick-text-color="var(--ui-text-dimmed)"
438
+ />
439
+ <VisTooltip :follow-cursor="false" horizontal-placement="right" />
440
+ <VisCrosshair color="none" :template="crosshairTemplate" />
441
+ </VisXYContainer>
442
+
443
+ <template #fallback>
444
+ <div class="loading-skeleton">
445
+ <div class="flex-1 flex items-end gap-1">
446
+ <div v-for="i in 30" :key="i" class="flex-1">
447
+ <UiSkeleton type="bar" :index="i" />
448
+ </div>
449
+ </div>
450
+ <div class="flex justify-between mt-3">
451
+ <UiSkeleton v-for="i in 5" :key="i" class="h-3 w-12" />
452
+ </div>
453
+ </div>
454
+ </template>
455
+ </ClientOnly>
456
+ </div>
457
+ </template>
458
+
459
+ <style scoped>
460
+ [data-ui="GscPerformanceChart"] :deep(.unovis-area-group path) {
461
+ stroke: none;
462
+ }
463
+
464
+ .gsc-chart {
465
+ display: grid;
466
+ grid-template-columns: 1fr;
467
+ position: relative;
468
+ }
469
+
470
+ .gsc-chart .chart-layer,
471
+ .gsc-chart .loading-skeleton {
472
+ grid-column-start: 1;
473
+ grid-row-start: 1;
474
+ }
475
+
476
+ .gsc-chart .chart-layer {
477
+ pointer-events: none;
478
+ }
479
+
480
+ .gsc-chart .chart-layer--interactive {
481
+ pointer-events: auto;
482
+ --vis-crosshair-line-stroke-color: var(--ui-border-accented);
483
+ --vis-crosshair-line-stroke-opacity: 0.6;
484
+ }
485
+
486
+ .gsc-chart .chart-layer--interactive :deep(.unovis-tooltip) {
487
+ display: none;
488
+ }
489
+
490
+ .estimated-region {
491
+ position: absolute;
492
+ top: 0;
493
+ right: 0;
494
+ pointer-events: none;
495
+ z-index: 5;
496
+ }
497
+
498
+ .estimated-tint {
499
+ position: absolute;
500
+ inset: 0;
501
+ background: linear-gradient(
502
+ to right,
503
+ transparent,
504
+ color-mix(in srgb, var(--ui-bg) 60%, transparent) 40%
505
+ );
506
+ }
507
+
508
+ .estimated-separator {
509
+ position: absolute;
510
+ left: 0;
511
+ top: 0;
512
+ bottom: 0;
513
+ width: 1px;
514
+ background: repeating-linear-gradient(
515
+ to bottom,
516
+ var(--ui-text-dimmed) 0,
517
+ var(--ui-text-dimmed) 3px,
518
+ transparent 3px,
519
+ transparent 7px
520
+ );
521
+ opacity: 0.35;
522
+ }
523
+
524
+ .loading-skeleton {
525
+ display: flex;
526
+ flex-direction: column;
527
+ justify-content: end;
528
+ padding-bottom: 2rem;
529
+ padding-left: 0.5rem;
530
+ padding-right: 0.5rem;
531
+ }
532
+ </style>
@@ -0,0 +1,63 @@
1
+ <script setup lang="ts">
2
+ import { CurveType } from '@unovis/ts'
3
+ import { VisAxis, VisCrosshair, VisLine, VisTooltip, VisXYContainer } from '@unovis/vue'
4
+
5
+ interface DataPoint {
6
+ date: string
7
+ pos_1_3: number
8
+ pos_4_10: number
9
+ pos_11_20: number
10
+ pos_20_plus: number
11
+ total: number
12
+ }
13
+
14
+ defineProps<{
15
+ data: DataPoint[]
16
+ height?: number
17
+ }>()
18
+
19
+ const x = (_: DataPoint, i: number) => i
20
+ const pos1_3 = (d: DataPoint) => d.pos_1_3 ?? 0
21
+ const pos4_10 = (d: DataPoint) => d.pos_4_10 ?? 0
22
+ const pos11_20 = (d: DataPoint) => d.pos_11_20 ?? 0
23
+ const pos20plus = (d: DataPoint) => d.pos_20_plus ?? 0
24
+
25
+ const colors = {
26
+ top3: '#10b981',
27
+ page1: '#3b82f6',
28
+ page2: '#f59e0b',
29
+ deep: '#ef4444',
30
+ }
31
+
32
+ function template(d: DataPoint) {
33
+ return `
34
+ <div class="text-sm">
35
+ <div class="font-medium mb-1">${d.date}</div>
36
+ <div style="color: ${colors.top3}">Pos 1-3: ${d.pos_1_3?.toLocaleString() ?? 0}</div>
37
+ <div style="color: ${colors.page1}">Pos 4-10: ${d.pos_4_10?.toLocaleString() ?? 0}</div>
38
+ <div style="color: ${colors.page2}">Pos 11-20: ${d.pos_11_20?.toLocaleString() ?? 0}</div>
39
+ <div style="color: ${colors.deep}">Pos 20+: ${d.pos_20_plus?.toLocaleString() ?? 0}</div>
40
+ <div class="text-muted mt-1">Total: ${d.total?.toLocaleString() ?? 0}</div>
41
+ </div>
42
+ `
43
+ }
44
+ </script>
45
+
46
+ <template>
47
+ <div :style="{ height: `${height ?? 240}px` }">
48
+ <VisXYContainer v-if="data?.length" :data="data" :height="height ?? 240">
49
+ <VisLine :x="x" :y="pos1_3" :color="colors.top3" :line-width="2" :curve-type="CurveType.MonotoneX" />
50
+ <VisLine :x="x" :y="pos4_10" :color="colors.page1" :line-width="2" :curve-type="CurveType.MonotoneX" />
51
+ <VisLine :x="x" :y="pos11_20" :color="colors.page2" :line-width="2" :curve-type="CurveType.MonotoneX" />
52
+ <VisLine :x="x" :y="pos20plus" :color="colors.deep" :line-width="2" :curve-type="CurveType.MonotoneX" />
53
+
54
+ <VisAxis type="x" :tick-format="(i: number) => data[i]?.date?.slice(5) ?? ''" :num-ticks="7" />
55
+ <VisAxis type="y" :tick-format="(n: number) => n >= 1000 ? `${(n / 1000).toFixed(0)}k` : String(n)" />
56
+ <VisCrosshair :template="template" />
57
+ <VisTooltip />
58
+ </VisXYContainer>
59
+ <div v-else class="flex items-center justify-center h-full text-muted text-sm">
60
+ No data
61
+ </div>
62
+ </div>
63
+ </template>
@@ -0,0 +1,63 @@
1
+ <script setup lang="ts">
2
+ // Keyword cell: keyword text + optional best-position chip + optional variant
3
+ // badge. Deliberately small — callers lay it out inside their own row/table.
4
+
5
+ const { keyword, position, variantCount = 0, href } = defineProps<{
6
+ keyword: string
7
+ position?: number
8
+ /** Number of GSC query-variants folded into this canonical keyword. */
9
+ variantCount?: number
10
+ href?: string
11
+ }>()
12
+
13
+ const positionLabel = computed(() => {
14
+ if (position == null || !Number.isFinite(position) || position <= 0)
15
+ return null
16
+ return position.toFixed(1)
17
+ })
18
+
19
+ const positionColor = computed<'success' | 'warning' | 'neutral' | 'error'>(() => {
20
+ if (position == null)
21
+ return 'neutral'
22
+ if (position <= 3)
23
+ return 'success'
24
+ if (position <= 10)
25
+ return 'warning'
26
+ if (position <= 20)
27
+ return 'neutral'
28
+ return 'error'
29
+ })
30
+ </script>
31
+
32
+ <template>
33
+ <div class="flex items-center gap-2 min-w-0">
34
+ <component
35
+ :is="href ? 'NuxtLink' : 'span'"
36
+ :to="href"
37
+ class="truncate text-default"
38
+ :class="href ? 'hover:text-primary hover:underline' : ''"
39
+ :title="keyword"
40
+ >
41
+ {{ keyword }}
42
+ </component>
43
+ <UBadge
44
+ v-if="positionLabel"
45
+ :color="positionColor"
46
+ variant="soft"
47
+ size="xs"
48
+ class="tabular-nums shrink-0"
49
+ >
50
+ #{{ positionLabel }}
51
+ </UBadge>
52
+ <UBadge
53
+ v-if="variantCount > 1"
54
+ color="neutral"
55
+ variant="soft"
56
+ size="xs"
57
+ class="tabular-nums shrink-0"
58
+ :title="`${variantCount} GSC query variants folded into this keyword`"
59
+ >
60
+ +{{ variantCount - 1 }}
61
+ </UBadge>
62
+ </div>
63
+ </template>
@@ -0,0 +1,40 @@
1
+ <script setup lang="ts">
2
+ // Site-scoped page header. Auto-prefixes the `[Overview, hostname-link]`
3
+ // crumb trail every `/sites/[id]/*` page repeats, on top of the layer's
4
+ // `PageHeader`. Hosts pass `:tail` for the remaining crumbs and use
5
+ // `<PageHeader>` directly for pages that don't fit the standard shape
6
+ // (e.g. the site index, where the title IS the hostname).
7
+
8
+ interface Crumb { label: string, to?: string }
9
+
10
+ const { tail = [], title, icon, description } = defineProps<{
11
+ tail?: Crumb[]
12
+ title: string
13
+ icon?: string
14
+ description?: string
15
+ }>()
16
+
17
+ const { siteId, site } = useGscCurrentSite()
18
+
19
+ const crumbs = computed<Crumb[]>(() => [
20
+ { label: 'Overview', to: '/' },
21
+ { label: site.value?.hostname ?? siteId.value, to: `/sites/${encodeURIComponent(siteId.value)}` },
22
+ ...tail,
23
+ ])
24
+ </script>
25
+
26
+ <template>
27
+ <PageHeader
28
+ :crumbs="crumbs"
29
+ :title="title"
30
+ :icon="icon"
31
+ :description="description"
32
+ >
33
+ <template v-if="$slots.icon" #icon>
34
+ <slot name="icon" />
35
+ </template>
36
+ <template #actions>
37
+ <slot name="actions" />
38
+ </template>
39
+ </PageHeader>
40
+ </template>