visitor count uwu nya owo nya nyaaaa uwuu nya mrow mrreoowww
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
|
@ -0,0 +1,55 @@
|
||||||
|
/* ============================================================
|
||||||
|
visitor-counter.css — #visitor-counter widget, top-right.
|
||||||
|
Shared across all pages.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
15. VISITOR COUNTER (#visitor-counter)
|
||||||
|
Fixed to top-right, below .beta-bar (theme + cat buttons).
|
||||||
|
============================================================ */
|
||||||
|
#visitor-counter {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 10rem;
|
||||||
|
/* clears the ~48px beta-bar + gap */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
index.html
|
|
@ -44,6 +44,7 @@
|
||||||
<title>Ari</title>
|
<title>Ari</title>
|
||||||
<link rel="icon" type="image/png" href="/assets/media/favicon.png" />
|
<link rel="icon" type="image/png" href="/assets/media/favicon.png" />
|
||||||
<link rel="stylesheet" href="/css/index.css">
|
<link rel="stylesheet" href="/css/index.css">
|
||||||
|
<link rel="stylesheet" href="/css/visitor-counter.css">
|
||||||
<script>try { var f = localStorage.getItem('ctpFlavor'); document.documentElement.setAttribute('data-flavor', ['mocha', 'macchiato', 'frappe', 'latte'].indexOf(f) >= 0 ? f : 'mocha'); } catch (e) { document.documentElement.setAttribute('data-flavor', 'mocha'); }</script>
|
<script>try { var f = localStorage.getItem('ctpFlavor'); document.documentElement.setAttribute('data-flavor', ['mocha', 'macchiato', 'frappe', 'latte'].indexOf(f) >= 0 ? f : 'mocha'); } catch (e) { document.documentElement.setAttribute('data-flavor', 'mocha'); }</script>
|
||||||
<!-- SEO Meta Tags -->
|
<!-- SEO Meta Tags -->
|
||||||
<meta name="description" content="Personal site for Ari on git.gay/AriTheStupidCat on GitHub" />
|
<meta name="description" content="Personal site for Ari on git.gay/AriTheStupidCat on GitHub" />
|
||||||
|
|
@ -81,8 +82,6 @@
|
||||||
<a href="/cv/">Curriculum Vitae</a>
|
<a href="/cv/">Curriculum Vitae</a>
|
||||||
<a href="/socials/">Socials</a>
|
<a href="/socials/">Socials</a>
|
||||||
<a href="/guestbook/">Guestbook</a>
|
<a href="/guestbook/">Guestbook</a>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -104,17 +103,17 @@
|
||||||
Git.Gay</a>
|
Git.Gay</a>
|
||||||
<a class="gitgay-link" href="https://github.com/AriTheStupidCat" target="_blank" rel="noopener">Check
|
<a class="gitgay-link" href="https://github.com/AriTheStupidCat" target="_blank" rel="noopener">Check
|
||||||
out my GitHub</a>
|
out my GitHub</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</main>
|
</main>
|
||||||
|
<div id="visitor-counter" role="status" aria-label="Visitor count"></div>
|
||||||
<aside class="button-wall" aria-label="88x31 buttons">
|
<aside class="button-wall" aria-label="88x31 buttons">
|
||||||
<img src="/assets/88x31/linux.gif" alt="Linux" loading="lazy">
|
<img src="/assets/88x31/linux.gif" alt="Linux" loading="lazy">
|
||||||
<img src="/assets/88x31/microslop.gif" alt="Microslop" loading="lazy">
|
<img src="/assets/88x31/microslop.gif" alt="Microslop" loading="lazy">
|
||||||
<img src="/assets/88x31/estrogen.gif" alt="Estrogen" loading="lazy">
|
<img src="/assets/88x31/estrogen.gif" alt="Estrogen" loading="lazy">
|
||||||
<img src="/assets/88x31/girlsnow.png" alt="Girls Network" loading="lazy">
|
<img src="/assets/88x31/girlsnow.png" alt="Girls Network" loading="lazy">
|
||||||
<img src="/assets/88x31/skirt.gif" alt="Skirt" loading="lazy">
|
<img src="/assets/88x31/skirt.gif" alt="Skirt" loading="lazy">
|
||||||
|
|
||||||
<img src="/assets/88x31/gitgay.png" alt="GitGay" loading="lazy">
|
<img src="/assets/88x31/gitgay.png" alt="GitGay" loading="lazy">
|
||||||
<img src="/assets/88x31/blink.gif" alt="Blink" loading="lazy">
|
<img src="/assets/88x31/blink.gif" alt="Blink" loading="lazy">
|
||||||
<img src="/assets/88x31/firefox.gif" alt="Firefox" loading="lazy">
|
<img src="/assets/88x31/firefox.gif" alt="Firefox" loading="lazy">
|
||||||
|
|
@ -122,9 +121,13 @@
|
||||||
<img src="/assets/88x31/noweb32.gif" alt="No Web 3.2" loading="lazy">
|
<img src="/assets/88x31/noweb32.gif" alt="No Web 3.2" loading="lazy">
|
||||||
<img src="/assets/88x31/meltice.gif" alt="Melt Ice" loading="lazy">
|
<img src="/assets/88x31/meltice.gif" alt="Melt Ice" loading="lazy">
|
||||||
<img src="/assets/88x31/transnow2.gif" alt="Melt Ice" loading="lazy">
|
<img src="/assets/88x31/transnow2.gif" alt="Melt Ice" loading="lazy">
|
||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
<script src="/js/index.js"></script>
|
<script src="/js/index.js"></script>
|
||||||
<script src="/js/flavors.js"></script>
|
<script src="/js/flavors.js"></script>
|
||||||
<script src="/js/cat.js"></script>
|
<script src="/js/cat.js"></script>
|
||||||
|
<script src="/js/visitor-counter.js" data-target="#visitor-counter" data-namespace="a-stupid-cat" data-key="hits" data-label="visitors"></script>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -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 a sessionStorage token to tell a new tab apart from a refresh.
|
||||||
|
* 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: ".png",
|
||||||
|
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 the cache first, this avoids hitting the API (and incrementing
|
||||||
|
* the count) on every refresh. */
|
||||||
|
const cached = getCached(opts.namespace, opts.key);
|
||||||
|
if (cached !== null) {
|
||||||
|
renderDigits(digitsEl, cached, opts.imgPath, opts.imgExt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* First visit in this session, so 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);
|
||||||