Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
158b5f1961 | ||
|
|
f9ce0a4f3b | ||
|
|
2d595f8c15 | ||
|
|
b1fc8e24d7 | ||
|
|
c5cf799f68 | ||
|
|
a5edc7b3b6 |
+10
-7
@@ -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
|
||||
links are stored by full URL so several links to the same domain remain
|
||||
distinct.
|
||||
- **Excluded**: back/forward (popstate) navigations, navigation involving
|
||||
the analytics page itself (`/_a`), and everything while the user has the
|
||||
editor open (`body.editing`). Admin noise, not visits.
|
||||
- **Excluded**: back/forward (popstate) navigations, navigating *to* the
|
||||
analytics page (`/_a` — its GET is untracked, and the server rejects it
|
||||
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,
|
||||
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
|
||||
logging in, that visit is removed from the JSON along with the counts
|
||||
recorded when it was created (site visit, entry view, entry transition).
|
||||
Views/transitions logged by later pings inside such a visit lack
|
||||
per-event timestamps and are left as-is. With no auth proxy (dev/test)
|
||||
logging in, that visit is removed from the JSON along with every count
|
||||
it recorded — an in-memory per-visit log of count events makes full
|
||||
reversal possible. With no auth proxy (dev/test)
|
||||
"admin" is everyone's state, so `hide` stays 0 and everything is recorded.
|
||||
- The server validates `to`: internal paths must be valid slug paths
|
||||
("/" or `[a-z0-9_-]` segments), external ones are re-derived to the
|
||||
|
||||
@@ -45,7 +45,7 @@ Pagerite is a single-user CMS/blog. This document records the initial high-level
|
||||
## Editing
|
||||
|
||||
- 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.
|
||||
- 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 `` at the cursor.
|
||||
|
||||
+1
-1
@@ -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):
|
||||
|
||||
- `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`.
|
||||
- `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.
|
||||
|
||||
@@ -233,7 +233,6 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-else class="empty">no crawler hits recorded yet</p>
|
||||
|
||||
<div v-if="abuseRows.length" class="visit-table-wrap">
|
||||
<table class="visit-table">
|
||||
|
||||
+36
-12
@@ -4,9 +4,9 @@
|
||||
// (/_api/ws/editor). Docked left of the article on the page itself.
|
||||
// The socket connects when the editor is opened and reconnects with
|
||||
// exponential backoff after a failure; unsaved text and pending saves
|
||||
// survive a disconnect. Editor scroll drives the article scroll (while
|
||||
// editing the window scroll is locked and only #main scrolls), keeping the
|
||||
// rendered article at the cursor's position. Saving (💾 / Ctrl+S) is explicit
|
||||
// survive a disconnect. Editor and article (window) scrolls are linked
|
||||
// proportionally both ways (syncWindowToEditor / syncEditorToWindow).
|
||||
// Saving (💾 / Ctrl+S) is explicit
|
||||
// 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
|
||||
// only on a real page reload.
|
||||
@@ -307,19 +307,37 @@ function onEditorShown() {
|
||||
if (dirty.value) requestRender()
|
||||
}
|
||||
|
||||
function syncScroll() {
|
||||
// Editor scroll drives the article: keep the rendered page at the same
|
||||
// proportional position as the cursor area in the editor. While editing
|
||||
// the window scroll is locked and #main is the scrolling element.
|
||||
// Bidirectional proportional scroll sync between the CodeMirror scroller
|
||||
// and the window (the article's scroller, also while editing). Both
|
||||
// directions apply instantly (never smooth — a smooth window scroll feeds
|
||||
// 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
|
||||
const main = document.getElementById('main')
|
||||
if (!main) return
|
||||
syncingScroll = true
|
||||
requestAnimationFrame(() => {
|
||||
const scroller = view.scrollDOM
|
||||
const max = scroller.scrollHeight - scroller.clientHeight
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -379,7 +397,11 @@ onMounted(() => {
|
||||
}),
|
||||
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.
|
||||
view.focus()
|
||||
window.__pageritePageEditor = {
|
||||
@@ -399,6 +421,8 @@ onUnmounted(() => {
|
||||
}
|
||||
view?.destroy()
|
||||
delete window.__pageritePageEditor
|
||||
removeEventListener('scroll', syncEditorToWindow)
|
||||
removeEventListener('resize', syncEditorToWindow)
|
||||
removeEventListener('keydown', onKeydown)
|
||||
removeEventListener('pagerite:editor-shown', onEditorShown)
|
||||
})
|
||||
@@ -606,7 +630,7 @@ onUnmounted(() => {
|
||||
|
||||
/* 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
|
||||
drives the article (syncScroll) instead of double-scrolling. */
|
||||
drives the article (syncWindowToEditor) instead of double-scrolling. */
|
||||
.editor {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -68,8 +68,8 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
|
||||
|
||||
<template>
|
||||
<section v-for="c in [
|
||||
{ ylabel: 'visits', chart: visitChart, legend: true, empty: 'no visits recorded yet' },
|
||||
{ ylabel: 'views', chart: viewChart, legend: false, empty: 'no views recorded yet' },
|
||||
{ ylabel: 'visits', chart: visitChart, legend: true },
|
||||
{ ylabel: 'views', chart: viewChart, legend: false },
|
||||
]" :key="c.ylabel">
|
||||
<template v-if="c.chart">
|
||||
<svg class="chart" :viewBox="`${-MARGIN_L} 0 ${VIEW_W} ${VIEW_H}`"
|
||||
@@ -116,7 +116,6 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
<p v-else class="empty">{{ c.empty }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -63,9 +63,24 @@ export function sumRange(raw, t0, t1) {
|
||||
export function weeklySeries(buckets) {
|
||||
const raw = rawTimes(buckets)
|
||||
const times = Object.keys(raw).map(Number)
|
||||
if (!times.length) return null
|
||||
const now = Date.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)
|
||||
// Weeks back as far as the data reaches: difference in Monday indices.
|
||||
const available = (thisMonday - mondayUTC(oldest)) / WEEK + 1
|
||||
@@ -113,13 +128,27 @@ export function weeklySeries(buckets) {
|
||||
export function rollingSeries(buckets, rangeKey) {
|
||||
const raw = rawTimes(buckets)
|
||||
const times = Object.keys(raw).map(Number)
|
||||
if (!times.length) return null
|
||||
const { span, bucket, minSpan = 0 } = RANGES[rangeKey]
|
||||
const t1 = Date.now()
|
||||
const earliest = Math.min(...times)
|
||||
const t0 = Math.floor((span != null
|
||||
? 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
|
||||
// "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
|
||||
|
||||
@@ -192,6 +192,10 @@ body {
|
||||
/* One line always: pagerite.js shrinks the font size to fit instead of
|
||||
wrapping (the themed size is the maximum). */
|
||||
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;
|
||||
padding-top: 1.5rem;
|
||||
color: var(--text);
|
||||
@@ -265,10 +269,9 @@ body:has(.multicol) #content {
|
||||
|
||||
body.editing #content {
|
||||
margin-left: var(--editor-w);
|
||||
padding-left: 1rem;
|
||||
/* gap between the docked editor and the content */
|
||||
/* No overflow clipping here: .editor-host lives outside this box
|
||||
(negative left), and .wide shrink-wraps to the remaining space. */
|
||||
/* No padding/gap here: the article area starts flush at the panel's right
|
||||
edge so that full-bleed .wide images (anchored to that edge below) line
|
||||
up with it exactly. */
|
||||
}
|
||||
|
||||
/* The sidebar's gutter space is needed by the editor instead. */
|
||||
@@ -276,33 +279,17 @@ body.editing #sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* While editing, the window itself does not scroll: the editor panel is
|
||||
exactly the remaining window height, and only the article area (#main)
|
||||
scrolls. Without the editor, normal full-page scroll applies. */
|
||||
body.editing {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
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. */
|
||||
/* While editing, the window keeps scrolling normally (the overlay
|
||||
scrollbars take no layout space, so the vw-based .wide bleed stays exact
|
||||
and no horizontal scrollbar appears). The editor panel is fixed to the
|
||||
viewport's left edge; main.js sets its top each scroll frame — the
|
||||
banner's bottom edge while the banner is visible, else the viewport top.
|
||||
The panel scrolls internally. */
|
||||
.editor-host {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
position: fixed;
|
||||
top: 0; /* main.js: banner bottom while visible, else 0 */
|
||||
bottom: 0;
|
||||
left: calc(0px - var(--editor-w));
|
||||
left: 0;
|
||||
width: var(--editor-w);
|
||||
}
|
||||
|
||||
@@ -822,18 +809,18 @@ body:has(.multicol) figure:has(.wide) {
|
||||
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) {
|
||||
width: calc(100vw - var(--editor-w));
|
||||
margin-inline: calc(50% - (100vw - var(--editor-w)) / 2);
|
||||
}
|
||||
|
||||
/* 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
|
||||
main's 1.25rem left padding (the gutter shrink from the padding roughly
|
||||
cancels the rounding): −2rem in total. */
|
||||
editor, and the bleed also crosses main's 1.25rem left padding. */
|
||||
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
|
||||
@@ -907,6 +894,30 @@ article h2 {
|
||||
(explicit img widths still shrink-wrap), while .wide keeps its full
|
||||
viewport bleed. */
|
||||
@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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -918,7 +929,11 @@ article h2 {
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
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
|
||||
@@ -926,7 +941,7 @@ article h2 {
|
||||
#sidebar > ul {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.2rem;
|
||||
gap: 0.3rem 0.75rem;
|
||||
}
|
||||
|
||||
figure:has(.right),
|
||||
|
||||
+25
-1
@@ -23,6 +23,28 @@ let app = null
|
||||
let savedTitle = null
|
||||
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' } = {}) {
|
||||
if (app) {
|
||||
// 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
|
||||
host = document.createElement('div')
|
||||
host.className = 'editor-host'
|
||||
// Docked inside #content: below the banner, next to the article only.
|
||||
document.getElementById('content').prepend(host)
|
||||
document.body.classList.add('editing')
|
||||
startTrackingPanel()
|
||||
// Which tab is active; pagerite.js uses this to decide whether a pen click
|
||||
// closes the panel or switches tabs.
|
||||
document.body.dataset.editorMode = mode
|
||||
@@ -65,6 +87,7 @@ function showEditor() {
|
||||
host.style.display = ''
|
||||
host.firstElementChild?.classList.remove('closing')
|
||||
document.body.classList.add('editing')
|
||||
startTrackingPanel()
|
||||
visible = true
|
||||
dispatchEvent(new CustomEvent('pagerite:editor-shown'))
|
||||
}
|
||||
@@ -72,6 +95,7 @@ function showEditor() {
|
||||
export function closeEditor() {
|
||||
if (!visible) return
|
||||
visible = false
|
||||
stopTrackingPanel()
|
||||
document.body.classList.remove('editing')
|
||||
// dataset.editorMode is kept while hidden: the tabs use it to tell whether
|
||||
// a pagerite:editor-shown event targets them.
|
||||
|
||||
@@ -385,9 +385,11 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
// Reading time pauses after 1 minute of inactivity and resumes on the
|
||||
// next mouse/touch/scroll/keyboard event.
|
||||
// Excluded: back/forward (popstate never pings), everything while the
|
||||
// editor is open (body.editing — admin noise, not visits), and the
|
||||
// analytics page itself (/_a), even though fetch-navigation treats it
|
||||
// like a normal article.
|
||||
// editor is open (body.editing — admin noise, not visits), and
|
||||
// navigations TO the analytics page (/_a — admin machinery, and the
|
||||
// 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
|
||||
// everyone's state) ping normally but with hide=1: the server then
|
||||
// records nothing and scrubs any session the same browser accumulated
|
||||
@@ -395,7 +397,7 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
// See docs/analytics.md.
|
||||
function ping(to, fr = currentPath, read = 0) {
|
||||
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 body = JSON.stringify({
|
||||
fr, to, hide,
|
||||
|
||||
+52
-27
@@ -316,6 +316,10 @@ class Store:
|
||||
pass # legacy schema / corrupt or unreadable file: start fresh
|
||||
#: client hash -> index of the current visit in data.visits
|
||||
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
|
||||
#: one, stashed for the visit the client's initial ping starts.
|
||||
#: Internal or absent referers never touch the table.
|
||||
@@ -403,35 +407,44 @@ class Store:
|
||||
del table[key]
|
||||
|
||||
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):
|
||||
the session is scrubbed from the stats. Views/transitions logged
|
||||
by later pings inside the visit lack per-event timestamps and are
|
||||
left as-is.
|
||||
the session is scrubbed from the stats. The in-memory
|
||||
``_count_log`` tracks each site-visit/view/transition count the
|
||||
visit produced, so the scrub reverses all of them — including the
|
||||
ones logged by later pings inside the visit.
|
||||
"""
|
||||
visit = self.data.visits[index]
|
||||
bucket = _bucket(visit.start)
|
||||
self._uncount(self.data.site_visits, bucket)
|
||||
views = self.data.views.get(visit.entry)
|
||||
if views is not None:
|
||||
self._uncount(views, bucket)
|
||||
if not views:
|
||||
del self.data.views[visit.entry]
|
||||
fr_map = self.data.transitions.get(visit.referer or "(direct)")
|
||||
if fr_map is not None:
|
||||
buckets = fr_map.get(visit.entry)
|
||||
if buckets is not None:
|
||||
self._uncount(buckets, bucket)
|
||||
if not buckets:
|
||||
del fr_map[visit.entry]
|
||||
if not fr_map:
|
||||
del self.data.transitions[visit.referer or "(direct)"]
|
||||
for event in self._count_log.pop(index, ()):
|
||||
kind = event[0]
|
||||
if kind == "site":
|
||||
self._uncount(self.data.site_visits, event[1])
|
||||
elif kind == "view":
|
||||
views = self.data.views.get(event[1])
|
||||
if views is not None:
|
||||
self._uncount(views, event[2])
|
||||
if not views:
|
||||
del self.data.views[event[1]]
|
||||
else: # transition
|
||||
_, fr, to, bucket = event
|
||||
fr_map = self.data.transitions.get(fr)
|
||||
if fr_map is not None:
|
||||
buckets = fr_map.get(to)
|
||||
if buckets is not None:
|
||||
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]
|
||||
# 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()):
|
||||
if i > index:
|
||||
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:
|
||||
"""Return the IP stored for ``client_hash``, or "" if missing."""
|
||||
@@ -590,10 +603,18 @@ class Store:
|
||||
)
|
||||
visit.statuses[entry] = status
|
||||
self.data.visits.append(visit)
|
||||
self.sessions[client_hash] = len(self.data.visits) - 1
|
||||
self._count(self.data.site_visits, _bucket(now))
|
||||
self._count(self.data.views.setdefault(entry, {}), _bucket(now))
|
||||
self._count_transition(referer or "(direct)", entry, now)
|
||||
index = len(self.data.visits) - 1
|
||||
self.sessions[client_hash] = index
|
||||
bucket = _bucket(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
|
||||
|
||||
def track_entry(
|
||||
@@ -763,9 +784,13 @@ class Store:
|
||||
else:
|
||||
visit = self.data.visits[index]
|
||||
now = datetime.now(UTC)
|
||||
bucket = _bucket(now)
|
||||
log = self._count_log.setdefault(index, [])
|
||||
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)
|
||||
log.append(("transition", fr, target, bucket))
|
||||
# First-seen only: repeat pages and repeated exits don't append.
|
||||
if visit.entry != target and target not in visit.trail:
|
||||
visit.trail.append(target)
|
||||
|
||||
@@ -27,15 +27,30 @@
|
||||
let my = 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()
|
||||
|
||||
// Convert viewport coordinates into the canvas' CSS-pixel coordinate
|
||||
// system. This remains correct with browser zoom, CSS transforms, etc.
|
||||
mx = (e.clientX - r.left) * c.clientWidth / r.width
|
||||
my = (e.clientY - r.top) * c.clientHeight / r.height
|
||||
mx = (x - r.left) * c.clientWidth / r.width
|
||||
my = (y - r.top) * c.clientHeight / r.height
|
||||
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 gy = 0.5
|
||||
|
||||
Reference in New Issue
Block a user