Compare commits

..
6 Commits
Author SHA1 Message Date
LeoVasanko 158b5f1961 Editor panel layout fixes, use body scroll and bidirectional sync with editor. 2026-08-24 21:41:33 +00:00
LeoVasanko f9ce0a4f3b Smarter initial analytics page when there is no visitor data yet. 2026-08-24 21:13:20 +00:00
LeoVasanko 2d595f8c15 Reduce #brand to actual size, avoid clicks on empty banner space touching it. 2026-08-24 20:58:50 +00:00
LeoVasanko b1fc8e24d7 Eyes banner follows taps not just mouse. 2026-08-24 20:54:50 +00:00
LeoVasanko c5cf799f68 Better nav layout for portrait phones. 2026-08-24 20:43:18 +00:00
LeoVasanko a5edc7b3b6 Fix crawler misclassification from /_a and orphan counts on visit scrub
Two analytics corrections verified against the production capture:

- pagerite.js suppressed pings with fr == '/_a', but fetch-navigation
  away from the analytics page had already GET-ed the target without the
  preload header; the orphaned pending hit then flushed to the crawler
  list, classifying a real user as a crawler. Navigations away from /_a
  now ping normally (the server rejects /_a as a target regardless, and
  admin noise is already handled by hide=1).

- _remove_visit only reversed the visit's creation counts, leaving
  views/transitions from later pings behind as orphans on the graph with
  no matching row in the visitor table. An in-memory per-visit count log
  now tracks every count event, so an admin hide=1 scrub reverses the
  visit completely.
