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
|
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
|
||||||
|
|||||||
@@ -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 `` 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 `` 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):
|
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.
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user