@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Harlan Wilton
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # @gscdump/nuxt
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@gscdump/nuxt?color=yellow)](https://npmjs.com/package/@gscdump/nuxt)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@gscdump/nuxt?color=yellow)](https://npm.chart.dev/@gscdump/nuxt)
5
+ [![license](https://img.shields.io/github/license/harlan-zw/gscdump?color=yellow)](https://github.com/harlan-zw/gscdump/blob/main/LICENSE)
6
+
7
+ > Nuxt layer: GSC analytics UI, Nuxt auth/runtime wiring, and DuckDB-WASM integration. It uses `@gscdump/sdk` internally for hosted analytics HTTP reads.
8
+
9
+ This package assumes a site is already linked. It does not own partner
10
+ onboarding, registration, lifecycle decisions, token exchange, or webhook
11
+ subscription management. Use `@gscdump/sdk` from the host app for those
12
+ flows, then pass the linked `gscdumpSiteId` into the analytics composables
13
+ here.
14
+
15
+ Three consumption modes:
16
+
17
+ | Mode | Who | What it does |
18
+ | --- | --- | --- |
19
+ | `origin` | gscdump.com | Reads R2 data directly (binding) + DuckDB service binding. Owns the data. |
20
+ | `consumer` | nuxtseo.com | Proxies all reads to an origin over HTTP + an API key. No data locally. |
21
+ | `local` | `examples/nuxt-dashboard` | Reads a `gscdump sync` dump from `GSCDUMP_DATA_DIR`. Offline dev. |
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ pnpm add @gscdump/nuxt
27
+ ```
28
+
29
+ ```ts
30
+ // nuxt.config.ts
31
+ export default defineNuxtConfig({
32
+ extends: ['@gscdump/nuxt'],
33
+ runtimeConfig: {
34
+ analytics: {
35
+ mode: 'origin', // or 'consumer' / 'local'
36
+ },
37
+ },
38
+ })
39
+ ```
40
+
41
+ ## Register an auth provider
42
+
43
+ ```ts
44
+ // server/plugins/register-auth.ts
45
+ export default defineNitroPlugin((nitro) => {
46
+ nitro.hooks.hook('analytics:register-auth-provider', (register) => {
47
+ register({
48
+ resolve: async (event) => {
49
+ const session = await getUserSession(event)
50
+ if (!session)
51
+ return null
52
+ return { userId: session.user.id, siteIds: session.accessibleSiteIds }
53
+ },
54
+ })
55
+ })
56
+ })
57
+ ```
58
+
59
+ ## Data access: which endpoint?
60
+
61
+ | Need | Endpoint | Client composable |
62
+ | --- | --- | --- |
63
+ | Pre-aggregated widgets (top pages, daily totals) | `GET /api/sites/:siteId/rollup/:id` | `useGscRollup` / `useGscRollups` / `useGscRollupFanout` |
64
+ | Raw rows filtered by dimension/range (`where(eq(page, ...))`) | `POST /api/sites/:siteId/rows` | `useGscRowQuery({ site, state })` |
65
+ | Analyzer result (striking-distance, CTR anomaly, …) | `POST /api/sites/:siteId/analyze` | `useGscAnalyzer(siteId).analyze(...)` |
66
+
67
+ Rule of thumb: reach for `/rows` + `useGscRowQuery` for detail pages that
68
+ need a freeform slice of the data. Only fall back to `/analyze` when the
69
+ server runs an actual analyzer (anything in the registry); hitting it for
70
+ plain row lookups routes through analyzer-gating logic you don't need.
71
+
72
+ ## Consumer mode (cross-origin)
73
+
74
+ Used when the Nuxt app authenticating the viewer is not the same origin as
75
+ the data — e.g. nuxtseo.com pro consuming gscdump.com. Two pieces:
76
+
77
+ 1. **Point fetch at the origin**: set `runtimeConfig.public.analytics.apiBase`
78
+ in `nuxt.config.ts` (or via `NUXT_PUBLIC_ANALYTICS_API_BASE`). Empty =
79
+ same-origin (the default origin-mode shape).
80
+
81
+ 2. **Hand the layer a per-viewer api key**: register a client plugin that
82
+ trades the viewer's host session for an api key, then call
83
+ `setGscFetchHeaders({ 'x-api-key': key })`. The layer's `useGscFetch`
84
+ attaches the header to every `/api/__gsc/*` call automatically. The same
85
+ header rides parquet GETs in `attachParquetUrlTables` (DuckDB-WASM
86
+ browser path), so R2 reads work cross-origin.
87
+
88
+ The `setupGscFetchAuth` helper collapses the boilerplate:
89
+
90
+ ```ts
91
+ // plugins/00.gscdump-auth.client.ts
92
+ export default setupGscFetchAuth({
93
+ credentialsEndpoint: '/api/me/gscdump-credentials',
94
+ // Optional: preload host-specific state. Runs inside runWithContext so
95
+ // composables work. Useful for useState-backed flags read by the layer's
96
+ // composables at construction time.
97
+ async onReady() {
98
+ const flag = useState<boolean | null>('app:browser-analyzer-enabled', () => null)
99
+ if (flag.value !== null)
100
+ return
101
+ const settings = await useGscFetch()<{ browserAnalyzerEnabled: boolean }>('/api/user/settings').catch(() => null)
102
+ flag.value = !!settings?.browserAnalyzerEnabled
103
+ },
104
+ })
105
+ ```
106
+
107
+ The helper is `enforce: 'pre'` and dedupes via an inflight promise; Nuxt
108
+ blocks subsequent plugins until headers land, removing the auth-header race
109
+ where a `useGscQuery` mounts before credentials resolve.
110
+
111
+ `credentialsEndpoint` returns `{ apiKey: string, ... }` by default. Override
112
+ the field name with `tokenField` and the header name with `headerName`.
113
+
114
+ Onboarding and lifecycle state stay outside this layer. A consumer app should
115
+ use `@gscdump/sdk` from its host backend to register users/sites, read
116
+ `GET /api/partner/users/:userId/lifecycle`, handle webhook or realtime
117
+ invalidation, and decide when a linked site is ready to render. Once it has a
118
+ linked site id, this layer handles query reads and analytics UI only.
119
+
120
+ ### CORS
121
+
122
+ The origin app (`gscdump.com` or your own) must allow the consumer origin
123
+ on `/api/__gsc/*`, `/api/r2-data/*`, and any host endpoints the helper
124
+ calls. Allow-headers must include `x-api-key`, `Cache-Control`, and `Range`
125
+ (DuckDB-WASM uses partial reads); expose `Content-Range`, `Accept-Ranges`,
126
+ `Content-Length`. Preflight needs to cover `PATCH` for any settings calls.
127
+
128
+ ## Related
129
+
130
+ - [`@gscdump/sdk`](../sdk) — framework-agnostic hosted API client used by host onboarding code and by this Nuxt layer internally.
131
+ - [`gscdump`](../gscdump) — local package and query builder.
132
+ - [`@gscdump/engine`](../engine) — Storage engine consumed in origin mode.
133
+ - [`@gscdump/engine-duckdb-wasm`](../engine-duckdb-wasm) — DuckDB-WASM browser runtime used by client-side widgets.
134
+ - [`@gscdump/analysis`](../analysis) — Analyzers powering `/analyze` endpoints.
135
+
136
+ ## License
137
+
138
+ [MIT](../../LICENSE)
@@ -0,0 +1,2 @@
1
+ @import "tailwindcss";
2
+ @import "@nuxt/ui";
@@ -0,0 +1,94 @@
1
+ <script setup lang="ts">
2
+ // Unified shell for `/analyze` analyzer tabs. Drives the chrome that 11
3
+ // per-analyzer sections used to repeat: stat tiles, body, caption. The body
4
+ // component + tile projection + caption come from `def.capabilities.panel`;
5
+ // the shell owns the loading/error/empty gating unless `panel.ownsLifecycle`
6
+ // is set (pipeline panels manage their own phase state internally).
7
+
8
+ import type {
9
+ GscAnalyzerDefinition,
10
+ GscAnalyzerPanelResult,
11
+ } from '../composables/useGscAnalyzerDefs'
12
+
13
+ interface Props {
14
+ def: GscAnalyzerDefinition
15
+ rows: unknown[]
16
+ meta: Record<string, unknown> | null
17
+ queryMs?: number | null
18
+ loading?: boolean
19
+ error?: string | null
20
+ /** Forwarded to the body component as `range`. Optional. */
21
+ range?: { start: string, end: string } | null
22
+ }
23
+
24
+ const props = defineProps<Props>()
25
+
26
+ const panel = computed(() => props.def.capabilities?.panel)
27
+
28
+ const tiles = computed(() => {
29
+ const p = panel.value
30
+ if (!p?.summarize)
31
+ return []
32
+ const result: GscAnalyzerPanelResult = {
33
+ results: props.rows,
34
+ meta: props.meta ?? {},
35
+ queryMs: props.queryMs ?? null,
36
+ }
37
+ return p.summarize(result)
38
+ })
39
+
40
+ const showBody = computed(() => {
41
+ if (!panel.value)
42
+ return false
43
+ if (panel.value.ownsLifecycle)
44
+ return true
45
+ return !props.loading && !props.error && props.rows.length > 0
46
+ })
47
+
48
+ const bodyProps = computed(() => ({
49
+ rows: props.rows,
50
+ meta: props.meta ?? {},
51
+ range: props.range ?? null,
52
+ }))
53
+ </script>
54
+
55
+ <template>
56
+ <section v-if="panel" class="gsc-analyzer-panel">
57
+ <div v-if="tiles.length > 0" class="gsc-analyzer-panel__tiles">
58
+ <div v-for="(t, i) in tiles" :key="`${t.label}-${i}`" class="gsc-analyzer-panel__tile">
59
+ <span class="gsc-analyzer-panel__tile-label">{{ t.label }}</span>
60
+ <span class="gsc-analyzer-panel__tile-value" :style="t.valueColor ? { color: t.valueColor } : undefined">{{ t.value }}</span>
61
+ </div>
62
+ </div>
63
+
64
+ <template v-if="!panel.ownsLifecycle">
65
+ <div v-if="error" class="gsc-analyzer-panel__error">
66
+ {{ error }}
67
+ </div>
68
+ <div v-else-if="loading" class="gsc-analyzer-panel__loading">
69
+ Running analyzer…
70
+ </div>
71
+ <div v-else-if="rows.length === 0" class="gsc-analyzer-panel__empty">
72
+ No rows.
73
+ </div>
74
+ </template>
75
+
76
+ <component :is="panel.component" v-if="showBody" v-bind="bodyProps" />
77
+
78
+ <p v-if="panel.caption" class="gsc-analyzer-panel__caption">
79
+ <slot name="caption">{{ panel.caption }}</slot>
80
+ </p>
81
+ </section>
82
+ </template>
83
+
84
+ <style scoped>
85
+ .gsc-analyzer-panel { background: #fff; border: 1px solid #ececef; border-top: 0; border-radius: 0 0 6px 6px; padding: 1rem 1rem 0.9rem; }
86
+ .gsc-analyzer-panel__tiles { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-bottom: 0.85rem; padding: 0 0.15rem; }
87
+ .gsc-analyzer-panel__tile { display: flex; flex-direction: column; gap: 0.1rem; }
88
+ .gsc-analyzer-panel__tile-label { font-size: 0.66rem; letter-spacing: 0.08em; text-transform: uppercase; color: #888; }
89
+ .gsc-analyzer-panel__tile-value { font-size: 1.3rem; font-weight: 600; color: #1d1d1f; font-variant-numeric: tabular-nums; }
90
+ .gsc-analyzer-panel__caption { margin: 0.8rem 0 0; font-size: 0.78rem; color: #666; font-style: italic; text-align: center; }
91
+ .gsc-analyzer-panel__caption :deep(code) { background: #f0f0f3; padding: 0.05rem 0.3rem; border-radius: 3px; font-size: 0.74rem; color: #4c3ca0; }
92
+ .gsc-analyzer-panel__error { padding: 1rem 1.25rem; color: #c00; background: #fff5f5; font-family: ui-monospace, monospace; font-size: 0.82rem; white-space: pre-wrap; }
93
+ .gsc-analyzer-panel__loading, .gsc-analyzer-panel__empty { padding: 2.5rem; text-align: center; color: var(--ui-text-dimmed); font-size: 0.9rem; }
94
+ </style>
@@ -0,0 +1,46 @@
1
+ <script setup lang="ts">
2
+ // Surfaces the trailing-28d impression-weighted anonymization % from the
3
+ // daily_totals rollup. GSC anonymises queries for low-volume or sensitive
4
+ // terms, so query-grained breakdowns (keywords, pages-by-query) undercount
5
+ // impressions by this amount. Hidden when data isn't ready or the gap is
6
+ // negligible (<1%).
7
+
8
+ import { useGscRollup } from '../composables/useGscRollup'
9
+ import { weightedAnonPct } from '../utils/anonymization'
10
+
11
+ interface DailyTotal {
12
+ impressions: number
13
+ anonymizedImpressionsPct: number
14
+ }
15
+
16
+ const { siteIdentifier } = defineProps<{
17
+ siteIdentifier: string | null | undefined
18
+ }>()
19
+
20
+ const { data: daily } = useGscRollup<DailyTotal[]>(() => siteIdentifier ?? null, 'daily_totals')
21
+
22
+ const formatted = computed(() => {
23
+ if (!siteIdentifier)
24
+ return null
25
+ const pct = weightedAnonPct(daily.value)
26
+ if (pct == null)
27
+ return null
28
+ const percent = pct * 100
29
+ if (percent < 1)
30
+ return null
31
+ return percent.toFixed(percent < 10 ? 1 : 0)
32
+ })
33
+ </script>
34
+
35
+ <template>
36
+ <div
37
+ v-if="formatted"
38
+ class="flex items-start gap-2 border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-200/90"
39
+ >
40
+ <UIcon name="i-ph-info" class="mt-0.5 flex-shrink-0 text-amber-400" />
41
+ <div>
42
+ <span class="font-semibold">~{{ formatted }}% of impressions are anonymised by Google.</span>
43
+ Query-grained breakdowns below exclude these, so the sum won't match site-level totals.
44
+ </div>
45
+ </div>
46
+ </template>
@@ -0,0 +1,297 @@
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>
@@ -0,0 +1,77 @@
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>