2026-08-24 20:18:13 +00:00
12 changed files with 236 additions and 101 deletions
+10 -7
View File
@@ -45,16 +45,19 @@ The client (`pagerite.js`) POSTs fire-and-forget pings to `/_a` with
back), so the exit URL is not necessarily the last trail entry. Outbound back), so the exit URL is not necessarily the last trail entry. Outbound
links are stored by full URL so several links to the same domain remain links are stored by full URL so several links to the same domain remain
distinct. distinct.
- **Excluded**: back/forward (popstate) navigations, navigation involving - **Excluded**: back/forward (popstate) navigations, navigating *to* the
the analytics page itself (`/_a`), and everything while the user has the analytics page (`/_a` — its GET is untracked, and the server rejects it
editor open (`body.editing`). Admin noise, not visits. as a ping target anyway), and everything while the user has the editor
open (`body.editing`). Admin noise, not visits. Navigating *away* from
`/_a` does ping: the fetch-navigation already GET-ed the target page
without the preload header, and without the ping that GET would flush to
the crawler list.
- **Admins**: when SSO is in use and the session is known to be an admin, - **Admins**: when SSO is in use and the session is known to be an admin,
the client still pings but adds `hide=1`. The server then records the client still pings but adds `hide=1`. The server then records
nothing — and if the same client session already had a visit from before nothing — and if the same client session already had a visit from before
logging in, that visit is removed from the JSON along with the counts logging in, that visit is removed from the JSON along with every count
recorded when it was created (site visit, entry view, entry transition). it recorded — an in-memory per-visit log of count events makes full
Views/transitions logged by later pings inside such a visit lack reversal possible. With no auth proxy (dev/test)
per-event timestamps and are left as-is. With no auth proxy (dev/test)
"admin" is everyone's state, so `hide` stays 0 and everything is recorded. "admin" is everyone's state, so `hide` stays 0 and everything is recorded.
- The server validates `to`: internal paths must be valid slug paths - The server validates `to`: internal paths must be valid slug paths
("/" or `[a-z0-9_-]` segments), external ones are re-derived to the ("/" or `[a-z0-9_-]` segments), external ones are re-derived to the
+1 -1
View File
@@ -45,7 +45,7 @@ Pagerite is a single-user CMS/blog. This document records the initial high-level
## Editing ## Editing
- Editing happens **in place**, in two modes opened by two pens: - Editing happens **in place**, in two modes opened by two pens:
- **Page mode** — the 🖊️ next to a page's heading (including 404s, which is how new pages start) opens a CodeMirror Markdown editor docked to the left of the article: the host sits inside `#content` (below the banner, never over the footer), the content shifts right and the sidebar hides while editing. Preview renders server-side per keystroke (no debouncing) straight into the visible article's heading and body. - **Page mode** — the 🖊️ next to a page's heading (including 404s, which is how new pages start) opens a CodeMirror Markdown editor docked to the left of the article: the panel is fixed to the viewport's left edge (its top tracks the banner's bottom until the banner scrolls away), the content shifts right and the sidebar hides while editing. Preview renders server-side per keystroke (no debouncing) straight into the visible article's heading and body.
- **Site mode** — the 🖊️ on the banner opens a panel with the site **brand** (applied to the header live), a **theme** selector (swapping the theme stylesheet in place), **font** picks (heading/body/brand — stored as plain `:root` rows inside the custom CSS, referencing the base stylesheet's per-family font variables), a **site-wide custom CSS** field (injected into `<style id="pagerite-user">` in the live page head and swapped during fetch-navigation), the page's **banner design** selector (inherit / none / any design found on disk, inherited by children), the page's **banner HTML** field (supplementing the design, previewed into the real banner region, so you see exactly which banner you're editing) and the **structure tree**. Everything saves immediately as you edit — no save button, no edit mode. - **Site mode** — the 🖊️ on the banner opens a panel with the site **brand** (applied to the header live), a **theme** selector (swapping the theme stylesheet in place), **font** picks (heading/body/brand — stored as plain `:root` rows inside the custom CSS, referencing the base stylesheet's per-family font variables), a **site-wide custom CSS** field (injected into `<style id="pagerite-user">` in the live page head and swapped during fetch-navigation), the page's **banner design** selector (inherit / none / any design found on disk, inherited by children), the page's **banner HTML** field (supplementing the design, previewed into the real banner region, so you see exactly which banner you're editing) and the **structure tree**. Everything saves immediately as you edit — no save button, no edit mode.
- Clicking a pen again closes the editor (without saving; a dirty preview reloads the page). The pens are `<button>`s wired up by `pagerite.js` — editing is an action, not a navigation. The editor's WebSocket **reconnects automatically** with local text and pending saves preserved. (All users are trusted authors for now; access control later with SSO.) - Clicking a pen again closes the editor (without saving; a dirty preview reloads the page). The pens are `<button>`s wired up by `pagerite.js` — editing is an action, not a navigation. The editor's WebSocket **reconnects automatically** with local text and pending saves preserved. (All users are trusted authors for now; access control later with SSO.)
- **CodeMirror 6** for Markdown editing (no WYSIWYG), title/published controls. Images can be pasted straight into the editor or chosen via a file input: they upload to the content store (`PUT /_api/files/...`) and insert `![alt](/_f/hash.ext)` at the cursor. - **CodeMirror 6** for Markdown editing (no WYSIWYG), title/published controls. Images can be pasted straight into the editor or chosen via a file input: they upload to the content store (`PUT /_api/files/...`) and insert `![alt](/_f/hash.ext)` at the cursor.
+1 -1
View File
@@ -6,7 +6,7 @@ The Vue editor is a single tabbed `EditorShell.vue` mounted in a host div create
The shell hosts four kept-alive tabs (ordered site-wide first — site, structure — then, after a visual break, the per-page tabs — article, banner): The shell hosts four kept-alive tabs (ordered site-wide first — site, structure — then, after a visual break, the per-page tabs — article, banner):
- `PageEditor.vue` — CodeMirror + server-rendered preview over WebSocket `/_api/ws/editor`, previewing into the visible article; editor scroll drives the article scroll — while any editor is open the window scroll is locked (`body.editing`), the panel exactly fills the available window height, and only `#main` scrolls; a format bar offers Markdown helpers — bold/italic/code/link/table/image upload, with Ctrl/Cmd-B/I/S bindings — for the hard-to-remember syntax. Edits content and title only, never the path. - `PageEditor.vue` — CodeMirror + server-rendered preview over WebSocket `/_api/ws/editor`, previewing into the visible article; editor and article scrolls are linked proportionally both ways, applied instantly (the window keeps scrolling normally while any editor is open the panel is fixed to the viewport's left edge, its top tracking the banner's bottom edge until the banner scrolls away — and the panel scrolls internally); a format bar offers Markdown helpers — bold/italic/code/link/table/image upload, with Ctrl/Cmd-B/I/S bindings — for the hard-to-remember syntax. Edits content and title only, never the path.
- `BannerEditor.vue` — per-page banner HTML + banner design selector, previewed into `#page-banner`. - `BannerEditor.vue` — per-page banner HTML + banner design selector, previewed into `#page-banner`.
- `SiteEditor.vue` — site brand + optional custom brand HTML with image/video upload + theme selector + font picker + favicon upload — clicking the preview tile picks a new one — + site-wide custom CSS, CSS injected into `<head id="pagerite-user">`. - `SiteEditor.vue` — site brand + optional custom brand HTML with image/video upload + theme selector + font picker + favicon upload — clicking the preview tile picks a new one — + site-wide custom CSS, CSS injected into `<head id="pagerite-user">`.
- `StructureEditor.vue` — the vue-draggable structure tree with always-editable title/slug inputs per row. - `StructureEditor.vue` — the vue-draggable structure tree with always-editable title/slug inputs per row.
-1
View File
@@ -233,7 +233,6 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
</tbody> </tbody>
</table> </table>
</div> </div>
<p v-else class="empty">no crawler hits recorded yet</p>
<div v-if="abuseRows.length" class="visit-table-wrap"> <div v-if="abuseRows.length" class="visit-table-wrap">
<table class="visit-table"> <table class="visit-table">
+36 -12
View File
@@ -4,9 +4,9 @@
// (/_api/ws/editor). Docked left of the article on the page itself. // (/_api/ws/editor). Docked left of the article on the page itself.
// The socket connects when the editor is opened and reconnects with // The socket connects when the editor is opened and reconnects with
// exponential backoff after a failure; unsaved text and pending saves // exponential backoff after a failure; unsaved text and pending saves
// survive a disconnect. Editor scroll drives the article scroll (while // survive a disconnect. Editor and article (window) scrolls are linked
// editing the window scroll is locked and only #main scrolls), keeping the // proportionally both ways (syncWindowToEditor / syncEditorToWindow).
// rendered article at the cursor's position. Saving (💾 / Ctrl+S) is explicit // Saving (💾 / Ctrl+S) is explicit
// and refreshes the page regions in place — never a reload — so the editor // and refreshes the page regions in place — never a reload — so the editor
// state (unsaved text included) also survives closing the shell; it is lost // state (unsaved text included) also survives closing the shell; it is lost
// only on a real page reload. // only on a real page reload.
@@ -307,19 +307,37 @@ function onEditorShown() {
if (dirty.value) requestRender() if (dirty.value) requestRender()
} }
function syncScroll() { // Bidirectional proportional scroll sync between the CodeMirror scroller
// Editor scroll drives the article: keep the rendered page at the same // and the window (the article's scroller, also while editing). Both
// proportional position as the cursor area in the editor. While editing // directions apply instantly (never smooth — a smooth window scroll feeds
// the window scroll is locked and #main is the scrolling element. // its intermediate positions back into the editor and fights the user's
// scrolling) and coalesce to one update per frame. Loops are broken two
// ways: a one-frame driver flag, and a 1px tolerance so the scroll events
// caused by our own writes are no-ops. When the panel's height changes
// mid-scroll (its top tracks the banner), the page is the driver: the
// editor is re-matched to the page's position, never vice versa.
function syncWindowToEditor() {
if (syncingScroll || !view) return if (syncingScroll || !view) return
const main = document.getElementById('main')
if (!main) return
syncingScroll = true syncingScroll = true
requestAnimationFrame(() => { requestAnimationFrame(() => {
const scroller = view.scrollDOM const scroller = view.scrollDOM
const max = scroller.scrollHeight - scroller.clientHeight const max = scroller.scrollHeight - scroller.clientHeight
const pct = max > 0 ? scroller.scrollTop / max : 0 const pct = max > 0 ? scroller.scrollTop / max : 0
main.scrollTop = pct * (main.scrollHeight - main.clientHeight) const y = pct * Math.max(0, document.documentElement.scrollHeight - innerHeight)
if (Math.abs(scrollY - y) > 1) scrollTo({ top: y, behavior: 'instant' })
syncingScroll = false
})
}
function syncEditorToWindow() {
if (syncingScroll || !view) return
syncingScroll = true
requestAnimationFrame(() => {
const scroller = view.scrollDOM
const pageMax = Math.max(0, document.documentElement.scrollHeight - innerHeight)
const pct = pageMax > 0 ? scrollY / pageMax : 0
const top = pct * Math.max(0, scroller.scrollHeight - scroller.clientHeight)
if (Math.abs(scroller.scrollTop - top) > 1) scroller.scrollTop = top
syncingScroll = false syncingScroll = false
}) })
} }
@@ -379,7 +397,11 @@ onMounted(() => {
}), }),
parent: editorEl.value, parent: editorEl.value,
}) })
view.scrollDOM.addEventListener('scroll', syncScroll) view.scrollDOM.addEventListener('scroll', syncWindowToEditor)
// Page → editor: window scroll (and resizes, e.g. the panel growing when
// the banner scrolls away) re-match the editor to the page's position.
addEventListener('scroll', syncEditorToWindow, { passive: true })
addEventListener('resize', syncEditorToWindow)
// Opening the editor means you want to write: start focused. // Opening the editor means you want to write: start focused.
view.focus() view.focus()
window.__pageritePageEditor = { window.__pageritePageEditor = {
@@ -399,6 +421,8 @@ onUnmounted(() => {
} }
view?.destroy() view?.destroy()
delete window.__pageritePageEditor delete window.__pageritePageEditor
removeEventListener('scroll', syncEditorToWindow)
removeEventListener('resize', syncEditorToWindow)
removeEventListener('keydown', onKeydown) removeEventListener('keydown', onKeydown)
removeEventListener('pagerite:editor-shown', onEditorShown) removeEventListener('pagerite:editor-shown', onEditorShown)
}) })
@@ -606,7 +630,7 @@ onUnmounted(() => {
/* CodeMirror sits inside a bordered box, like a dialog's input area, with /* CodeMirror sits inside a bordered box, like a dialog's input area, with
a slight margin to the panel edges. Wheel scroll stays in the editor and a slight margin to the panel edges. Wheel scroll stays in the editor and
drives the article (syncScroll) instead of double-scrolling. */ drives the article (syncWindowToEditor) instead of double-scrolling. */
.editor { .editor {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
+2 -3
View File
@@ -68,8 +68,8 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
<template> <template>
<section v-for="c in [ <section v-for="c in [
{ ylabel: 'visits', chart: visitChart, legend: true, empty: 'no visits recorded yet' }, { ylabel: 'visits', chart: visitChart, legend: true },
{ ylabel: 'views', chart: viewChart, legend: false, empty: 'no views recorded yet' }, { ylabel: 'views', chart: viewChart, legend: false },
]" :key="c.ylabel"> ]" :key="c.ylabel">
<template v-if="c.chart"> <template v-if="c.chart">
<svg class="chart" :viewBox="`${-MARGIN_L} 0 ${VIEW_W} ${VIEW_H}`" <svg class="chart" :viewBox="`${-MARGIN_L} 0 ${VIEW_W} ${VIEW_H}`"
@@ -116,7 +116,6 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
</g> </g>
</svg> </svg>
</template> </template>
<p v-else class="empty">{{ c.empty }}</p>
</section> </section>
</template> </template>
+33 -4
View File
@@ -63,9 +63,24 @@ export function sumRange(raw, t0, t1) {
export function weeklySeries(buckets) { export function weeklySeries(buckets) {
const raw = rawTimes(buckets) const raw = rawTimes(buckets)
const times = Object.keys(raw).map(Number) const times = Object.keys(raw).map(Number)
if (!times.length) return null
const now = Date.now() const now = Date.now()
const thisMonday = mondayUTC(now) const thisMonday = mondayUTC(now)
if (!times.length) {
const points = []
const end = Math.min(thisMonday + WEEK, Math.floor(now / MIN5) * MIN5 + MIN5)
for (let t = thisMonday; t < end; t += MIN5) {
points.push({ t, count: 0 })
}
return {
series: [{ points, label: `Week ${isoWeek(thisMonday)}`, opacity: 1, area: true }],
t0: thisMonday,
t1: thisMonday + WEEK,
rate: HOUR / MIN5,
binMinutes: 5,
unitMinutes: 60,
unit: 'hour',
}
}
const oldest = Math.min(...times) const oldest = Math.min(...times)
// Weeks back as far as the data reaches: difference in Monday indices. // Weeks back as far as the data reaches: difference in Monday indices.
const available = (thisMonday - mondayUTC(oldest)) / WEEK + 1 const available = (thisMonday - mondayUTC(oldest)) / WEEK + 1
@@ -113,13 +128,27 @@ export function weeklySeries(buckets) {
export function rollingSeries(buckets, rangeKey) { export function rollingSeries(buckets, rangeKey) {
const raw = rawTimes(buckets) const raw = rawTimes(buckets)
const times = Object.keys(raw).map(Number) const times = Object.keys(raw).map(Number)
if (!times.length) return null
const { span, bucket, minSpan = 0 } = RANGES[rangeKey] const { span, bucket, minSpan = 0 } = RANGES[rangeKey]
const t1 = Date.now() const t1 = Date.now()
const earliest = Math.min(...times)
const t0 = Math.floor((span != null const t0 = Math.floor((span != null
? t1 - span ? t1 - span
: Math.min(earliest, t1 - minSpan)) / DAY) * DAY : Math.min(times.length ? Math.min(...times) : Infinity, t1 - minSpan)) / DAY) * DAY
if (!times.length) {
const bucketMs = t1 - t0 <= 31 * DAY ? Math.min(bucket, 6 * HOUR) : bucket
const points = []
for (let t = t0; t < t1; t += bucketMs) {
points.push({ t, count: 0 })
}
return {
series: [{ points, label: '', opacity: 1, area: true }],
t0,
t1,
rate: DAY / bucketMs,
binMinutes: bucketMs / 60e3,
unitMinutes: 24 * 60,
unit: 'day',
}
}
// The bucket follows the actual window length, not the range key: when // The bucket follows the actual window length, not the range key: when
// "all" is capped to its 30-day minimum it covers the very window "month" // "all" is capped to its 30-day minimum it covers the very window "month"
// does, and daily bins would draw a different curve over the same data // does, and daily bins would draw a different curve over the same data
+51 -36
View File
@@ -192,6 +192,10 @@ body {
/* One line always: pagerite.js shrinks the font size to fit instead of /* One line always: pagerite.js shrinks the font size to fit instead of
wrapping (the themed size is the maximum). */ wrapping (the themed size is the maximum). */
white-space: nowrap; white-space: nowrap;
/* Shrink-wrap to the text: as a flex child of the column-direction
#banner it would otherwise stretch full-width, making the empty banner
area beside the text a link to the front page. */
align-self: flex-start;
margin: auto 1.25rem 0; margin: auto 1.25rem 0;
padding-top: 1.5rem; padding-top: 1.5rem;
color: var(--text); color: var(--text);
@@ -265,10 +269,9 @@ body:has(.multicol) #content {
body.editing #content { body.editing #content {
margin-left: var(--editor-w); margin-left: var(--editor-w);
padding-left: 1rem; /* No padding/gap here: the article area starts flush at the panel's right
/* gap between the docked editor and the content */ edge so that full-bleed .wide images (anchored to that edge below) line
/* No overflow clipping here: .editor-host lives outside this box up with it exactly. */
(negative left), and .wide shrink-wraps to the remaining space. */
} }
/* The sidebar's gutter space is needed by the editor instead. */ /* The sidebar's gutter space is needed by the editor instead. */
@@ -276,33 +279,17 @@ body.editing #sidebar {
display: none; display: none;
} }
/* While editing, the window itself does not scroll: the editor panel is /* While editing, the window keeps scrolling normally (the overlay
exactly the remaining window height, and only the article area (#main) scrollbars take no layout space, so the vw-based .wide bleed stays exact
scrolls. Without the editor, normal full-page scroll applies. */ and no horizontal scrollbar appears). The editor panel is fixed to the
body.editing { viewport's left edge; main.js sets its top each scroll frame — the
height: 100vh; banner's bottom edge while the banner is visible, else the viewport top.
overflow: hidden; The panel scrolls internally. */
}
body.editing #content {
min-height: 0;
grid-template-rows: 100%;
}
body.editing #main {
height: 100%;
min-height: 0;
overflow-y: auto;
}
/* The editor host lives inside #content: it starts below the banner and
ends above the footer. With the page scroll locked while editing, the
panel exactly fills that space — the full available window height. */
.editor-host { .editor-host {
position: absolute; position: fixed;
top: 0; top: 0; /* main.js: banner bottom while visible, else 0 */
bottom: 0; bottom: 0;
left: calc(0px - var(--editor-w)); left: 0;
width: var(--editor-w); width: var(--editor-w);
} }
@@ -822,18 +809,18 @@ body:has(.multicol) figure:has(.wide) {
margin-inline: calc(-20vw - 1.25rem) 0; margin-inline: calc(-20vw - 1.25rem) 0;
} }
/* Editing: shrink the bleed to the space right of the docked editor. */ /* Editing: shrink the bleed to the space right of the docked editor. The
window keeps its overlay scrollbars while editing, so — unlike a classic
scrollbar — they take no layout space and the vw math stays exact. */
body.editing figure:has(.wide) { body.editing figure:has(.wide) {
width: calc(100vw - var(--editor-w)); width: calc(100vw - var(--editor-w));
margin-inline: calc(50% - (100vw - var(--editor-w)) / 2); margin-inline: calc(50% - (100vw - var(--editor-w)) / 2);
} }
/* Editing + multicol: the left gutter is 1/5 of the space right of the /* Editing + multicol: the left gutter is 1/5 of the space right of the
editor, and the bleed also crosses #content's 1rem editing gap plus editor, and the bleed also crosses main's 1.25rem left padding. */
main's 1.25rem left padding (the gutter shrink from the padding roughly
cancels the rounding): 2rem in total. */
body.editing:has(.multicol) figure:has(.wide) { body.editing:has(.multicol) figure:has(.wide) {
margin-inline: calc((100vw - var(--editor-w)) / -5 - 2rem) 0; margin-inline: calc((100vw - var(--editor-w)) / -5 - 1.25rem) 0;
} }
/* Narrow windows with a sidebar: below 102rem the symmetric gutters can no /* Narrow windows with a sidebar: below 102rem the symmetric gutters can no
@@ -907,6 +894,30 @@ article h2 {
(explicit img widths still shrink-wrap), while .wide keeps its full (explicit img widths still shrink-wrap), while .wide keeps its full
viewport bleed. */ viewport bleed. */
@media (max-width: 48rem) { @media (max-width: 48rem) {
/* Nav type shrinks fluidly as space runs out. The nav font-size is
em-based both in base and in every theme override, so scaling the
banner's font-size (nothing else in the banner is em-sized — brand and
gaps use rem) reaches the nav through all themes with a single rule.
2.6vw crosses 1rem at ≈38.5rem, so only genuinely narrow viewports
shrink. */
#banner {
font-size: clamp(0.65rem, 2.6vw, 1rem);
}
/* Tighter margins/padding/gaps: the 1.25rem side gutter is wasted space
on a phone. */
#brand {
margin-inline: 0.6rem;
}
#nav {
padding: 0.25rem 0.6rem;
}
#nav ul {
gap: 0.15rem 0.9rem;
}
#content { #content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -918,7 +929,11 @@ article h2 {
max-height: none; max-height: none;
overflow-y: visible; overflow-y: visible;
border-radius: 0; border-radius: 0;
padding: 0.5rem 1rem; padding: 0.4rem 0.8rem;
/* Smaller type: the horizontal link strip fits roughly a third more
items per line. The nested-list gaps below are em-based and shrink
along. */
font-size: 0.8rem;
} }
/* Only the main level becomes a horizontal wrapping strip; submenus stay /* Only the main level becomes a horizontal wrapping strip; submenus stay
@@ -926,7 +941,7 @@ article h2 {
#sidebar > ul { #sidebar > ul {
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem 1.2rem; gap: 0.3rem 0.75rem;
} }
figure:has(.right), figure:has(.right),
+25 -1
View File
@@ -23,6 +23,28 @@ let app = null
let savedTitle = null let savedTitle = null
let visible = false let visible = false
// The panel is fixed to the viewport's left edge (pagerite.css) but tracks
// the page: its top is the banner's bottom edge while the banner is visible
// (= #content's top edge), and the viewport top once the banner has
// scrolled away. The window keeps scrolling normally while editing.
function trackPanelTop() {
const content = document.getElementById('content')
if (host && content) {
host.style.top = `${Math.max(0, content.getBoundingClientRect().top)}px`
}
}
function startTrackingPanel() {
trackPanelTop()
addEventListener('scroll', trackPanelTop, { passive: true })
addEventListener('resize', trackPanelTop)
}
function stopTrackingPanel() {
removeEventListener('scroll', trackPanelTop)
removeEventListener('resize', trackPanelTop)
}
export function openEditor(path, { mode = 'page' } = {}) { export function openEditor(path, { mode = 'page' } = {}) {
if (app) { if (app) {
// Shell already mounted: re-show it if hidden, switch to the requested // Shell already mounted: re-show it if hidden, switch to the requested
@@ -36,9 +58,9 @@ export function openEditor(path, { mode = 'page' } = {}) {
savedTitle = document.title savedTitle = document.title
host = document.createElement('div') host = document.createElement('div')
host.className = 'editor-host' host.className = 'editor-host'
// Docked inside #content: below the banner, next to the article only.
document.getElementById('content').prepend(host) document.getElementById('content').prepend(host)
document.body.classList.add('editing') document.body.classList.add('editing')
startTrackingPanel()
// Which tab is active; pagerite.js uses this to decide whether a pen click // Which tab is active; pagerite.js uses this to decide whether a pen click
// closes the panel or switches tabs. // closes the panel or switches tabs.
document.body.dataset.editorMode = mode document.body.dataset.editorMode = mode
@@ -65,6 +87,7 @@ function showEditor() {
host.style.display = '' host.style.display = ''
host.firstElementChild?.classList.remove('closing') host.firstElementChild?.classList.remove('closing')
document.body.classList.add('editing') document.body.classList.add('editing')
startTrackingPanel()
visible = true visible = true
dispatchEvent(new CustomEvent('pagerite:editor-shown')) dispatchEvent(new CustomEvent('pagerite:editor-shown'))
} }
@@ -72,6 +95,7 @@ function showEditor() {
export function closeEditor() { export function closeEditor() {
if (!visible) return if (!visible) return
visible = false visible = false
stopTrackingPanel()
document.body.classList.remove('editing') document.body.classList.remove('editing')
// dataset.editorMode is kept while hidden: the tabs use it to tell whether // dataset.editorMode is kept while hidden: the tabs use it to tell whether
// a pagerite:editor-shown event targets them. // a pagerite:editor-shown event targets them.
+6 -4
View File
@@ -385,9 +385,11 @@ import "overlayscrollbars/overlayscrollbars.css";
// Reading time pauses after 1 minute of inactivity and resumes on the // Reading time pauses after 1 minute of inactivity and resumes on the
// next mouse/touch/scroll/keyboard event. // next mouse/touch/scroll/keyboard event.
// Excluded: back/forward (popstate never pings), everything while the // Excluded: back/forward (popstate never pings), everything while the
// editor is open (body.editing — admin noise, not visits), and the // editor is open (body.editing — admin noise, not visits), and
// analytics page itself (/_a), even though fetch-navigation treats it // navigations TO the analytics page (/_a — admin machinery, and the
// like a normal article. // server rejects it as a ping target anyway). Navigations AWAY from /_a
// must ping: load() already fetched the target page without the preload
// header, and without the ping that GET would flush to the crawler list.
// Admins (when SSO is actually in use — with no auth proxy "admin" is // Admins (when SSO is actually in use — with no auth proxy "admin" is
// everyone's state) ping normally but with hide=1: the server then // everyone's state) ping normally but with hide=1: the server then
// records nothing and scrubs any session the same browser accumulated // records nothing and scrubs any session the same browser accumulated
@@ -395,7 +397,7 @@ import "overlayscrollbars/overlayscrollbars.css";
// See docs/analytics.md. // See docs/analytics.md.
function ping(to, fr = currentPath, read = 0) { function ping(to, fr = currentPath, read = 0) {
if (document.body.classList.contains("editing")) return; if (document.body.classList.contains("editing")) return;
if ((to && to === "/_a") || fr === "/_a") return; if (to && to === "/_a") return;
const hide = ssoAvailable && isAdmin ? 1 : 0; const hide = ssoAvailable && isAdmin ? 1 : 0;
const body = JSON.stringify({ const body = JSON.stringify({
fr, to, hide, fr, to, hide,
+52 -27
View File
@@ -316,6 +316,10 @@ class Store:
pass # legacy schema / corrupt or unreadable file: start fresh pass # legacy schema / corrupt or unreadable file: start fresh
#: client hash -> index of the current visit in data.visits #: client hash -> index of the current visit in data.visits
self.sessions: dict[bytes, int] = {} self.sessions: dict[bytes, int] = {}
#: visit index -> count events recorded for that visit, so
#: ``_remove_visit`` can reverse all of them — not just the ones
#: from the visit's creation. In-memory only, like ``sessions``.
self._count_log: dict[int, list[tuple]] = {}
#: ip -> external https origin of the latest document GET carrying #: ip -> external https origin of the latest document GET carrying
#: one, stashed for the visit the client's initial ping starts. #: one, stashed for the visit the client's initial ping starts.
#: Internal or absent referers never touch the table. #: Internal or absent referers never touch the table.
@@ -403,35 +407,44 @@ class Store:
del table[key] del table[key]
def _remove_visit(self, index: int) -> None: def _remove_visit(self, index: int) -> None:
"""Delete a visit and reverse the counts its creation recorded. """Delete a visit and reverse every count it recorded.
Used when a known visitor turns out to be an admin (hide=1 ping): Used when a known visitor turns out to be an admin (hide=1 ping):
the session is scrubbed from the stats. Views/transitions logged the session is scrubbed from the stats. The in-memory
by later pings inside the visit lack per-event timestamps and are ``_count_log`` tracks each site-visit/view/transition count the
left as-is. visit produced, so the scrub reverses all of them — including the
ones logged by later pings inside the visit.
""" """
visit = self.data.visits[index] for event in self._count_log.pop(index, ()):
bucket = _bucket(visit.start) kind = event[0]
self._uncount(self.data.site_visits, bucket) if kind == "site":
views = self.data.views.get(visit.entry) self._uncount(self.data.site_visits, event[1])
if views is not None: elif kind == "view":
self._uncount(views, bucket) views = self.data.views.get(event[1])
if not views: if views is not None:
del self.data.views[visit.entry] self._uncount(views, event[2])
fr_map = self.data.transitions.get(visit.referer or "(direct)") if not views:
if fr_map is not None: del self.data.views[event[1]]
buckets = fr_map.get(visit.entry) else: # transition
if buckets is not None: _, fr, to, bucket = event
self._uncount(buckets, bucket) fr_map = self.data.transitions.get(fr)
if not buckets: if fr_map is not None:
del fr_map[visit.entry] buckets = fr_map.get(to)
if not fr_map: if buckets is not None:
del self.data.transitions[visit.referer or "(direct)"] self._uncount(buckets, bucket)
if not buckets:
del fr_map[to]
if not fr_map:
del self.data.transitions[fr]
del self.data.visits[index] del self.data.visits[index]
# Sessions store list indices; shift the ones past the removed visit. # Sessions and count logs store list indices; shift the ones past
# the removed visit.
for key, i in list(self.sessions.items()): for key, i in list(self.sessions.items()):
if i > index: if i > index:
self.sessions[key] = i - 1 self.sessions[key] = i - 1
self._count_log = {
i - 1 if i > index else i: log for i, log in self._count_log.items()
}
def _client_ip(self, client_hash: bytes) -> str: def _client_ip(self, client_hash: bytes) -> str:
"""Return the IP stored for ``client_hash``, or "" if missing.""" """Return the IP stored for ``client_hash``, or "" if missing."""
@@ -590,10 +603,18 @@ class Store:
) )
visit.statuses[entry] = status visit.statuses[entry] = status
self.data.visits.append(visit) self.data.visits.append(visit)
self.sessions[client_hash] = len(self.data.visits) - 1 index = len(self.data.visits) - 1
self._count(self.data.site_visits, _bucket(now)) self.sessions[client_hash] = index
self._count(self.data.views.setdefault(entry, {}), _bucket(now)) bucket = _bucket(now)
self._count_transition(referer or "(direct)", entry, now) fr = referer or "(direct)"
self._count(self.data.site_visits, bucket)
self._count(self.data.views.setdefault(entry, {}), bucket)
self._count_transition(fr, entry, now)
self._count_log[index] = [
("site", bucket),
("view", entry, bucket),
("transition", fr, entry, bucket),
]
return visit return visit
def track_entry( def track_entry(
@@ -763,9 +784,13 @@ class Store:
else: else:
visit = self.data.visits[index] visit = self.data.visits[index]
now = datetime.now(UTC) now = datetime.now(UTC)
bucket = _bucket(now)
log = self._count_log.setdefault(index, [])
if target.startswith("/"): if target.startswith("/"):
self._count(self.data.views.setdefault(target, {}), _bucket(now)) self._count(self.data.views.setdefault(target, {}), bucket)
log.append(("view", target, bucket))
self._count_transition(fr, target, now) self._count_transition(fr, target, now)
log.append(("transition", fr, target, bucket))
# First-seen only: repeat pages and repeated exits don't append. # First-seen only: repeat pages and repeated exits don't append.
if visit.entry != target and target not in visit.trail: if visit.entry != target and target not in visit.trail:
visit.trail.append(target) visit.trail.append(target)
+19 -4
View File
@@ -27,15 +27,30 @@
let my = 0 let my = 0
let lastMove = 0 let lastMove = 0
addEventListener('mousemove', e => { // Mouse and touch tracked with separate listeners (pointer events arrive
// too late on some mobile browsers). Passive listeners: a drag on the
// banner still scrolls the page — on browsers that stop delivering
// touchmove once scrolling takes over, the gaze just follows until then.
const track = (x, y) => {
const r = c.getBoundingClientRect() const r = c.getBoundingClientRect()
// Convert viewport coordinates into the canvas' CSS-pixel coordinate // Convert viewport coordinates into the canvas' CSS-pixel coordinate
// system. This remains correct with browser zoom, CSS transforms, etc. // system. This remains correct with browser zoom, CSS transforms, etc.
mx = (e.clientX - r.left) * c.clientWidth / r.width mx = (x - r.left) * c.clientWidth / r.width
my = (e.clientY - r.top) * c.clientHeight / r.height my = (y - r.top) * c.clientHeight / r.height
lastMove = performance.now() lastMove = performance.now()
}) }
addEventListener('mousemove', e => track(e.clientX, e.clientY))
const trackTouch = e => {
const t = e.touches[0]
if (t) track(t.clientX, t.clientY)
}
addEventListener('touchstart', trackTouch, { passive: true })
addEventListener('touchmove', trackTouch, { passive: true })
let gx = 0.5 let gx = 0.5
let gy = 0.5 let gy = 0.5