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 = "📊";