@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.
- package/README.md +10 -137
- package/dist/module.d.mts +22 -0
- package/dist/module.mjs +30 -0
- package/dist/runtime-config.d.mts +12 -0
- package/dist/runtime-config.mjs +27 -0
- package/package.json +26 -176
- package/app/assets/css/main.css +0 -2
- package/app/components/GscAnalyzerPanel.vue +0 -94
- package/app/components/GscAnonymizationBanner.vue +0 -46
- package/app/components/GscBootProgress.vue +0 -297
- package/app/components/GscCommandPalette.vue +0 -77
- package/app/components/GscDashboardPage.vue +0 -26
- package/app/components/GscEngineBadge.vue +0 -28
- package/app/components/GscHero.vue +0 -214
- package/app/components/GscLazyTopResult.vue +0 -47
- package/app/components/GscPerformanceChart.vue +0 -532
- package/app/components/GscPositionDistributionChart.vue +0 -63
- package/app/components/GscQueryLabel.vue +0 -63
- package/app/components/GscSitePageHeader.vue +0 -40
- package/app/components/GscSourceDebugPanel.vue +0 -196
- package/app/components/GscSyncStatusCell.vue +0 -54
- package/app/components/GscTopRollupCard.vue +0 -90
- package/app/composables/_useGscBackfill.ts +0 -119
- package/app/composables/_useGscQueryDispatcher.ts +0 -123
- package/app/composables/_useGscResource.ts +0 -202
- package/app/composables/_useGscSharedSiteResource.ts +0 -136
- package/app/composables/useGscAnalytics.ts +0 -206
- package/app/composables/useGscAnalyticsClient.ts +0 -9
- package/app/composables/useGscAnalyticsConfig.ts +0 -8
- package/app/composables/useGscAnalyticsSourceInfo.ts +0 -165
- package/app/composables/useGscAnalyzer.ts +0 -460
- package/app/composables/useGscAnalyzerBatch.ts +0 -106
- package/app/composables/useGscAnalyzerDefs.ts +0 -119
- package/app/composables/useGscAuth.ts +0 -115
- package/app/composables/useGscConsoleUrl.ts +0 -45
- package/app/composables/useGscCountries.ts +0 -46
- package/app/composables/useGscCurrentSite.ts +0 -36
- package/app/composables/useGscInspectionHistory.ts +0 -42
- package/app/composables/useGscInspections.ts +0 -52
- package/app/composables/useGscPanelContext.ts +0 -31
- package/app/composables/useGscPeriod.ts +0 -243
- package/app/composables/useGscQuery.ts +0 -301
- package/app/composables/useGscQueryDispatcher.ts +0 -14
- package/app/composables/useGscRequestIndexingInspect.ts +0 -29
- package/app/composables/useGscResource.ts +0 -11
- package/app/composables/useGscRollup.ts +0 -237
- package/app/composables/useGscSearchAppearance.ts +0 -46
- package/app/composables/useGscSiteAnalyzer.ts +0 -456
- package/app/composables/useGscSitemapHistory.ts +0 -41
- package/app/composables/useGscSitemaps.ts +0 -45
- package/app/composables/useGscTableState.ts +0 -119
- package/app/plugins/analytics.ts +0 -57
- package/app/queries/gsc.ts +0 -163
- package/app/utils/anonymization.ts +0 -24
- package/app/utils/country-names.ts +0 -56
- package/app/utils/duckdb-wasm.ts +0 -166
- package/app/utils/gsc-constants.ts +0 -10
- package/app/utils/gsc-error.ts +0 -58
- package/app/utils/gsc-fetch.ts +0 -94
- package/app/utils/gsc-filters.ts +0 -32
- package/app/utils/gsc-rows.ts +0 -72
- package/app/utils/gsc-rpc.ts +0 -18
- package/app/utils/position.ts +0 -7
- package/module.ts +0 -81
- package/nuxt.config.ts +0 -61
- 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>
|