Refactor analytics to /_a instead of under article pages.

This commit is contained in:
2026-08-20 23:16:51 +00:00
parent 7f4bc8efa4
commit f78229bd30
10 changed files with 190 additions and 134 deletions
+11 -30
View File
@@ -1,12 +1,10 @@
<script setup>
// Full-screen analytics app (replaces the page chrome while open; opened via
// the 📊 pen or directly by URL hash #/analytics/<range>, so refresh and link
// sharing work). Fetches the raw collected data from /_api/analytics
// (admin-gated by the auth proxy) and renders it: totals, smoothed
// visit/views curves over a selectable range, a transition map, and the
// recent visit trails. Read-only.
// Analytics viewer rendered as a normal page inside #main. Fetches the raw
// collected data from /_api/analytics (admin-gated by the auth proxy) and
// renders totals, smoothed visit/views curves, a transition map, and recent
// visit/crawler tables. Read-only.
// See docs/analytics.md for the data format.
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { RANGES } from './analytics/time.js'
import {
calcTotalViews,
@@ -23,7 +21,6 @@ import VisitorCharts from './VisitorCharts.vue'
const props = defineProps({
initialRange: { type: String, default: 'week' },
})
const emit = defineEmits(['close'])
const data = ref(null)
const pageTree = ref(null)
@@ -45,22 +42,16 @@ onMounted(async () => {
} catch { /* map just narrows to pages seen in transitions */ }
})
function onKeydown(ev) {
if (ev.key === 'Escape') emit('close')
}
onMounted(() => addEventListener('keydown', onKeydown))
onUnmounted(() => removeEventListener('keydown', onKeydown))
const visits = computed(() => data.value?.visits || [])
const totalViews = computed(() => calcTotalViews(data.value?.views))
const range = ref(RANGES[props.initialRange] ? props.initialRange : 'week')
// Keep the URL shareable: the hash names the open view and its range.
// Keep the URL shareable when the range changes.
watch(range, (r) => {
if (location.hash.startsWith('#/analytics')) {
history.replaceState(null, '', `#/analytics/${r}`)
}
const url = new URL(location.href)
url.searchParams.set('range', r)
history.replaceState(null, '', url)
})
const visitRows = computed(() => formatVisitRows(visits.value, pageTree.value))
@@ -93,7 +84,7 @@ function countryName(code) {
{{ r.label }}
</button>
</nav>
<button type="button" class="close" title="close" @click="emit('close')"></button>
<a href="/" class="close" title="home"></a>
</header>
<p v-if="error" class="error"> {{ error }}</p>
<p v-else-if="!data" class="loading">loading</p>
@@ -104,7 +95,7 @@ function countryName(code) {
</section>
<VisitorCharts :data="data" :range="range" />
<TransitionGraph :data="data" :range="range" :page-tree="pageTree" @close="emit('close')" />
<TransitionGraph :data="data" :range="range" :page-tree="pageTree" />
<section>
<h2>Recent visits</h2>
@@ -361,13 +352,3 @@ function countryName(code) {
.empty, .loading, .error { color: var(--muted); }
.error { color: var(--error, #c00); }
</style>
<style>
/* True full screen: while the analytics app is open the page chrome is
hidden, so the document itself (not an overlay) scrolls the view. */
body.analytics-open #banner,
body.analytics-open #content,
body.analytics-open > footer {
display: none;
}
</style>
+1 -2
View File
@@ -22,7 +22,6 @@ const props = defineProps({
range: { type: String, required: true },
pageTree: { type: Array, default: null },
})
const emit = defineEmits(['close'])
const window = computed(() => rangeWindow(props.range))
@@ -116,7 +115,7 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
<text :x="x.x" :y="x.y + x.r + 11" class="txlabel">{{ x.label }}</text>
</g>
<g v-for="n in graph.nodes" :key="n.path">
<a :href="n.path" :title="n.title" @click="emit('close')">
<a :href="n.path" :title="n.title">
<circle :cx="n.x" :cy="n.y" :r="TNODE_R" class="tnode" />
<text :x="n.x" :y="n.y - 2" class="tnodeslug">{{ n.label }}</text>
<text :x="n.x" :y="n.y + 12" class="tnodecount">{{ n.views }}</text>
+24
View File
@@ -0,0 +1,24 @@
// Analytics page entry: mounts AnalyticsView inside the normal page layout.
// The backend renders #analytics-app inside #main and links this module for
// the initial load; pagerite.js also imports it on fetch-navigation to /_a.
import { createApp } from 'vue'
import AnalyticsView from './AnalyticsView.vue'
let app = null
export function mount(container) {
if (app) return
app = createApp(AnalyticsView, {
initialRange: new URLSearchParams(location.search).get('range') || 'week',
})
app.mount(container)
}
export function unmount() {
app?.unmount()
app = null
}
// Auto-mount on a normal (non-fetch) page load.
const container = document.getElementById('analytics-app')
if (container) mount(container)
-48
View File
@@ -17,7 +17,6 @@ if (import.meta.env.DEV) {
import { createApp } from 'vue'
import EditorShell from './EditorShell.vue'
import AnalyticsView from './AnalyticsView.vue'
let host = null
let app = null
@@ -98,51 +97,4 @@ export function closeEditor() {
})
}
// --- Full-screen analytics app ---------------------------------------------
// Replaces the page chrome while open (body.analytics-open hides it, see
// AnalyticsView.vue); opened from the 📊 pen or directly via the URL hash
// #/analytics/<range> so refresh and link sharing stay in analytics.
let analyticsHost = null
let analyticsApp = null
function analyticsHashRange() {
const m = location.hash.match(/^#\/analytics(?:\/(\w+))?/)
return m ? m[1] || 'week' : null
}
function onHashChange() {
if (analyticsHashRange() === null) closeAnalytics()
else openAnalytics()
}
export function openAnalytics() {
if (analyticsHost) return
let r = analyticsHashRange()
if (r === null) {
r = 'week'
// Pushed (not replaced) so the back button exits the app via hashchange.
history.pushState(null, '', `#/analytics/${r}`)
}
analyticsHost = document.createElement('div')
document.body.append(analyticsHost)
document.body.classList.add('analytics-open')
analyticsApp = createApp(AnalyticsView, {
initialRange: r,
onClose: closeAnalytics,
})
analyticsApp.mount(analyticsHost)
addEventListener('hashchange', onHashChange)
}
export function closeAnalytics() {
if (!analyticsHost) return
removeEventListener('hashchange', onHashChange)
analyticsApp?.unmount()
analyticsApp = null
analyticsHost?.remove()
analyticsHost = null
document.body.classList.remove('analytics-open')
if (analyticsHashRange() !== null) {
history.replaceState(null, '', location.pathname + location.search)
}
}
+54 -39
View File
@@ -97,30 +97,30 @@ import "overlayscrollbars/overlayscrollbars.css";
// Editing is open for admins and, as a dev/no-proxy fallback, when no
// Paskia SSO is detected at all.
const canEdit = isAdmin || !ssoAvailable;
// The analytics page is a read-only dashboard: editing pens and the side
// panel do not apply there. Login/logout links are still useful.
const onAnalytics = currentPath === "/_a";
const banner = document.getElementById("page-banner");
if (banner) {
const old = banner.parentElement.querySelector(".editor-pens");
if (old) old.remove();
const pens = document.createElement("div");
pens.className = "editor-pens";
if (canEdit) {
if (canEdit && !onAnalytics) {
pens.append(makePen("banner"));
pens.append(makePen("site"));
if (editorMeta) {
// Full-screen analytics view (separate from the docked panel).
const btn = document.createElement("button");
btn.type = "button";
btn.className = "edit-link analytics-link";
btn.title = "analytics";
btn.textContent = "📊";
btn.dataset.editorSrc = editorMeta.src;
pens.append(btn);
}
// Analytics viewer is now a normal page at /_a.
const a = document.createElement("a");
a.className = "edit-link analytics-link";
a.href = "/_a";
a.title = "analytics";
a.textContent = "📊";
pens.append(a);
}
if (ssoAvailable) pens.append(makeAuthLink(isAdmin));
banner.after(pens);
}
if (canEdit) injectPagePen();
if (canEdit && !onAnalytics) injectPagePen();
}
async function setupAuth() {
@@ -149,18 +149,6 @@ import "overlayscrollbars/overlayscrollbars.css";
renderAuthUi();
pingEntryOnce();
// The analytics app is addressable by URL (#/analytics/<range>), so a
// refresh or a shared link lands back in it. Only for editors.
const openAnalyticsFromHash = () => {
if (!location.hash.startsWith("#/analytics")) return;
if (!(isAdmin || !ssoAvailable) || !editorMeta) return;
import(/* @vite-ignore */ editorMeta.src)
.then((m) => m.openAnalytics())
.catch((e) => console.error("analytics view load failed:", e));
};
openAnalyticsFromHash();
addEventListener("hashchange", openAnalyticsFromHash);
}
// Returning to the page via history back/forward may restore a cached
@@ -356,11 +344,13 @@ import "overlayscrollbars/overlayscrollbars.css";
// back/forward (popstate never pings) and everything while we know the
// user is an admin — but only when SSO is actually in use; with no auth
// (dev/test) "admin" is everyone's state and nothing would be recorded —
// or has the editor/analytics view open (admin noise, not visits).
// or has the editor open (admin noise, not visits). The analytics page
// itself (/_a) is also excluded even though fetch-navigation treats it like
// a normal article.
// See docs/analytics.md.
function ping(to, fr = currentPath) {
if ((ssoAvailable && isAdmin) || document.body.classList.contains("editing")
|| document.body.classList.contains("analytics-open")) return;
|| to === "/_a" || fr === "/_a") return;
try {
fetch("/_a", {
method: "POST",
@@ -385,6 +375,36 @@ import "overlayscrollbars/overlayscrollbars.css";
ping(currentPath);
}
// --- Analytics page mount/unmount --------------------------------------
// The analytics page is a normal page whose body is rendered by the server
// but whose content is a Vue app. We load the entry module on demand so the
// analytics bundle is only fetched when visiting /_a, and unmount the app
// before swapping away so Vue teardown runs cleanly.
let analyticsUnmount = null;
function teardownAnalytics() {
analyticsUnmount?.();
analyticsUnmount = null;
}
async function mountAnalytics(doc) {
const src = doc.querySelector('meta[name="pagerite:analytics-src"]')?.content;
if (!src) {
teardownAnalytics();
return;
}
try {
const mod = await import(/* @vite-ignore */ src);
const container = document.getElementById("analytics-app");
if (container) {
mod.mount(container);
analyticsUnmount = mod.unmount;
}
} catch (e) {
console.error("analytics mount failed:", e);
}
}
// --- Fetch navigation ------------------------------------------------
async function load(url, push = true, back = false) {
// Navigating with the editor open closes it; unsaved edits are lost
@@ -392,6 +412,7 @@ import "overlayscrollbars/overlayscrollbars.css";
if (document.body.classList.contains("editing")) {
editorModule?.then((m) => m.closeEditor());
}
teardownAnalytics();
let doc;
let finalUrl = url;
const cached = pageCache.get(new URL(url, location.href).pathname);
@@ -450,6 +471,7 @@ import "overlayscrollbars/overlayscrollbars.css";
runScripts(document.getElementById("page-banner"));
runScripts(document.getElementById("main"));
applyEffects();
mountAnalytics(document);
};
// Rotating cube page transition (see the FRAGILE block in pagerite.css);
// mirrored when navigating back through history. Navigation within the
@@ -479,16 +501,6 @@ import "overlayscrollbars/overlayscrollbars.css";
// the Vue app on demand (with any extra styles) and mount it in place.
// Clicking the pen of the already-open tab closes the shell; clicking
// another pen switches the shell to that tab.
// The 📊 pen opens the full-screen analytics view (its own Vue app,
// not a tab of the docked editor shell).
const analyticsBtn = ev.target.closest("button.analytics-link");
if (analyticsBtn && analyticsBtn.dataset.editorSrc) {
ev.preventDefault();
import(/* @vite-ignore */ analyticsBtn.dataset.editorSrc)
.then((m) => m.openAnalytics())
.catch((e) => console.error("analytics view load failed:", e));
return;
}
const editBtn = ev.target.closest("button.edit-link");
if (editBtn && editBtn.dataset.editorSrc) {
ev.preventDefault();
@@ -525,8 +537,10 @@ import "overlayscrollbars/overlayscrollbars.css";
}
// Same-page anchor links (footnotes etc.): let the browser handle them
if (url.pathname === location.pathname && url.hash) return;
// Machinery and auth endpoints are never fetch-navigated.
if (url.pathname.startsWith("/_") || url.pathname.startsWith("/auth")) return;
// Machinery and auth endpoints are never fetch-navigated, except the
// public analytics viewer page at /_a.
if ((url.pathname.startsWith("/_") && url.pathname !== "/_a")
|| url.pathname.startsWith("/auth")) return;
ev.preventDefault();
// Capture the source now: load() updates currentPath before pinging.
const from = currentPath;
@@ -534,7 +548,7 @@ import "overlayscrollbars/overlayscrollbars.css";
});
addEventListener("popstate", () => {
// Hash-only history entries (the analytics app) are not navigations.
// Hash-only history entries are not navigations.
if (location.pathname === currentPath) return;
load(location.href, false, true);
});
@@ -603,4 +617,5 @@ import "overlayscrollbars/overlayscrollbars.css";
setupAuth();
applyEffects();
mountAnalytics(document);
})();
+1
View File
@@ -40,6 +40,7 @@ export default defineConfig({
input: {
main: fileURLToPath(new URL('./src/main.js', import.meta.url)),
pagerite: fileURLToPath(new URL('./src/pagerite.js', import.meta.url)),
analytics: fileURLToPath(new URL('./src/analytics-main.js', import.meta.url)),
// Only the base CSS is built; theme/banner-design stylesheets live
// in pagerite/themes/{name}/ and are served by the backend as-is.
pagerite_base: fileURLToPath(new URL('./src/assets/pagerite.css', import.meta.url)),