@gscdump/nuxt 0.22.4 → 1.5.3

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 (66) hide show
  1. package/README.md +10 -137
  2. package/dist/module.d.mts +22 -0
  3. package/dist/module.mjs +30 -0
  4. package/dist/runtime-config.d.mts +12 -0
  5. package/dist/runtime-config.mjs +27 -0
  6. package/package.json +26 -176
  7. package/app/assets/css/main.css +0 -2
  8. package/app/components/GscAnalyzerPanel.vue +0 -94
  9. package/app/components/GscAnonymizationBanner.vue +0 -46
  10. package/app/components/GscBootProgress.vue +0 -297
  11. package/app/components/GscCommandPalette.vue +0 -77
  12. package/app/components/GscDashboardPage.vue +0 -26
  13. package/app/components/GscEngineBadge.vue +0 -28
  14. package/app/components/GscHero.vue +0 -214
  15. package/app/components/GscLazyTopResult.vue +0 -47
  16. package/app/components/GscPerformanceChart.vue +0 -532
  17. package/app/components/GscPositionDistributionChart.vue +0 -63
  18. package/app/components/GscQueryLabel.vue +0 -63
  19. package/app/components/GscSitePageHeader.vue +0 -40
  20. package/app/components/GscSourceDebugPanel.vue +0 -196
  21. package/app/components/GscSyncStatusCell.vue +0 -54
  22. package/app/components/GscTopRollupCard.vue +0 -90
  23. package/app/composables/_useGscBackfill.ts +0 -119
  24. package/app/composables/_useGscQueryDispatcher.ts +0 -123
  25. package/app/composables/_useGscResource.ts +0 -202
  26. package/app/composables/_useGscSharedSiteResource.ts +0 -136
  27. package/app/composables/useGscAnalytics.ts +0 -206
  28. package/app/composables/useGscAnalyticsClient.ts +0 -9
  29. package/app/composables/useGscAnalyticsConfig.ts +0 -8
  30. package/app/composables/useGscAnalyticsSourceInfo.ts +0 -165
  31. package/app/composables/useGscAnalyzer.ts +0 -460
  32. package/app/composables/useGscAnalyzerBatch.ts +0 -106
  33. package/app/composables/useGscAnalyzerDefs.ts +0 -119
  34. package/app/composables/useGscAuth.ts +0 -115
  35. package/app/composables/useGscConsoleUrl.ts +0 -45
  36. package/app/composables/useGscCountries.ts +0 -46
  37. package/app/composables/useGscCurrentSite.ts +0 -36
  38. package/app/composables/useGscInspectionHistory.ts +0 -42
  39. package/app/composables/useGscInspections.ts +0 -52
  40. package/app/composables/useGscPanelContext.ts +0 -31
  41. package/app/composables/useGscPeriod.ts +0 -243
  42. package/app/composables/useGscQuery.ts +0 -301
  43. package/app/composables/useGscQueryDispatcher.ts +0 -14
  44. package/app/composables/useGscRequestIndexingInspect.ts +0 -29
  45. package/app/composables/useGscResource.ts +0 -11
  46. package/app/composables/useGscRollup.ts +0 -237
  47. package/app/composables/useGscSearchAppearance.ts +0 -46
  48. package/app/composables/useGscSiteAnalyzer.ts +0 -456
  49. package/app/composables/useGscSitemapHistory.ts +0 -41
  50. package/app/composables/useGscSitemaps.ts +0 -45
  51. package/app/composables/useGscTableState.ts +0 -119
  52. package/app/plugins/analytics.ts +0 -57
  53. package/app/queries/gsc.ts +0 -163
  54. package/app/utils/anonymization.ts +0 -24
  55. package/app/utils/country-names.ts +0 -56
  56. package/app/utils/duckdb-wasm.ts +0 -166
  57. package/app/utils/gsc-constants.ts +0 -10
  58. package/app/utils/gsc-error.ts +0 -58
  59. package/app/utils/gsc-fetch.ts +0 -94
  60. package/app/utils/gsc-filters.ts +0 -32
  61. package/app/utils/gsc-rows.ts +0 -72
  62. package/app/utils/gsc-rpc.ts +0 -18
  63. package/app/utils/position.ts +0 -7
  64. package/module.ts +0 -81
  65. package/nuxt.config.ts +0 -61
  66. package/types.ts +0 -115
