Page caching and zstd compression. Avoid useless fetching. Mobile layouts of navigation menus improved.
This commit is contained in:
@@ -21,10 +21,6 @@ import VisitorCell from './VisitorCell.vue'
|
||||
import TransitionGraph from './TransitionGraph.vue'
|
||||
import VisitorCharts from './VisitorCharts.vue'
|
||||
|
||||
const props = defineProps({
|
||||
initialRange: { type: String, default: 'week' },
|
||||
})
|
||||
|
||||
const ABUSE_MAX_LINES = 5
|
||||
|
||||
const data = ref(null)
|
||||
@@ -35,6 +31,13 @@ let ws = null
|
||||
let reconnectTimeout = null
|
||||
let timeInterval = null
|
||||
|
||||
// The initial range comes from the URL hash (shareable links); without one,
|
||||
// it is derived from the first analytics snapshot: day when the recorded
|
||||
// history is shorter than 24 h, week otherwise.
|
||||
const hashRange = location.hash.slice(1)
|
||||
const range = ref(RANGES[hashRange] ? hashRange : 'week')
|
||||
let rangePinned = Boolean(RANGES[hashRange])
|
||||
|
||||
function connectAnalytics() {
|
||||
if (ws) return
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
@@ -43,6 +46,15 @@ function connectAnalytics() {
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
data.value = JSON.parse(event.data)
|
||||
if (!rangePinned) {
|
||||
rangePinned = true
|
||||
const starts = (data.value?.visits || [])
|
||||
.map((v) => Date.parse(v.start))
|
||||
.filter((t) => !Number.isNaN(t))
|
||||
if (starts.length && Date.now() - Math.min(...starts) < 24 * 3600 * 1000) {
|
||||
range.value = 'day'
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
error.value = 'analytics data could not be loaded'
|
||||
}
|
||||
@@ -82,8 +94,6 @@ const visits = computed(() => data.value?.visits || [])
|
||||
const totalViews = computed(() => calcTotalViews(data.value?.views))
|
||||
const readStats = computed(() => calcReadStats(visits.value))
|
||||
|
||||
const range = ref(RANGES[props.initialRange] ? props.initialRange : 'week')
|
||||
|
||||
// Keep the URL shareable when the range changes.
|
||||
watch(range, (r) => {
|
||||
const url = new URL(location.href)
|
||||
|
||||
+29
-16
@@ -242,30 +242,43 @@ async function saveSettings(opts = {}) {
|
||||
async function onThemeChange() {
|
||||
await saveSettings()
|
||||
// Theme CSS is backend-served at /_themes/{theme}/theme.css in both dev
|
||||
// and prod: swap the link in place, then re-render (the theme's default
|
||||
// banner design and the page's stylesheet links may change with it).
|
||||
let link = document.getElementById('pagerite-theme')
|
||||
// and prod, but rendered differently: a <link> in dev, an inline <style>
|
||||
// in prod. Swap it in place, then re-render (the theme's default banner
|
||||
// design and the page's stylesheets may change with it).
|
||||
let el = document.getElementById('pagerite-theme')
|
||||
const url = `/_themes/${theme.value}/theme.css`
|
||||
if (theme.value) {
|
||||
const href = `/_themes/${theme.value}/theme.css`
|
||||
if (link) {
|
||||
link.href = href
|
||||
} else {
|
||||
if (el?.tagName === 'STYLE') {
|
||||
el.textContent = await (await fetch(url)).text()
|
||||
} else if (el) {
|
||||
el.href = url
|
||||
} else if (import.meta.env.DEV) {
|
||||
// Re-create after "none": keep base < theme < design < custom CSS.
|
||||
// In dev there is no #pagerite-base link (the base is a
|
||||
// In dev there is no #pagerite-base element (the base is a
|
||||
// Vite-injected <style>), so anchor to the next sheet instead of
|
||||
// prepending before the base styles.
|
||||
link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.id = 'pagerite-theme'
|
||||
link.href = href
|
||||
el = document.createElement('link')
|
||||
el.rel = 'stylesheet'
|
||||
el.id = 'pagerite-theme'
|
||||
el.href = url
|
||||
const before = document.getElementById('pagerite-base')?.nextSibling
|
||||
?? document.getElementById('pagerite-banner')
|
||||
?? document.getElementById('pagerite-user')
|
||||
if (before) before.before(link)
|
||||
else document.head.append(link)
|
||||
if (before) before.before(el)
|
||||
else document.head.append(el)
|
||||
} else {
|
||||
// Prod: inline <style>, fetched from the backend-served URL.
|
||||
el = document.createElement('style')
|
||||
el.id = 'pagerite-theme'
|
||||
el.textContent = await (await fetch(url)).text()
|
||||
const before = document.getElementById('pagerite-base')?.nextSibling
|
||||
?? document.getElementById('pagerite-banner')
|
||||
?? document.getElementById('pagerite-user')
|
||||
if (before) before.before(el)
|
||||
else document.head.append(el)
|
||||
}
|
||||
} else if (link) {
|
||||
link.remove()
|
||||
} else if (el) {
|
||||
el.remove()
|
||||
}
|
||||
loadPlain(path.value)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// 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.
|
||||
// In production the backend inlines this module into the /_a page (and
|
||||
// pagerite.js re-creates the script element after fetch-navigations there);
|
||||
// in dev pagerite.js imports it from the Vite dev server on demand. Either
|
||||
// way it auto-mounts on #analytics-app when it evaluates, and unmounts when
|
||||
// pagerite.js announces a swap away from /_a.
|
||||
import { createApp } from 'vue'
|
||||
import AnalyticsView from './AnalyticsView.vue'
|
||||
|
||||
@@ -8,11 +11,7 @@ let app = null
|
||||
|
||||
export function mount(container) {
|
||||
if (app) return
|
||||
app = createApp(AnalyticsView, {
|
||||
initialRange: location.hash.slice(1)
|
||||
|| container.dataset.initialRange
|
||||
|| 'week',
|
||||
})
|
||||
app = createApp(AnalyticsView)
|
||||
app.mount(container)
|
||||
}
|
||||
|
||||
@@ -21,6 +20,11 @@ export function unmount() {
|
||||
app = null
|
||||
}
|
||||
|
||||
// Auto-mount on a normal (non-fetch) page load.
|
||||
// pagerite.js calls this before swapping away from /_a; each evaluation
|
||||
// (the inlined production module evaluates fresh on every visit) replaces
|
||||
// the handle.
|
||||
window.__pageriteAnalyticsUnmount = unmount
|
||||
|
||||
// Auto-mount when the page holding #analytics-app is present.
|
||||
const container = document.getElementById('analytics-app')
|
||||
if (container) mount(container)
|
||||
|
||||
@@ -912,7 +912,9 @@ article h2 {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
#sidebar ul {
|
||||
/* Only the main level becomes a horizontal wrapping strip; submenus stay
|
||||
vertical blocks attached under their parent item. */
|
||||
#sidebar > ul {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.2rem;
|
||||
|
||||
+141
-36
@@ -55,6 +55,19 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
let isAdmin = false;
|
||||
let editorMeta = null;
|
||||
|
||||
// Asset URLs for the on-demand bundles. Dev renders them as
|
||||
// pagerite:* meta tags (Vite dev-server URLs); production inlines all
|
||||
// page assets and carries the on-demand URLs in a JSON script instead.
|
||||
const assets = (() => {
|
||||
const el = document.getElementById("pagerite-assets");
|
||||
if (el) return JSON.parse(el.textContent);
|
||||
const map = {};
|
||||
for (const m of document.querySelectorAll('meta[name^="pagerite:"]')) {
|
||||
map[m.name] = m.content;
|
||||
}
|
||||
return map;
|
||||
})();
|
||||
|
||||
function makePen(mode) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
@@ -124,11 +137,11 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
}
|
||||
|
||||
async function setupAuth() {
|
||||
const src = document.querySelector('meta[name="pagerite:editor-src"]')?.content;
|
||||
const src = assets["pagerite:editor-src"];
|
||||
if (!src) { pingEntryOnce(); return; }
|
||||
editorMeta = {
|
||||
src,
|
||||
css: document.querySelector('meta[name="pagerite:editor-css"]')?.content,
|
||||
css: assets["pagerite:editor-css"],
|
||||
};
|
||||
|
||||
// Detect whether Paskia SSO is available on this site.
|
||||
@@ -147,6 +160,26 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
// No auth proxy / dev.
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
// Teach the backend the site's public origin (used for absolute
|
||||
// social/canonical URLs): unlike request headers, location.origin
|
||||
// reflects the real scheme and host even behind reverse proxies.
|
||||
fetch("/_api/site-url", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ url: location.origin }),
|
||||
}).catch(() => {});
|
||||
// Warm the cache with the editor bundle: the hashed asset is
|
||||
// immutable, so preloading costs nothing and the pens then open
|
||||
// instantly. The analytics page has no editor.
|
||||
if (currentPath !== "/_a" && !import.meta.env.DEV) {
|
||||
const preload = document.createElement("link");
|
||||
preload.rel = "modulepreload";
|
||||
preload.href = src;
|
||||
document.head.append(preload);
|
||||
}
|
||||
}
|
||||
|
||||
renderAuthUi();
|
||||
pingEntryOnce();
|
||||
}
|
||||
@@ -230,6 +263,7 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
// buttons; re-add whichever auth UI is appropriate for this session.
|
||||
renderAuthUi();
|
||||
placeEditPen();
|
||||
fitNav();
|
||||
// Multi-column layout only when there is enough text to justify it.
|
||||
// Split the body into columned segments: h1s, h2s and wide figures are
|
||||
// full-width separators and never go inside columns.
|
||||
@@ -285,14 +319,17 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
// internal link is fetched exactly once, and navigation is served from
|
||||
// memory with no fetch at all. Editor re-renders (swapdoc.loadPlain)
|
||||
// announce their fresh copies via pagerite:page-fetched, keeping the
|
||||
// cache in sync after edits.
|
||||
// cache in sync after edits. The current page is NOT preloaded: we just
|
||||
// received it as the document (re-fetching would be redundant, and
|
||||
// browser heuristics may send it without if-none-match, defeating the
|
||||
// conditional request); it enters the cache when navigated to.
|
||||
const pageCache = new Map(); // pathname -> HTML text
|
||||
addEventListener("pagerite:page-fetched", (ev) => {
|
||||
pageCache.set(new URL(ev.detail.url, location.href).pathname, ev.detail.html);
|
||||
});
|
||||
|
||||
function preload() {
|
||||
const urls = new Set([location.pathname]);
|
||||
const urls = new Set();
|
||||
for (const a of document.querySelectorAll(
|
||||
'#nav a[href^="/"], #sidebar a[href^="/"], #main a[href^="/"]',
|
||||
)) {
|
||||
@@ -455,29 +492,39 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
|
||||
// --- 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;
|
||||
|
||||
// but whose content is a Vue app. In dev the entry module is imported from
|
||||
// the Vite dev server on demand; in production it is inlined into the /_a
|
||||
// page as script#pagerite-js-analytics, which a fetch-navigation swap does
|
||||
// not execute — re-create the element so the fresh module auto-mounts on
|
||||
// #analytics-app (see analytics-main.js). The module exposes its unmount
|
||||
// as window.__pageriteAnalyticsUnmount.
|
||||
function teardownAnalytics() {
|
||||
analyticsUnmount?.();
|
||||
analyticsUnmount = null;
|
||||
// Remove even the server-rendered script element so a later return to
|
||||
// /_a re-mounts from a fresh copy (the module has torn itself down).
|
||||
document.getElementById("pagerite-js-analytics")?.remove();
|
||||
window.__pageriteAnalyticsUnmount?.();
|
||||
window.__pageriteAnalyticsUnmount = null;
|
||||
}
|
||||
|
||||
async function mountAnalytics(doc) {
|
||||
const src = doc.querySelector('meta[name="pagerite:analytics-src"]')?.content;
|
||||
if (!src) {
|
||||
teardownAnalytics();
|
||||
if (!doc.getElementById("analytics-app")) return;
|
||||
// Already mounted: on a full /_a load the inline script has run.
|
||||
if (document.getElementById("pagerite-js-analytics")) return;
|
||||
const inline = doc.getElementById("pagerite-js-analytics");
|
||||
if (inline) {
|
||||
const s = document.createElement("script");
|
||||
for (const a of inline.attributes) s.setAttribute(a.name, a.value);
|
||||
s.textContent = inline.textContent;
|
||||
document.body.append(s);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const mod = await import(/* @vite-ignore */ src);
|
||||
// Dev: the cached module auto-mounts only on its first evaluation,
|
||||
// so call mount() explicitly for repeat visits (it no-ops when the
|
||||
// app is already up).
|
||||
const mod = await import(/* @vite-ignore */ assets["pagerite:analytics-src"]);
|
||||
const container = document.getElementById("analytics-app");
|
||||
if (container) {
|
||||
mod.mount(container);
|
||||
analyticsUnmount = mod.unmount;
|
||||
}
|
||||
if (container) mod.mount(container);
|
||||
} catch (e) {
|
||||
console.error("analytics mount failed:", e);
|
||||
}
|
||||
@@ -504,8 +551,8 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
// Reflect any redirect the server issued.
|
||||
if (res.redirected) finalUrl = res.url;
|
||||
const html = await res.text();
|
||||
// Populate the cache too, or the post-swap preload (which includes
|
||||
// location.pathname) would fetch the very page we just loaded again.
|
||||
// Populate the cache too, so returning here (back/forward, or a
|
||||
// self-link in the nav) is served from memory.
|
||||
pageCache.set(new URL(finalUrl, location.href).pathname, html);
|
||||
doc = new DOMParser().parseFromString(html, "text/html");
|
||||
} catch {
|
||||
@@ -534,27 +581,47 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
} else if (oldSidebar) {
|
||||
oldSidebar.remove();
|
||||
}
|
||||
// Site-wide custom CSS lives in <head id="pagerite-user"> and must be
|
||||
// kept in sync across fetch-navigations. It is kept last in <head>:
|
||||
// in dev Vite injects the base stylesheet after the server-rendered
|
||||
// tag, and equal-specificity :root rules are decided by order.
|
||||
const oldUserStyle = document.getElementById("pagerite-user");
|
||||
const newUserStyle = doc.getElementById("pagerite-user");
|
||||
if (oldUserStyle && newUserStyle) {
|
||||
oldUserStyle.textContent = newUserStyle.textContent;
|
||||
document.head.appendChild(oldUserStyle);
|
||||
} else if (newUserStyle) {
|
||||
document.head.appendChild(document.importNode(newUserStyle, true));
|
||||
} else if (oldUserStyle) {
|
||||
oldUserStyle.remove();
|
||||
// Stylesheets live in <head> with stable ids — links in dev, inline
|
||||
// <style> elements in production — and must follow the swap: the
|
||||
// analytics sheet exists on /_a only, and theme/banner/custom CSS
|
||||
// may have changed since this page was loaded. Diff by id, keeping
|
||||
// the fresh document's order; unchanged sheets keep their elements
|
||||
// so their @keyframes are never torn down. Editor-injected sheets
|
||||
// (data-pagerite, no id) and Vite's dev styles (no id) are left
|
||||
// alone. Mirrors the head sync in swapdoc.js.
|
||||
const sel = 'link[rel="stylesheet"][id], style[id]';
|
||||
const fresh = [...doc.head.querySelectorAll(sel)];
|
||||
const freshIds = new Set(fresh.map((el) => el.id));
|
||||
for (const el of [...document.head.querySelectorAll(sel)]) {
|
||||
if (!freshIds.has(el.id)) el.remove();
|
||||
}
|
||||
let anchor = null;
|
||||
for (const el of fresh) {
|
||||
const cur = document.getElementById(el.id);
|
||||
if (cur && cur.outerHTML === el.outerHTML) {
|
||||
anchor = cur;
|
||||
continue;
|
||||
}
|
||||
const imported = document.importNode(el, true);
|
||||
if (cur) cur.replaceWith(imported);
|
||||
else if (anchor) anchor.after(imported);
|
||||
else {
|
||||
const base = document.getElementById("pagerite-base");
|
||||
if (base) base.after(imported);
|
||||
else document.head.append(imported);
|
||||
}
|
||||
anchor = imported;
|
||||
}
|
||||
// Custom CSS must stay last: equal-specificity :root rules (font
|
||||
// variables) are decided by order, and in dev Vite injects the base
|
||||
// stylesheet after the server-rendered tag.
|
||||
const userStyle = document.getElementById("pagerite-user");
|
||||
if (userStyle) document.head.appendChild(userStyle);
|
||||
document.title = doc.title;
|
||||
// Banners may contain scripts (canvas etc.), content pages may too.
|
||||
runScripts(document.getElementById("page-banner"));
|
||||
runScripts(document.getElementById("main"));
|
||||
applyEffects();
|
||||
// The fetched doc carries the analytics meta; the live document's
|
||||
// <head> is never swapped, so querying it would never find the entry.
|
||||
mountAnalytics(doc);
|
||||
};
|
||||
// Rotating cube page transition (see the FRAGILE block in pagerite.css);
|
||||
@@ -707,6 +774,44 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
fit();
|
||||
}
|
||||
|
||||
// --- Nav condense-to-fit -------------------------------------------------
|
||||
// The top nav stays on one row even on too-narrow screens: first the link
|
||||
// gaps shrink, then the nav's side padding, and only in extreme cases the
|
||||
// font size. #nav is replaced on fetch-navigation swaps, so this re-runs
|
||||
// from applyEffects (fresh elements each time); CSS keeps flex-wrap: wrap
|
||||
// as the no-JS fallback.
|
||||
function fitNav() {
|
||||
const nav = document.getElementById("nav");
|
||||
const ul = nav?.querySelector("ul");
|
||||
if (!ul) return;
|
||||
// Restore the themed defaults before measuring.
|
||||
nav.style.fontSize = "";
|
||||
nav.style.paddingInline = "";
|
||||
ul.style.columnGap = "";
|
||||
ul.style.flexWrap = "nowrap";
|
||||
const overflow = () => ul.scrollWidth - ul.clientWidth;
|
||||
if (overflow() <= 0) return;
|
||||
// 1) shrink the gaps between items (down to a fifth of the themed gap)
|
||||
const gap = parseFloat(getComputedStyle(ul).columnGap) || 0;
|
||||
const joints = Math.max(ul.children.length - 1, 1);
|
||||
if (gap > 0) {
|
||||
ul.style.columnGap = `${Math.max(0.2 * gap, gap - overflow() / joints)}px`;
|
||||
}
|
||||
// 2) shrink the nav's side padding (down to 0.4x)
|
||||
if (overflow() > 0) {
|
||||
const pad = parseFloat(getComputedStyle(nav).paddingInlineStart) || 0;
|
||||
nav.style.paddingInline = `${Math.max(0.4 * pad, pad - overflow() / 2)}px`;
|
||||
}
|
||||
// 3) shrink the font to fit what remains
|
||||
if (overflow() > 0) {
|
||||
const fs = parseFloat(getComputedStyle(nav).fontSize);
|
||||
nav.style.fontSize = `${fs * ul.clientWidth / ul.scrollWidth}px`;
|
||||
}
|
||||
}
|
||||
|
||||
addEventListener("resize", fitNav);
|
||||
document.fonts?.ready.then(fitNav);
|
||||
|
||||
setupAuth();
|
||||
applyEffects();
|
||||
mountAnalytics(document);
|
||||
|
||||
+26
-19
@@ -59,32 +59,39 @@ function swapRegions(doc) {
|
||||
curUserStyle.remove()
|
||||
}
|
||||
// Theme and other public stylesheets live in <head>, rendered with stable
|
||||
// ids by the backend; sync them positionally so the custom CSS (rendered
|
||||
// last) always keeps winning by order. Diff-based: unchanged sheets keep
|
||||
// their elements, so their @keyframes are never torn down (re-creating
|
||||
// keyframes would replay the editor's slide-in animation).
|
||||
const freshLinks = [...doc.head.querySelectorAll('link[rel="stylesheet"]')]
|
||||
const freshIds = new Set(freshLinks.map((l) => l.id))
|
||||
for (const link of [...document.head.querySelectorAll('link[rel="stylesheet"]')]) {
|
||||
if (!link.dataset.pagerite && !freshIds.has(link.id)) link.remove()
|
||||
// ids by the backend (links in dev, inline <style> elements in prod);
|
||||
// sync them positionally so the custom CSS (rendered last) always keeps
|
||||
// winning by order. Diff-based: unchanged sheets keep their elements, so
|
||||
// their @keyframes are never torn down (re-creating keyframes would
|
||||
// replay the editor's slide-in animation).
|
||||
const sel = 'link[rel="stylesheet"][id], style[id]'
|
||||
const freshEls = [...doc.head.querySelectorAll(sel)]
|
||||
const freshIds = new Set(freshEls.map((el) => el.id))
|
||||
for (const el of [...document.head.querySelectorAll(sel)]) {
|
||||
if (!freshIds.has(el.id)) el.remove()
|
||||
}
|
||||
// Insert missing sheets in the fresh document's order, each right after
|
||||
// its predecessor's element. The first sheet rendered is always the base
|
||||
// CSS, so its link doubles as the fallback anchor when nothing matched yet
|
||||
// (e.g. no theme was selected before and the position is otherwise lost).
|
||||
// CSS, so its element doubles as the fallback anchor when nothing matched
|
||||
// yet (e.g. no theme was selected before and the position is otherwise
|
||||
// lost).
|
||||
let anchor = null
|
||||
for (const link of freshLinks) {
|
||||
const cur = link.id && document.getElementById(link.id)
|
||||
if (cur && cur.href === link.href) {
|
||||
for (const el of freshEls) {
|
||||
const cur = el.id && document.getElementById(el.id)
|
||||
if (cur && cur.outerHTML === el.outerHTML) {
|
||||
anchor = cur
|
||||
continue
|
||||
}
|
||||
const el = document.importNode(link, true)
|
||||
// Same id, new URL (theme switch): replace in place, keeping position.
|
||||
if (cur) cur.replaceWith(el)
|
||||
else if (anchor) anchor.after(el)
|
||||
else document.getElementById('pagerite-base')?.after(el) ?? document.head.append(el)
|
||||
anchor = el
|
||||
const imported = document.importNode(el, true)
|
||||
// Same id, new content (theme switch): replace in place, keeping position.
|
||||
if (cur) cur.replaceWith(imported)
|
||||
else if (anchor) anchor.after(imported)
|
||||
else {
|
||||
const base = document.getElementById('pagerite-base')
|
||||
if (base) base.after(imported)
|
||||
else document.head.append(imported)
|
||||
}
|
||||
anchor = imported
|
||||
}
|
||||
// The editor keeps its own title while open; only inherit the server title
|
||||
// when navigating outside the editor (e.g. fetch-navigation swaps).
|
||||
|
||||
Reference in New Issue
Block a user