diff --git a/frontend/src/AnalyticsView.vue b/frontend/src/AnalyticsView.vue index ac6e71f..f7a3e3a 100644 --- a/frontend/src/AnalyticsView.vue +++ b/frontend/src/AnalyticsView.vue @@ -218,6 +218,7 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c :ip-display="v.ipDisplay" :ua="v.ua" :ua-raw="v.uaRaw" + :ua-url="v.uaUrl" :country="v.country" :city="v.city" :lang="v.lang" @@ -253,6 +254,7 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c :ip-display="c.ipDisplay" :ua="c.ua" :ua-raw="c.uaRaw" + :ua-url="c.uaUrl" :country="c.country" :city="c.city" :lang="c.lang" @@ -299,6 +301,8 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c :ip-display="a.ipDisplay" :ua="a.ua" :ua-raw="a.uaRaw" + :ua-url="a.uaUrl" + :ua-raws="a.uaRaws" :country="a.country" :city="a.city" :lang="a.lang" @@ -506,22 +510,6 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c .visit-table .clickable-list, .visit-table .last-seen { cursor: pointer; - position: relative; -} - -.visit-table :deep(.copy-popup) { - position: absolute; - bottom: calc(100% + 0.25rem); - left: 50%; - transform: translateX(-50%); - padding: 0.15rem 0.4rem; - background: var(--text, CanvasText); - color: var(--bg, Canvas); - border-radius: 0.25rem; - font-size: 0.75rem; - white-space: nowrap; - pointer-events: none; - z-index: 10; } .crawler-top-uas { diff --git a/frontend/src/BannerEditor.vue b/frontend/src/BannerEditor.vue index 543ffa4..674bec5 100644 --- a/frontend/src/BannerEditor.vue +++ b/frontend/src/BannerEditor.vue @@ -403,14 +403,6 @@ onUnmounted(() => { margin-left: auto; padding: 0 0.2rem; font-size: 1rem; - background: none; - border: none; - cursor: pointer; - opacity: 0.7; -} - -.block-head .icon-btn:hover { - opacity: 1; } /* The banner design selector stays compact; the upload button is pushed diff --git a/frontend/src/SiteEditor.vue b/frontend/src/SiteEditor.vue index 0c09b33..f2a3534 100644 --- a/frontend/src/SiteEditor.vue +++ b/frontend/src/SiteEditor.vue @@ -782,14 +782,6 @@ onUnmounted(() => { margin-left: auto; padding: 0 0.2rem; font-size: 1rem; - background: none; - border: none; - cursor: pointer; - opacity: 0.7; -} - -.block-head .icon-btn:hover { - opacity: 1; } .text-input { diff --git a/frontend/src/VisitorCell.vue b/frontend/src/VisitorCell.vue index d5d9eb1..75db0c4 100644 --- a/frontend/src/VisitorCell.vue +++ b/frontend/src/VisitorCell.vue @@ -4,15 +4,20 @@ // Clicking the IP copies the full address to the clipboard. // ``variantCount`` overrides the UA line to warn when multiple client // fingerprints share the same IP (e.g. a scanner rotating UAs). +// Clicking the UA line copies the raw UA(s) to the clipboard, one per line +// (``uaRaws`` carries every variation for multi-client IPs). import { computed } from 'vue' import * as flagSvgs from 'country-flag-icons/string/3x2' -import { copyIp, formatLang } from './analytics/format.js' +import { copyIp, copyList, formatLang } from './analytics/format.js' +import { langName } from './langs.js' const props = defineProps({ ip: { type: String, default: '' }, ipDisplay: { type: String, default: '—' }, ua: { type: String, default: '' }, uaRaw: { type: String, default: '' }, + uaRaws: { type: String, default: '' }, + uaUrl: { type: String, default: '' }, country: { type: String, default: '' }, city: { type: String, default: '' }, lang: { type: String, default: '' }, @@ -26,6 +31,7 @@ const hasCity = computed(() => !!(props.city && props.city !== '—')) const hasLocale = computed(() => hasCountry.value || hasCity.value) const langValue = computed(() => props.langDisplay || formatLang(props.lang)) const showLang = computed(() => langValue.value && langValue.value !== '—') +const uaCopy = computed(() => props.uaRaws || props.uaRaw) function flagSvg(code) { return flagSvgs[code?.toUpperCase()] || '' @@ -58,10 +64,16 @@ function countryName(code) {
- {{ variantCount }} client variations - {{ ua || '—' }} + {{ variantCount }} client variations + {{ ua || '—' }}🔗
-
{{ langValue }}
+
{{ langValue }}
@@ -121,6 +133,13 @@ function countryName(code) { text-align: left; } +.ua-link { + text-decoration: none; + font-size: 0.75em; + margin-left: 0.2em; + vertical-align: middle; +} + .locale-lang { flex: 0 0 auto; overflow: hidden; diff --git a/frontend/src/analytics/format.js b/frontend/src/analytics/format.js index 95e1186..97da743 100644 --- a/frontend/src/analytics/format.js +++ b/frontend/src/analytics/format.js @@ -25,32 +25,31 @@ export const hostIP = (ip) => { } } -function showCopiedFeedback(el) { - if (!el || typeof document === 'undefined') return +function showCopiedFeedback(el, event) { + if (typeof document === 'undefined') return const popup = document.createElement('span') popup.textContent = 'Copied!' popup.className = 'copy-popup' + // Fixed to the viewport at the click point: table cells clip absolute + // popups with their overflow: hidden ellipsis styling. + const x = event?.clientX ?? 0 + const y = event?.clientY ?? 0 popup.style.cssText = - 'position:absolute;bottom:calc(100% + 0.25rem);left:50%;' + - 'transform:translateX(-50%);padding:0.15rem 0.4rem;' + + `position:fixed;left:${x}px;top:${y}px;` + + 'transform:translate(-50%, calc(-100% - 0.5rem));padding:0.15rem 0.4rem;' + 'background:var(--text, CanvasText);color:var(--bg, Canvas);' + 'border-radius:0.25rem;font-size:0.75rem;white-space:nowrap;' + - 'pointer-events:none;z-index:10;' - el.classList.add('has-copy-popup') - el.appendChild(popup) - setTimeout(() => { - popup.remove() - el.classList.remove('has-copy-popup') - }, 1200) + 'pointer-events:none;z-index:100;' + document.body.appendChild(popup) + setTimeout(() => popup.remove(), 1200) } /** Copy the full IP to the clipboard and show a brief "Copied!" popup. */ export async function copyIp(ip, event) { if (!ip) return - const el = event?.currentTarget try { await navigator.clipboard.writeText(ip) - showCopiedFeedback(el) + showCopiedFeedback(event?.currentTarget, event) } catch { /* ignore */ } @@ -59,10 +58,9 @@ export async function copyIp(ip, event) { /** Copy arbitrary text to the clipboard and show a brief "Copied!" popup. */ export async function copyList(text, event) { if (!text) return - const el = event?.currentTarget try { await navigator.clipboard.writeText(text) - showCopiedFeedback(el) + showCopiedFeedback(event?.currentTarget, event) } catch { /* ignore */ } @@ -342,7 +340,7 @@ export function countCrawlerUas(crawlers, clients) { const counts = {} for (const c of crawlers || []) { const client = (clients || {})[c.client] || {} - const value = client.ua_pretty || client.ua || '(no UA)' + const value = client.uarite?.pretty || client.ua || '(no UA)' counts[value] = (counts[value] || 0) + 1 } return Object.entries(counts).sort((a, b) => b[1] - a[1]) @@ -421,8 +419,9 @@ export function formatCrawlerRows(crawlers, clients, pageTree, now = Date.now()) ip: client.ip || '', ipDisplay: isHost ? mainDomain(host) : hostIP(client.ip) || client.ip || '—', isHost, - ua: client.ua_pretty || client.ua || '—', + ua: client.uarite?.pretty || client.ua || '—', uaRaw: client.ua || '', + uaUrl: client.uarite?.url || '', lang: client.lang || '—', langDisplay: formatLang(client.lang), country: client.country || '—', @@ -505,6 +504,13 @@ export function formatAbuseRows(abuse, clients, pageTree, now = Date.now()) { const client = (clients || {})[g.lastClient] || {} const host = client.host || '' const isHost = !!host + const uaRaws = [ + ...new Set( + [...g.clientHashes] + .map((h) => (clients || {})[h]?.ua) + .filter(Boolean), + ), + ].join('\n') return { lastSeen: formatWhen(g.lastStart, now), lastSeenIso: formatWhenIso(g.lastStart), @@ -527,8 +533,10 @@ export function formatAbuseRows(abuse, clients, pageTree, now = Date.now()) { ip: client.ip || g.ip, ipDisplay: isHost ? mainDomain(host) : hostIP(client.ip || g.ip) || client.ip || g.ip || '—', isHost, - ua: client.ua_pretty || client.ua || '—', + ua: client.uarite?.pretty || client.ua || '—', uaRaw: client.ua || '', + uaUrl: client.uarite?.url || '', + uaRaws, lang: client.lang || '—', langDisplay: formatLang(client.lang), country: client.country || '—', @@ -582,8 +590,9 @@ export function formatVisitRows(visits, clients, pageTree, now = Date.now()) { lang: dash(client.lang), country: dash(client.country), city: dash(client.city), - ua: client.ua_pretty || client.ua || '—', + ua: client.uarite?.pretty || client.ua || '—', uaRaw: client.ua || '', + uaUrl: client.uarite?.url || '', utm: utm || '—', utmTitle, } diff --git a/frontend/src/assets/pagerite.css b/frontend/src/assets/pagerite.css index ae0efd1..97c64dd 100644 --- a/frontend/src/assets/pagerite.css +++ b/frontend/src/assets/pagerite.css @@ -756,6 +756,20 @@ article { position: relative; } +/* Emoji/symbol icon buttons and links: dim until hovered. */ +.icon-btn { + padding: 0; + font: inherit; + background: none; + border: none; + cursor: pointer; + opacity: 0.7; +} + +.icon-btn:hover { + opacity: 1; +} + .edit-link { position: absolute; top: 0.2rem; @@ -763,12 +777,6 @@ article { left: -2.2rem; z-index: 2; /* stay above full-bleed .wide images */ - font: inherit; - background: none; - border: none; - padding: 0; - cursor: pointer; - opacity: 0.7; text-shadow: 0 0 0.1em black; } @@ -790,24 +798,14 @@ article h2 .edit-section { opacity: 0.35; } -.edit-link:hover { - opacity: 1; -} - /* Login/profile links injected by pagerite.js when Paskia SSO is in use. They live inside the .editor-pens flex container in the banner's top-right - corner and inherit its reset; keep only their opacity/text-shadow tweaks. */ + corner and inherit its reset; keep only their text-shadow tweak. */ .editor-pens a.login-link, .editor-pens a.profile-link { - opacity: 0.7; text-shadow: 0 0 0.1em black; } -.editor-pens a.login-link:hover, -.editor-pens a.profile-link:hover { - opacity: 1; -} - article p, article li, article dd { diff --git a/frontend/src/pagerite.js b/frontend/src/pagerite.js index 9a609bc..3fdc86c 100644 --- a/frontend/src/pagerite.js +++ b/frontend/src/pagerite.js @@ -132,16 +132,16 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect"; if (line != null) { // Section pen on an anchored h2: opens the page editor at the // section's markdown source line (data-line, from the backend). - btn.className = "edit-link edit-section"; + btn.className = "edit-link edit-section icon-btn"; btn.title = "edit section"; btn.textContent = "🖊️"; btn.dataset.editorLine = line; } else if (mode === "page") { - btn.className = "edit-link edit-page"; + btn.className = "edit-link edit-page icon-btn"; btn.title = "edit page"; btn.textContent = "🖊️"; } else { - btn.className = "edit-link site-edit-link"; + btn.className = "edit-link site-edit-link icon-btn"; btn.title = "site settings"; btn.textContent = "⚙️"; } @@ -165,7 +165,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect"; function makeAuthLink(admin) { const a = document.createElement("a"); - a.className = admin ? "profile-link" : "login-link"; + a.className = (admin ? "profile-link" : "login-link") + " icon-btn"; a.href = "/auth/"; a.title = admin ? "profile" : "log in"; a.textContent = admin ? "\u{1F510}" : "\u{1F511}"; @@ -211,7 +211,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect"; if (canEdit && !onAnalytics) { // Analytics viewer is now a normal page at /_a. const a = document.createElement("a"); - a.className = "edit-link analytics-link"; + a.className = "edit-link analytics-link icon-btn"; a.href = "/_a"; a.title = "analytics"; a.textContent = "📊";