analytics viewer: referer + UTM as one neutral-grey badge

Favicon flush to the badge's left edge, then the referer host and UTM
summary in one link, with a single newline-formatted tooltip (origin,
then each utm_* pair). Fixed --badge-bg/--badge-text/--badge-muted
palette (defined in pagerite.css, deliberately unthemed) so transparent
favicons stay legible in both light and dark themes; own-article trail
links remain plain, lifting referer info visually apart from them.
Replaces the outlined utm-tag pill.
This commit is contained in:
2026-09-15 05:20:57 +00:00
parent 3b1804b093
commit 7724290921
4 changed files with 119 additions and 29 deletions
+7 -12
View File
@@ -23,6 +23,7 @@ import {
formatVisitRows,
} from './analytics/format.js'
import TrailLink from './TrailLink.vue'
import RefererBadge from './RefererBadge.vue'
import VisitorCell from './VisitorCell.vue'
import TransitionGraph from './TransitionGraph.vue'
import VisitorCharts from './VisitorCharts.vue'
@@ -215,8 +216,7 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
<tbody>
<tr v-for="(v, i) in visitRows" :key="i">
<td class="trail">
<TrailLink v-if="v.refererStep" :step="v.refererStep" :favicons="favicons" @close="$emit('close')" />
<span v-if="v.utm && v.utm !== '—'" class="utm-tag small muted" :title="v.utmTitle">{{ v.utm }}</span>
<RefererBadge v-if="v.refererBadge" :badge="v.refererBadge" :favicons="favicons" />
<span v-if="v.rowFlag" class="flag" v-html="v.rowFlag" :title="v.rowFlagTitle"></span>
<TrailLink v-for="(s, si) in v.trail" :key="si" :step="s" :favicons="favicons" :flags="s.langFlags" @close="$emit('close')" />
</td>
@@ -253,7 +253,7 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
<tbody>
<tr v-for="(c, i) in crawlerRows" :key="i">
<td class="trail">
<TrailLink v-if="c.refererStep" :step="c.refererStep" :favicons="favicons" @close="$emit('close')" />
<RefererBadge v-if="c.refererBadge" :badge="c.refererBadge" :favicons="favicons" />
<TrailLink v-for="(s, si) in c.pages" :key="si" :step="s" :count="s.count" @close="$emit('close')" />
<span v-for="(f, fi) in c.readFlags" :key="fi" class="flag" v-html="f.flag" :title="f.name"></span>
</td>
@@ -480,16 +480,11 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
color: var(--error, #c00);
}
.visit-table .utm-tag {
display: inline-block;
/* The referer badge outgrows the 8rem trail-link cap (it carries the UTM
summary too); keep the inline-flex layout from the component. */
.visit-table .trail a.referer-badge {
display: inline-flex;
max-width: 100%;
padding: 0.05rem 0.4rem;
border: 1px solid var(--line);
border-radius: 0.25rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: bottom;
}
/* Same flag chip as the visitor cells (VisitorCell.vue); the flags here
+68
View File
@@ -0,0 +1,68 @@
<script setup>
// Referer + UTM as one badge in the analytics visit/crawler tables: the
// referer's favicon flush on the left, its host as the link text, then the
// UTM summary smaller/muted inside the same badge. The whole badge links to
// the referer origin (external, new tab) and carries a single one-fact-
// per-line tooltip (badge.title: origin, then each utm pair) — no titles on
// the inner elements. Referers are external, so there is no close event.
import { computed } from 'vue'
const props = defineProps({
badge: { type: Object, required: true },
favicons: { type: Object, default: null },
})
const favicon = computed(() => (props.badge.origin ? props.favicons?.[props.badge.origin] : null))
</script>
<template>
<a class="referer-badge"
:href="badge.href || undefined"
:title="badge.title"
:target="badge.href ? '_blank' : undefined"
:rel="badge.href ? 'noopener' : undefined">
<img v-if="favicon" class="badge-favicon" :src="favicon" alt="" />
<span v-if="badge.label">{{ badge.label }}</span>
<small v-if="badge.utm" class="small">{{ badge.utm }}</small>
</a>
</template>
<style scoped>
/* Browser-chrome chip on a fixed neutral palette (--badge-* in
pagerite.css, deliberately unthemed): black-on-transparent and
white-on-transparent favicons both stay legible on it, and the text is
always dark regardless of the theme's link/text colors. Colors go on the
inner elements, so the theme's a / a:hover color rules (which target the
anchor) cannot cascade in. */
.referer-badge {
display: inline-flex;
align-items: center;
gap: 0.35em;
padding-right: 0.45em;
border-radius: 0.25rem;
background: var(--badge-bg);
color: var(--badge-text);
white-space: nowrap;
overflow: hidden;
}
/* Flush with the badge's top/left/bottom edges: a full-height square (the
badge has no padding on those sides), corners clipped by the badge's
overflow: hidden border-radius. */
.badge-favicon {
width: 1.5em;
height: 1.5em;
object-fit: cover;
flex: none;
}
.referer-badge > span,
.referer-badge > small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.referer-badge > span { color: var(--badge-text); }
.referer-badge > small { color: var(--badge-muted); }
</style>
+36 -17
View File
@@ -177,6 +177,32 @@ function stepOf(path, titles) {
return null
}
/**
* Badge data combining a visit's/crawler's external referer origin with the
* visit's UTM tags: the origin as the badge link/label (the favicon is
* looked up by origin in the component), the known UTM values as a short
* inline summary, and a one-fact-per-line tooltip — the full origin URL on
* the first line, then every ``utm_*=value`` pair. Null when there is no
* external referer and no UTM tag (a plain direct visit).
*/
function refererBadgeOf(referer, titles, utmTags = {}) {
const step = stepOf(referer, titles)
const external = step?.external ? step : null
const known = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']
const utm = known.map((k) => utmTags[k]).filter(Boolean).join(' · ')
if (!external && !utm) return null
return {
href: external?.origin || '',
label: external?.slug || '',
origin: external?.origin || '',
utm,
title: [
...(external ? [external.origin] : []),
...Object.entries(utmTags).map(([k, value]) => `${k}=${value}`),
].join('\n'),
}
}
/**
* Human-readable relative timestamp. Adapted from cista-storage: uses
* ``Intl.RelativeTimeFormat`` for short intervals and a compact date for
@@ -369,9 +395,10 @@ export function mainDomain(host, limit = 24) {
/**
* Group raw crawler hits by client hash and format each group as a row showing
* every internal page that crawler visited. Rows are sorted by most recent hit
* first, with total hits as a tie-breaker. The group's ``refererStep`` is the
* latest external referer seen for the crawler — spiders often advertise
* their own site there — rendered with its favicon like visit referers.
* first, with total hits as a tie-breaker. The group's ``refererBadge`` is
* the latest external referer seen for the crawler — spiders often advertise
* their own site there — rendered as a badge with its favicon like visit
* referers.
* ``clients`` maps client hashes to client records.
*/
export function formatCrawlerRows(crawlers, clients, pageTree, now = Date.now(), site = { multilingual: false, primaryLang: '' }) {
@@ -420,7 +447,7 @@ export function formatCrawlerRows(crawlers, clients, pageTree, now = Date.now(),
lastSeen: formatWhen(g.lastStart, now),
lastSeenIso: formatWhenIso(g.lastStart),
lastSeenLocal: formatWhenLocal(g.lastStart),
refererStep: stepOf(g.referer, titles),
refererBadge: refererBadgeOf(g.referer, titles),
pages: [...g.pages.entries()]
.sort((a, b) => b[1].count - a[1].count)
.map(([path, info]) => ({ ...stepOf(path, titles), count: info.count, status: info.status })),
@@ -559,9 +586,10 @@ export function formatAbuseRows(abuse, clients, pageTree, now = Date.now()) {
/**
* Format raw visit records as rows for a technical table. Returns objects
* with display strings; missing values become "—". ``trail`` starts with the
* external referer (when present), then the entry page and any further internal
* pages or external exit origins; consecutive views of the same page (e.g. a
* with display strings; missing values become "—". The external referer
* (when present) and the UTM tags ride along as ``refererBadge``; ``trail``
* holds the entry page and any further internal pages or external exit
* origins; consecutive views of the same page (e.g. a
* language switch re-view) merge into one step that keeps the
* consecutive-distinct rendered languages, summed read time, and the latest
* status. On multilingual sites the rendered languages surface as flag
@@ -599,12 +627,6 @@ export function formatVisitRows(visits, clients, pageTree, now = Date.now(), sit
}
}
const distinctLangs = new Set(trail.flatMap((s) => s.langs))
const utmKeys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']
const utmValues = utmKeys.map((k) => (v.utm || {})[k]).filter(Boolean)
const utm = utmValues.length ? utmValues.join(' · ') : ''
const utmTitle = Object.entries(v.utm || {})
.map(([k, value]) => `${k}=${value}`)
.join(', ')
const dash = (s) => (s || '—')
const host = client.host || ''
const isHost = !!host
@@ -614,8 +636,7 @@ export function formatVisitRows(visits, clients, pageTree, now = Date.now(), sit
lastSeenLocal: formatWhenLocal(v.start),
langDisplay: formatLang(client.lang),
trail,
refererStep: stepOf(v.referer, titles),
referer: dash(v.referer),
refererBadge: refererBadgeOf(v.referer, titles, v.utm),
ip: client.ip || '',
ipDisplay: isHost ? mainDomain(host) : hostIP(client.ip) || client.ip || '—',
isHost,
@@ -625,8 +646,6 @@ export function formatVisitRows(visits, clients, pageTree, now = Date.now(), sit
ua: client.uarite?.pretty || client.ua || '—',
uaRaw: client.ua || '',
uaUrl: client.uarite?.url || '',
utm: utm || '—',
utmTitle,
}
if (site.multilingual && distinctLangs.size) {
if (distinctLangs.size === 1) {
+8
View File
@@ -26,6 +26,14 @@
/* Selection fill for page text and the CodeMirror editors; themes
override when the accent tint clashes with accent-colored text. */
--selection-bg: color-mix(var(--accent) 30%, transparent);
/* Referer-badge chip in the analytics viewer: a fixed neutral palette,
deliberately NOT themed — the chip sits behind transparent favicons, so
black-on-transparent and white-on-transparent glyphs must both stay
legible on every theme (dark greys kill black glyphs, pure white kills
white ones); its text likewise stays dark on any theme. */
--badge-bg: #c9d1d9;
--badge-text: #1f2328;
--badge-muted: #59636e;
/* Code highlighting palette, consumed by pygments.css: complete light and
dark sets (background included), resolved by light-dark() from the
used color-scheme. A theme picks a set simply by declaring