Add visitor counter
This commit is contained in:
parent
2fe5dc6582
commit
0bf9c75bdc
50
css/main.css
50
css/main.css
|
|
@ -2430,4 +2430,54 @@ body:has(.music-wrap) {
|
|||
.mnp-fill { transition: none; }
|
||||
#music.is-live .mnp-state::before { animation: none; }
|
||||
.ly-line { transition: color 0.15s ease; }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
15. VISITOR COUNTER (#visitor-counter)
|
||||
Fixed to top-right, below .beta-bar (theme + cat buttons).
|
||||
============================================================ */
|
||||
#visitor-counter {
|
||||
position: fixed;
|
||||
top: 4rem; /* clears the ~48px beta-bar + gap */
|
||||
right: 1rem;
|
||||
z-index: 6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
#visitor-counter .vc-label {
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: lowercase;
|
||||
color: var(--subtext-0);
|
||||
font-family: 'Comic Code', ui-monospace, monospace;
|
||||
}
|
||||
|
||||
/* Boot reveal: fade in with the rest of the chrome */
|
||||
#visitor-counter {
|
||||
transition: opacity 0.6s ease, transform 0.6s ease;
|
||||
}
|
||||
|
||||
body.term-booting #visitor-counter {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
body.term-chrome-in #visitor-counter {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Mobile: static in topbar flow */
|
||||
@media (max-width: 640px) {
|
||||
#visitor-counter {
|
||||
position: static;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.topbar #visitor-counter {
|
||||
order: 2;
|
||||
}
|
||||
}
|
||||
|
|
@ -96,6 +96,8 @@
|
|||
</span>
|
||||
</aside>
|
||||
|
||||
<div id="visitor-counter" aria-label="Visitor count"></div>
|
||||
|
||||
<script src="/js/cat.js" data-cat="/assets/oneko/classics/classic.png"></script>
|
||||
<script src="/js/nav.js"></script>
|
||||
<script src="/js/now-playing.js" data-user="1464890289922641993"></script>
|
||||
|
|
@ -103,6 +105,11 @@
|
|||
<script src="/js/dev-mode.js"></script>
|
||||
<script src="/js/terminal.js"></script>
|
||||
<script src="/js/site-switcher.js"></script>
|
||||
<script src="/js/visitor-counter.js"
|
||||
data-target="#visitor-counter"
|
||||
data-namespace="clove-is-a-dev"
|
||||
data-key="hits"
|
||||
data-label="visitors"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -40,17 +40,6 @@
|
|||
return '<img class="t-social-ic" src="/assets/socials/' + (SOCIAL_ICON[key] || "site") + '.svg" alt="">';
|
||||
}
|
||||
|
||||
// ---- friends (keyword -> who they are) ---------------------------------
|
||||
const FRIENDS = {
|
||||
ari: { name: "Ari", desc: "🩵 My wifey 🩵 the best 🩵 Her corner of the web:", url: "https://ariare.es", urlLabel: "ariare.es 🩵" },
|
||||
saphie: { name: "Saphie", desc: "🩷 Cammy's partner, loves linguistics🩷", url: "" },
|
||||
camilla: { name: "Camillia (Cammy)", desc: "🖤 Close friend who shares a passion for coding 🖤", url: "https://cammy-the-cat.com", urlLabel: "cammy-the-cat.com 🖤" },
|
||||
ria: { name: "Ria", desc: "🤍 Close friend/platonic daughter who means a lot to me 🤍", url: "" },
|
||||
lilly: { name: "Lilly (Lils)", desc: "💖 Pookie, really cool person 💖", url: "" },
|
||||
primrose: { name: "Nimnose", desc: "💜 Lil's partner 💜", url: "" },
|
||||
fin: { name: "Fin", desc: "💛 Ari's friend who is really nice and who I can be unfilered with 💛", url: "" }
|
||||
};
|
||||
|
||||
let cache = null;
|
||||
async function checkDomain(subdomain) {
|
||||
if (!cache) {
|
||||
|
|
@ -132,8 +121,8 @@
|
|||
const rows = [
|
||||
["help", "show this list"],
|
||||
["socials", "list all socials"],
|
||||
["<social> [-open]", "show a social & ask to open it (append -open to do directly)"],
|
||||
["system [person]", "open my system website (append a person's name to open their page)"],
|
||||
["<social>", "show a social & ask to open it (append -open to do directly)"],
|
||||
["system", "open my system website (append a person's name to open their page)"],
|
||||
["about", "a little about me"],
|
||||
["hyfetch", "system info, with flair"]
|
||||
];
|
||||
|
|
@ -147,7 +136,7 @@
|
|||
["help", "show this list"],
|
||||
["socials", "list all socials"],
|
||||
["<social>", "show a social & ask to open it (append -open to do directly)"],
|
||||
["system [person]", "open my system website (append a person's name to open their page)"],
|
||||
["system", "open my system website (append a person's name to open their page)"],
|
||||
["about", "a little about me"],
|
||||
["hyfetch", "system info, with flair"]
|
||||
];
|
||||
|
|
@ -195,7 +184,7 @@
|
|||
"Clove Twilight — fae/faer\n" +
|
||||
"Transfem developer from Southampton, UK. I make Discord bots,\n" +
|
||||
"personal-site nonsense, and run a small corner of the internet\n" +
|
||||
"under the trade mark 'doughmination'. Big on Linux, Catppuccin, and cats.\n\n" +
|
||||
"under the trade mark 'doughmination system'. Big on Linux, Catppuccin, and cats.\n\n" +
|
||||
"This site is the beta playground for clove.is-a.dev — expect things\n" +
|
||||
"to break in funny ways. Type 'socials' to find me elsewhere."
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,197 @@
|
|||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
const STYLE_ID = "visitor-counter-styles";
|
||||
|
||||
function injectStyles() {
|
||||
if (document.getElementById(STYLE_ID)) return;
|
||||
const s = document.createElement("style");
|
||||
s.id = STYLE_ID;
|
||||
s.textContent = `
|
||||
.vc-root {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: "Comic Code";
|
||||
}
|
||||
.vc-digits {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 50px;
|
||||
}
|
||||
.vc-digits img {
|
||||
display: block;
|
||||
image-rendering: pixelated;
|
||||
width: 22.5px;
|
||||
height: 50px;
|
||||
}
|
||||
.vc-label {
|
||||
font-size: 11px;
|
||||
color: var(--ch-muted, #8b95a1);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
.vc-error {
|
||||
font-size: 11px;
|
||||
color: var(--ch-muted, #8b95a1);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vc-digits img { animation: none !important; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached visitor count for this session, or null if not cached.
|
||||
* The cache key is scoped to namespace+key so multiple counters don't collide.
|
||||
*/
|
||||
function getCached(namespace, key) {
|
||||
try {
|
||||
const raw = localStorage.getItem("vc:" + namespace + ":" + key);
|
||||
if (!raw) return null;
|
||||
const { count, session } = JSON.parse(raw);
|
||||
// Use sessionStorage token to detect new tabs vs. refreshes.
|
||||
// A refresh keeps the same sessionStorage; a new tab starts fresh.
|
||||
const token = sessionStorage.getItem("vc-session");
|
||||
if (token && token === session) return count;
|
||||
return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setCached(namespace, key, count) {
|
||||
try {
|
||||
// Create (or reuse) a session token so this count is tied to the tab session.
|
||||
let token = sessionStorage.getItem("vc-session");
|
||||
if (!token) {
|
||||
token = Math.random().toString(36).slice(2);
|
||||
sessionStorage.setItem("vc-session", token);
|
||||
}
|
||||
localStorage.setItem(
|
||||
"vc:" + namespace + ":" + key,
|
||||
JSON.stringify({ count, session: token })
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function fetchCount(namespace, key) {
|
||||
const url = `https://abacus.jasoncameron.dev/hit/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error("Abacus HTTP " + res.status);
|
||||
const data = await res.json();
|
||||
// Abacus returns { value: <number> }
|
||||
if (typeof data.value !== "number") throw new Error("Unexpected response shape");
|
||||
return data.value;
|
||||
}
|
||||
|
||||
function renderDigits(container, count, imgPath, imgExt) {
|
||||
container.innerHTML = "";
|
||||
const digits = String(Math.max(0, Math.floor(count))).padStart(6, "0");
|
||||
for (const d of digits) {
|
||||
const img = document.createElement("img");
|
||||
img.src = imgPath + d + imgExt;
|
||||
img.alt = d;
|
||||
img.width = 22.5;
|
||||
img.height = 50;
|
||||
container.appendChild(img);
|
||||
}
|
||||
}
|
||||
|
||||
async function render(target, options) {
|
||||
const opts = Object.assign(
|
||||
{
|
||||
namespace: "clove-is-a-dev",
|
||||
key: "hits",
|
||||
imgPath: "/assets/numbers/",
|
||||
imgExt: ".gif",
|
||||
label: "visitors",
|
||||
},
|
||||
options || {}
|
||||
);
|
||||
|
||||
const root =
|
||||
typeof target === "string" ? document.querySelector(target) : target;
|
||||
if (!root) {
|
||||
console.warn("[visitor-counter] target not found:", target);
|
||||
return;
|
||||
}
|
||||
|
||||
injectStyles();
|
||||
root.classList.add("vc-root");
|
||||
root.innerHTML = "";
|
||||
|
||||
const digitsEl = document.createElement("div");
|
||||
digitsEl.className = "vc-digits";
|
||||
root.appendChild(digitsEl);
|
||||
|
||||
if (opts.label) {
|
||||
const labelEl = document.createElement("span");
|
||||
labelEl.className = "vc-label";
|
||||
labelEl.textContent = opts.label;
|
||||
root.appendChild(labelEl);
|
||||
}
|
||||
|
||||
// Try cache first — avoids hitting the API (and incrementing) on refresh
|
||||
const cached = getCached(opts.namespace, opts.key);
|
||||
if (cached !== null) {
|
||||
renderDigits(digitsEl, cached, opts.imgPath, opts.imgExt);
|
||||
return;
|
||||
}
|
||||
|
||||
// First visit in this session: hit the API
|
||||
try {
|
||||
const count = await fetchCount(opts.namespace, opts.key);
|
||||
setCached(opts.namespace, opts.key, count);
|
||||
renderDigits(digitsEl, count, opts.imgPath, opts.imgExt);
|
||||
} catch (err) {
|
||||
console.error("[visitor-counter]", err);
|
||||
const errEl = document.createElement("span");
|
||||
errEl.className = "vc-error";
|
||||
errEl.textContent = "— visitors";
|
||||
digitsEl.replaceWith(errEl);
|
||||
if (opts.label && root.querySelector(".vc-label")) {
|
||||
root.querySelector(".vc-label").remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-init from script tag attributes
|
||||
function autoInit() {
|
||||
const script =
|
||||
document.currentScript ||
|
||||
document.querySelector('script[src*="visitor-counter"]');
|
||||
if (!script) return;
|
||||
|
||||
const targetSel = script.dataset.target;
|
||||
if (!targetSel) return;
|
||||
|
||||
const ns = script.dataset.namespace;
|
||||
const key = script.dataset.key;
|
||||
const imgPath = script.dataset.imgPath;
|
||||
const imgExt = script.dataset.imgExt;
|
||||
const label = script.dataset.label;
|
||||
|
||||
const init = () =>
|
||||
render(targetSel, {
|
||||
...(ns && { namespace: ns }),
|
||||
...(key && { key }),
|
||||
...(imgPath && { imgPath }),
|
||||
...(imgExt && { imgExt }),
|
||||
...(label !== undefined && { label }),
|
||||
});
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
}
|
||||
|
||||
autoInit();
|
||||
|
||||
global.VisitorCounter = { render };
|
||||
})(typeof window !== "undefined" ? window : this);
|
||||
Loading…
Reference in New Issue