diff --git a/css/main.css b/css/main.css index 0bbd703..7e46d05 100644 --- a/css/main.css +++ b/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; + } } \ No newline at end of file diff --git a/index.html b/index.html index b151381..8df58fb 100644 --- a/index.html +++ b/index.html @@ -96,6 +96,8 @@ +
+ @@ -103,6 +105,11 @@ + \ No newline at end of file diff --git a/js/terminal.js b/js/terminal.js index f808edb..34c04a1 100644 --- a/js/terminal.js +++ b/js/terminal.js @@ -40,17 +40,6 @@ return ''; } - // ---- 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"], - [" [-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)"], + ["", "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"], ["", "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." }; diff --git a/js/visitor-counter.js b/js/visitor-counter.js new file mode 100644 index 0000000..f165f96 --- /dev/null +++ b/js/visitor-counter.js @@ -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: } + 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); \ No newline at end of file