@@ -1,297 +0,0 @@
1
- <script setup lang="ts">
2
- // Minimal boot-progress indicator for the browser-side DuckDB-WASM + parquet
3
- // attach flow. Reads per-site progress straight off the injected analyzer
4
- // state (`siteProgress` on `BrowserAnalyzerState`) — no derived composable,
5
- // no prop drilling.
6
- //
7
- // Collapsed view: one slim stacked bar, each segment a site, coloured by
8
- // stage. Tap to expand per-site rows with file counters. Hidden once every
9
- // tracked site is ready.
10
-
11
- import type { SiteLoadProgress, SiteLoadStage } from '../composables/useGscAnalytics'
12
- import { useGscBootProgress } from '../composables/useGscAnalytics'
13
-
14
- // Reads the shared per-site progress ref directly. Both DuckDB boot and rollup
15
- // fan-out write to it, so this component lights up for either flow without
16
- // caring who's producing the signal.
17
- const { progress: siteProgress } = useGscBootProgress()
18
-
19
- const expanded = ref(false)
20
-
21
- const sites = computed<SiteLoadProgress[]>(() =>
22
- Object.values(siteProgress.value).sort((a, b) => a.startedAt - b.startedAt),
23
- )
24
-
25
- const allReady = computed(() => sites.value.length > 0 && sites.value.every(s => s.stage === 'ready'))
26
- const anyActive = computed(() => sites.value.some(s => s.stage !== 'ready' && s.stage !== 'idle' && s.stage !== 'error'))
27
-
28
- const aggregate = computed(() => {
29
- let attached = 0
30
- let total = 0
31
- for (const s of sites.value) {
32
- attached += s.filesAttached
33
- total += s.filesTotal
34
- }
35
- return { attached, total, pct: total > 0 ? Math.round((attached / total) * 100) : 0 }
36
- })
37
-
38
- // Rank stages so the earliest-stage site drives the overall headline.
39
- const stageRank: Record<SiteLoadStage, number> = {
40
- idle: 0,
41
- wasm: 1,
42
- manifest: 2,
43
- attach: 3,
44
- ready: 4,
45
- error: -1,
46
- }
47
-
48
- const earliestStage = computed<SiteLoadStage>(() => {
49
- let min: SiteLoadStage = 'ready'
50
- let minRank = stageRank.ready
51
- for (const s of sites.value) {
52
- const r = stageRank[s.stage]
53
- if (r >= 0 && r < minRank) {
54
- min = s.stage
55
- minRank = r
56
- }
57
- }
58
- return min
59
- })
60
-
61
- const stageLabel: Record<SiteLoadStage, string> = {
62
- idle: 'Queued',
63
- wasm: 'Starting DuckDB',
64
- manifest: 'Fetching manifest',
65
- attach: 'Attaching parquet',
66
- ready: 'Ready',
67
- error: 'Error',
68
- }
69
-
70
- const stageColor: Record<SiteLoadStage, string> = {
71
- idle: 'bg-neutral-700',
72
- wasm: 'bg-violet-500',
73
- manifest: 'bg-sky-500',
74
- attach: 'bg-emerald-500',
75
- ready: 'bg-emerald-600',
76
- error: 'bg-rose-500',
77
- }
78
-
79
- function siteWidth(site: SiteLoadProgress): string {
80
- if (site.stage === 'ready')
81
- return '100%'
82
- if (site.filesTotal === 0)
83
- // No file count yet (wasm / manifest phase) — show a fractional sliver so
84
- // the segment is visible but clearly not done.
85
- return site.stage === 'wasm' ? '10%' : '25%'
86
- return `${Math.max(5, Math.round((site.filesAttached / site.filesTotal) * 100))}%`
87
- }
88
-
89
- function truncate(id: string, max = 28): string {
90
- if (id.length <= max)
91
- return id
92
- return `${id.slice(0, max - 1)}…`
93
- }
94
- </script>
95
-
96
- <template>
97
- <Transition
98
- enter-active-class="transition-all duration-200"
99
- enter-from-class="opacity-0 translate-y-[-4px]"
100
- enter-to-class="opacity-100 translate-y-0"
101
- leave-active-class="transition-all duration-300"
102
- leave-from-class="opacity-100"
103
- leave-to-class="opacity-0"
104
- >
105
- <div
106
- v-if="!allReady && sites.length > 0"
107
- class="analytics-boot-progress"
108
- >
109
- <button
110
- type="button"
111
- class="headline"
112
- :aria-expanded="expanded"
113
- @click="expanded = !expanded"
114
- >
115
- <span class="dot" :class="stageColor[earliestStage]" />
116
- <span class="label">{{ stageLabel[earliestStage] }}</span>
117
- <span class="counter">
118
- <template v-if="aggregate.total > 0">
119
- {{ aggregate.attached }} / {{ aggregate.total }}
120
- </template>
121
- <template v-else>
122
- {{ sites.length }} site{{ sites.length === 1 ? '' : 's' }}
123
- </template>
124
- </span>
125
- <span class="caret" :class="{ open: expanded }">⌄</span>
126
- </button>
127
-
128
- <div class="bar">
129
- <div
130
- v-for="site in sites"
131
- :key="site.siteId"
132
- class="seg"
133
- :class="[stageColor[site.stage], { shimmer: anyActive && site.stage !== 'ready' && site.stage !== 'error' }]"
134
- :style="{ width: siteWidth(site) }"
135
- :title="`${site.siteId}: ${stageLabel[site.stage]}`"
136
- />
137
- </div>
138
-
139
- <Transition
140
- enter-active-class="transition-all duration-200"
141
- enter-from-class="opacity-0 max-h-0"
142
- enter-to-class="opacity-100 max-h-[400px]"
143
- leave-active-class="transition-all duration-150"
144
- leave-from-class="opacity-100 max-h-[400px]"
145
- leave-to-class="opacity-0 max-h-0"
146
- >
147
- <ul v-if="expanded" class="rows">
148
- <li v-for="site in sites" :key="site.siteId" class="row">
149
- <span class="row-dot" :class="stageColor[site.stage]" />
150
- <span class="row-id">{{ truncate(site.siteId) }}</span>
151
- <span class="row-stage">{{ stageLabel[site.stage] }}</span>
152
- <span class="row-count">
153
- <template v-if="site.filesTotal > 0">
154
- {{ site.filesAttached }}/{{ site.filesTotal }}
155
- </template>
156
- <template v-else-if="site.stage === 'ready' && site.endedAt">
157
- {{ Math.round(site.endedAt - site.startedAt) }}ms
158
- </template>
159
- <template v-else-if="site.stage === 'error'">
160
- <span class="err">{{ truncate(site.error ?? 'error', 40) }}</span>
161
- </template>
162
- <template v-else>
163
-
164
- </template>
165
- </span>
166
- </li>
167
- </ul>
168
- </Transition>
169
- </div>
170
- </Transition>
171
- </template>
172
-
173
- <style scoped>
174
- .analytics-boot-progress {
175
- display: flex;
176
- flex-direction: column;
177
- gap: 6px;
178
- padding: 8px 10px;
179
- border: 1px solid rgba(255, 255, 255, 0.06);
180
- background: rgba(255, 255, 255, 0.02);
181
- border-radius: 4px;
182
- font-size: 12px;
183
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
184
- }
185
- .headline {
186
- display: flex;
187
- align-items: center;
188
- gap: 8px;
189
- background: transparent;
190
- border: 0;
191
- padding: 0;
192
- color: inherit;
193
- cursor: pointer;
194
- text-align: left;
195
- width: 100%;
196
- }
197
- .dot {
198
- width: 8px;
199
- height: 8px;
200
- border-radius: 999px;
201
- flex-shrink: 0;
202
- }
203
- .label {
204
- color: rgba(255, 255, 255, 0.82);
205
- font-weight: 500;
206
- }
207
- .counter {
208
- color: rgba(255, 255, 255, 0.55);
209
- margin-left: auto;
210
- font-variant-numeric: tabular-nums;
211
- }
212
- .caret {
213
- display: inline-block;
214
- color: rgba(255, 255, 255, 0.4);
215
- transition: transform 180ms ease;
216
- width: 10px;
217
- text-align: center;
218
- }
219
- .caret.open {
220
- transform: rotate(180deg);
221
- }
222
- .bar {
223
- display: flex;
224
- gap: 2px;
225
- height: 3px;
226
- width: 100%;
227
- overflow: hidden;
228
- border-radius: 2px;
229
- background: rgba(255, 255, 255, 0.04);
230
- }
231
- .seg {
232
- height: 100%;
233
- border-radius: 2px;
234
- transition: width 220ms cubic-bezier(0.22, 0.61, 0.36, 1);
235
- min-width: 4px;
236
- position: relative;
237
- }
238
- .seg.shimmer::after {
239
- content: '';
240
- position: absolute;
241
- inset: 0;
242
- background: linear-gradient(
243
- 90deg,
244
- rgba(255, 255, 255, 0) 0%,
245
- rgba(255, 255, 255, 0.28) 50%,
246
- rgba(255, 255, 255, 0) 100%
247
- );
248
- animation: boot-shimmer 1.4s linear infinite;
249
- }
250
- @keyframes boot-shimmer {
251
- from { transform: translateX(-100%); }
252
- to { transform: translateX(100%); }
253
- }
254
- .rows {
255
- list-style: none;
256
- margin: 4px 0 0;
257
- padding: 0;
258
- overflow: hidden;
259
- }
260
- .row {
261
- display: grid;
262
- grid-template-columns: 10px minmax(0, 1fr) auto auto;
263
- gap: 8px;
264
- align-items: center;
265
- padding: 3px 0;
266
- color: rgba(255, 255, 255, 0.72);
267
- }
268
- .row + .row {
269
- border-top: 1px solid rgba(255, 255, 255, 0.04);
270
- }
271
- .row-dot {
272
- width: 6px;
273
- height: 6px;
274
- border-radius: 999px;
275
- margin-left: 2px;
276
- }
277
- .row-id {
278
- overflow: hidden;
279
- text-overflow: ellipsis;
280
- white-space: nowrap;
281
- color: rgba(255, 255, 255, 0.88);
282
- }
283
- .row-stage {
284
- color: rgba(255, 255, 255, 0.5);
285
- font-size: 11px;
286
- }
287
- .row-count {
288
- color: rgba(255, 255, 255, 0.7);
289
- font-variant-numeric: tabular-nums;
290
- font-size: 11px;
291
- min-width: 60px;
292
- text-align: right;
293
- }
294
- .row-count .err {
295
- color: #fb7185;
296
- }
297
- </style>
@@ -1,77 +0,0 @@
1
- <script setup lang="ts">
2
- // Cmd+K palette. Wraps UCommandPalette inside a UModal, wires up the global
3
- // keyboard shortcut, and sources its items from the analytics context + a
4
- // static list of top-level routes. Drop `<GscCommandPalette />` into your
5
- // root layout once.
6
- //
7
- // Hosts can extend the groups via the `groups` prop — e.g. nuxtseo.com adds
8
- // its billing / settings entries without forking the component.
9
-
10
- import type { CommandPaletteGroup, CommandPaletteItem } from '@nuxt/ui'
11
- import type { SiteListItem } from '../composables/useGscAnalytics'
12
- import { useMagicKeys, whenever } from '@vueuse/core'
13
- import { useGscSites } from '../composables/useGscAnalytics'
14
-
15
- const { groups: extraGroups = [] } = defineProps<{
16
- groups?: CommandPaletteGroup[]
17
- }>()
18
-
19
- const router = useRouter()
20
- const { sites } = useGscSites()
21
-
22
- const open = ref(false)
23
- const selected = ref<CommandPaletteItem | null>(null)
24
-
25
- const keys = useMagicKeys()
26
- const cmdK = computed(() => keys.meta_k?.value || keys.ctrl_k?.value || false)
27
- whenever(cmdK, () => {
28
- open.value = !open.value
29
- })
30
-
31
- const siteGroup = computed<CommandPaletteGroup<CommandPaletteItem>>(() => ({
32
- id: 'sites',
33
- label: 'Sites',
34
- items: (sites.value ?? []).map<CommandPaletteItem>((s: SiteListItem) => ({
35
- label: s.hostname,
36
- suffix: s.propertyType === 'domain' ? 'sc-domain' : 'url-prefix',
37
- icon: 'i-lucide-globe',
38
- to: `/sites/${encodeURIComponent(s.id)}`,
39
- })),
40
- }))
41
-
42
- const navGroup: CommandPaletteGroup<CommandPaletteItem> = {
43
- id: 'nav',
44
- label: 'Navigation',
45
- items: [
46
- { label: 'Overview', icon: 'i-lucide-layout-dashboard', to: '/' },
47
- ],
48
- }
49
-
50
- const allGroups = computed<CommandPaletteGroup[]>(() => [
51
- siteGroup.value,
52
- navGroup,
53
- ...extraGroups,
54
- ])
55
-
56
- watch(selected, (item) => {
57
- if (!item)
58
- return
59
- if (typeof item.to === 'string')
60
- router.push(item.to)
61
- open.value = false
62
- selected.value = null
63
- })
64
- </script>
65
-
66
- <template>
67
- <UModal v-model:open="open" :ui="{ content: 'max-w-[560px]' }">
68
- <template #content>
69
- <UCommandPalette
70
- v-model="selected"
71
- :groups="allGroups"
72
- placeholder="Jump to a site or action…"
73
- class="h-[420px]"
74
- />
75
- </template>
76
- </UModal>
77
- </template>
@@ -1,26 +0,0 @@
1
- <script setup lang="ts">
2
- // Thin page container: sets the `flex flex-col min-h-screen` chrome every
3
- // site-scoped page has been repeating and provides a main-region slot with
4
- // the shared max-width + padding. Use together with PageHeader.
5
-
6
- const { gap = 'md' } = defineProps<{
7
- /** Spacing between direct children of the main region. Maps to tailwind gap-3/4/5. */
8
- gap?: 'sm' | 'md' | 'lg'
9
- }>()
10
-
11
- const gapClass = computed(() => (
12
- gap === 'sm' ? 'gap-3' : gap === 'lg' ? 'gap-5' : 'gap-4'
13
- ))
14
- </script>
15
-
16
- <template>
17
- <div class="flex flex-col min-h-screen overflow-x-clip">
18
- <slot name="header" />
19
- <div
20
- class="max-w-[1128px] min-w-0 px-4 sm:px-6 lg:px-9 pt-4 pb-10 flex flex-col flex-1 w-full"
21
- :class="gapClass"
22
- >
23
- <slot />
24
- </div>
25
- </div>
26
- </template>
@@ -1,28 +0,0 @@
1
- <script setup lang="ts">
2
- // Small observability pill: shows which backend served the query
3
- // (browser/DuckDB-WASM vs server/D1), how long it took, and the fallback
4
- // reason if the browser path tried and failed. Silent until data settles.
5
-
6
- const { engine, elapsedMs = null, fallbackReason = null } = defineProps<{
7
- engine: 'browser' | 'server' | null
8
- elapsedMs?: number | null
9
- fallbackReason?: string | null
10
- }>()
11
- </script>
12
-
13
- <template>
14
- <div v-if="engine" class="flex items-center gap-2 text-[10px] font-mono uppercase text-neutral-500">
15
- <span
16
- class="px-1.5 py-0.5 border"
17
- :class="engine === 'browser'
18
- ? 'border-cyan-500/40 text-cyan-400 bg-cyan-500/5'
19
- : 'border-white/15 text-neutral-400 bg-white/5'"
20
- >
21
- engine: {{ engine }}
22
- </span>
23
- <span v-if="elapsedMs != null">{{ Math.round(elapsedMs) }} ms</span>
24
- <span v-if="fallbackReason" class="text-amber-500" :title="fallbackReason">
25
- fallback
26
- </span>
27
- </div>
28
- </template>
@@ -1,214 +0,0 @@
1
- <script setup lang="ts">
2
- // Hero block: four stat cards (clicks / impressions / CTR / avg position) with
3
- // growth badges vs. the comparison window, plus a PerformanceChart over the
4
- // selected period. Fed directly by the `daily_totals` rollup payload so it
5
- // works before DuckDB has finished booting.
6
- //
7
- // Consumes `DateRange` from useGscPeriod — the caller owns period/compare state
8
- // and passes the resolved window in. Keeping the compute here means the stat
9
- // grid and chart read from the same windowed slice.
10
-
11
- import type { CompareMode, DateRange } from '../composables/useGscPeriod'
12
- import { computeGrowth } from '../composables/useGscPeriod'
13
-
14
- interface DailyTotal {
15
- /** Rollup writer stamps this as Unix ms, not an ISO date. */
16
- date: number
17
- clicks: number
18
- impressions: number
19
- sum_position: number
20
- anonymizedImpressionsPct: number
21
- }
22
-
23
- const { payload, range, compareMode } = defineProps<{
24
- payload: readonly DailyTotal[] | null | undefined
25
- range: DateRange
26
- compareMode: CompareMode
27
- }>()
28
-
29
- function toIso(ms: number): string {
30
- return new Date(ms).toISOString().slice(0, 10)
31
- }
32
-
33
- function daysBetween(a: string, b: string): number {
34
- return Math.round((Date.parse(b) - Date.parse(a)) / 86400000)
35
- }
36
-
37
- function shiftIso(iso: string, days: number): string {
38
- return new Date(Date.parse(iso) + days * 86400000).toISOString().slice(0, 10)
39
- }
40
-
41
- // Snap the requested window's `end` to the site's actual most-recent data
42
- // point. Without this, a syncing site whose last day is earlier than the
43
- // stable-latency cutoff compares a short-current window to a full-length
44
- // previous window and shows a false decline.
45
- function snapWindow(payload: readonly DailyTotal[], start: string, end: string): { start: string, end: string } {
46
- let maxIso: string | null = null
47
- for (const d of payload) {
48
- const iso = toIso(d.date)
49
- if (!maxIso || iso > maxIso)
50
- maxIso = iso
51
- }
52
- if (!maxIso || maxIso >= end)
53
- return { start, end }
54
- const shift = daysBetween(end, maxIso)
55
- return { start: shiftIso(start, shift), end: maxIso }
56
- }
57
-
58
- interface WindowTotals {
59
- clicks: number
60
- impressions: number
61
- ctr: number
62
- position: number
63
- series: Array<{ date: string, clicks: number, impressions: number }>
64
- }
65
-
66
- function summarize(days: readonly DailyTotal[], start: string, end: string): WindowTotals {
67
- let clicks = 0
68
- let impressions = 0
69
- let weightedPosition = 0
70
- const series: WindowTotals['series'] = []
71
- for (const d of days) {
72
- const iso = toIso(d.date)
73
- if (iso < start || iso > end)
74
- continue
75
- clicks += d.clicks
76
- impressions += d.impressions
77
- weightedPosition += d.sum_position
78
- series.push({ date: iso, clicks: d.clicks, impressions: d.impressions })
79
- }
80
- return {
81
- clicks,
82
- impressions,
83
- ctr: impressions > 0 ? clicks / impressions : 0,
84
- position: impressions > 0 ? weightedPosition / impressions + 1 : 0,
85
- series,
86
- }
87
- }
88
-
89
- const stats = computed(() => {
90
- if (!payload?.length)
91
- return null
92
- const snapped = snapWindow(payload, range.start, range.end)
93
- const shift = daysBetween(range.end, snapped.end)
94
- const cmpRawStart = compareMode === 'year' ? range.yearStart : range.prevStart
95
- const cmpRawEnd = compareMode === 'year' ? range.yearEnd : range.prevEnd
96
- const cmpStart = shift ? shiftIso(cmpRawStart, shift) : cmpRawStart
97
- const cmpEnd = shift ? shiftIso(cmpRawEnd, shift) : cmpRawEnd
98
- return {
99
- current: summarize(payload, snapped.start, snapped.end),
100
- previous: compareMode === 'none' ? null : summarize(payload, cmpStart, cmpEnd),
101
- snapped,
102
- }
103
- })
104
-
105
- function fmtInt(n: number): string {
106
- return new Intl.NumberFormat().format(Math.round(n))
107
- }
108
- function fmtPct(n: number): string {
109
- return `${(n * 100).toFixed(2)}%`
110
- }
111
- function fmtPos(n: number): string {
112
- return n > 0 ? n.toFixed(1) : '–'
113
- }
114
- function fmtGrowth(g: number | null, invert = false): string {
115
- if (g == null)
116
- return ''
117
- const v = invert ? -g : g
118
- const sign = v > 0 ? '+' : ''
119
- return `${sign}${(v * 100).toFixed(1)}%`
120
- }
121
- function growthColor(g: number | null, invert = false): 'success' | 'error' | 'neutral' {
122
- if (g == null || Math.abs(g) < 0.005)
123
- return 'neutral'
124
- const v = invert ? -g : g
125
- return v > 0 ? 'success' : 'error'
126
- }
127
-
128
- const statCards = computed(() => {
129
- if (!stats.value)
130
- return []
131
- const { current, previous } = stats.value
132
- return [
133
- {
134
- label: 'Clicks',
135
- value: fmtInt(current.clicks),
136
- icon: 'i-lucide-mouse-pointer-click',
137
- growth: computeGrowth(current.clicks, previous?.clicks),
138
- invert: false,
139
- },
140
- {
141
- label: 'Impressions',
142
- value: fmtInt(current.impressions),
143
- icon: 'i-lucide-eye',
144
- growth: computeGrowth(current.impressions, previous?.impressions),
145
- invert: false,
146
- },
147
- {
148
- label: 'CTR',
149
- value: fmtPct(current.ctr),
150
- icon: 'i-lucide-percent',
151
- growth: computeGrowth(current.ctr, previous?.ctr),
152
- invert: false,
153
- },
154
- {
155
- label: 'Avg. position',
156
- value: fmtPos(current.position),
157
- icon: 'i-lucide-hash',
158
- growth: computeGrowth(current.position, previous?.position),
159
- invert: true,
160
- },
161
- ]
162
- })
163
-
164
- const chartData = computed(() => stats.value?.current.series ?? [])
165
- const chartPrevData = computed(() => stats.value?.previous?.series ?? [])
166
- </script>
167
-
168
- <template>
169
- <div class="flex flex-col gap-4">
170
- <div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
171
- <div
172
- v-for="stat in statCards"
173
- :key="stat.label"
174
- class="rounded-lg border border-default bg-default p-4"
175
- >
176
- <div class="flex items-center gap-1.5 text-[11px] font-semibold text-dimmed uppercase tracking-widest">
177
- <UIcon :name="stat.icon" class="size-3" />
178
- {{ stat.label }}
179
- </div>
180
- <div class="flex items-baseline gap-2 mt-1.5">
181
- <div class="text-2xl font-semibold text-default tabular-nums tracking-tight">
182
- {{ stat.value }}
183
- </div>
184
- <UBadge
185
- v-if="stats?.previous"
186
- :color="growthColor(stat.growth, stat.invert)"
187
- variant="soft"
188
- size="xs"
189
- class="tabular-nums"
190
- >
191
- {{ fmtGrowth(stat.growth, stat.invert) }}
192
- </UBadge>
193
- </div>
194
- </div>
195
- <div
196
- v-if="!stats"
197
- class="col-span-full rounded-lg border border-dashed border-default p-6 text-center text-sm text-muted"
198
- >
199
- No data yet. Run <UKbd>gscdump sync</UKbd> to populate the daily_totals rollup.
200
- </div>
201
- </div>
202
-
203
- <div
204
- v-if="stats && chartData.length"
205
- class="rounded-lg border border-default bg-default p-4"
206
- >
207
- <GscPerformanceChart
208
- :value="chartData"
209
- :prev-value="compareMode !== 'none' ? chartPrevData : null"
210
- :height="220"
211
- />
212
- </div>
213
- </div>
214
- </template>
@@ -1,47 +0,0 @@
1
- <script setup lang="ts">
2
- import { useIntersectionObserver } from '@vueuse/core'
3
- import { gscQueries } from '../queries/gsc'
4
- import { useGscRpc } from '../utils/gsc-rpc'
5
-
6
- const props = defineProps<{
7
- siteUrl: string
8
- type: 'topPage' | 'topKeyword'
9
- identifier: string
10
- startDate: string
11
- endDate: string
12
- }>()
13
-
14
- const container = useTemplateRef<HTMLElement>('container')
15
- const value = ref<string | null>(null)
16
- const loaded = ref(false)
17
-
18
- const { stop } = useIntersectionObserver(
19
- container,
20
- ([entry]) => {
21
- if (!entry?.isIntersecting || loaded.value)
22
- return
23
- loaded.value = true
24
- stop()
25
- const request = useGscRpc().query(
26
- gscQueries.topAssociation(props.siteUrl, {
27
- type: props.type,
28
- identifier: props.identifier,
29
- startDate: props.startDate,
30
- endDate: props.endDate,
31
- }),
32
- ) as unknown as Promise<{ value: string | null }>
33
- request
34
- .then(r => value.value = r.value)
35
- .catch(() => {})
36
- },
37
- { rootMargin: '100px' },
38
- )
39
- </script>
40
-
41
- <template>
42
- <div ref="container">
43
- <div v-if="value" class="text-xs text-muted truncate max-w-md">
44
- {{ value }}
45
- </div>
46
- </div>
47
- </template>