Files
Ty Clifford 5f7c62f8a7 v3.1.1
2026-06-24 09:19:08 -04:00

1177 lines
55 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// ── Bootstrap ─────────────────────────────────────────────────────────────────
require_once __DIR__ . '/index_config/config.php';
require_once __DIR__ . '/functions.php';
// Unserialise config arrays
$typed = unserialize(TYPED_STRINGS);
$navs = unserialize(NAV_LINKS);
$donates = unserialize(DONATE_LINKS);
$socials = unserialize(SOCIAL_LINKS);
// Load updates feed — paginate for initial render
$all_updates = [];
if (file_exists(UPDATES_JSON)) {
$raw = json_decode(file_get_contents(UPDATES_JSON), true);
if (is_array($raw)) $all_updates = $raw;
}
$updates_per_page = max(1, (int) UPDATES_PER_PAGE);
$updates_page1 = array_slice($all_updates, 0, $updates_per_page);
// ── Shared SVG icon map ───────────────────────────────────────────────────────
$svg_icons = [
'github' => '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"/></svg>',
'email' => '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="2,4 12,13 22,4"/></svg>',
'patreon' => '<svg width="12" height="14" viewBox="0 0 12 14" fill="currentColor" aria-hidden="true"><ellipse cx="7.5" cy="4.8" rx="4.5" ry="4.5"/><rect x="0" y="0" width="2.8" height="14" rx="1.2"/></svg>',
'bluesky' => '<svg width="14" height="13" viewBox="0 0 568 501" fill="currentColor" aria-hidden="true"><path d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.209C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.889-129.52 80.454-149.07-65.72 11.185-139.6-7.295-159.875-79.748C9.945 203.659 0 75.293 0 57.947 0-28.906 76.134-1.611 123.121 33.664Z"/></svg>',
'tiktok' => '<svg width="13" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-2.88 2.5 2.89 2.89 0 0 1-2.89-2.89 2.89 2.89 0 0 1 2.89-2.89c.28 0 .54.04.79.1V9.01a6.33 6.33 0 0 0-.79-.05 6.34 6.34 0 0 0-6.34 6.34 6.34 6.34 0 0 0 6.34 6.34 6.34 6.34 0 0 0 6.33-6.34V8.69a8.17 8.17 0 0 0 4.79 1.54V6.78a4.85 4.85 0 0 1-1.02-.09z"/></svg>',
'youtube' => '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M22.54 6.42a2.78 2.78 0 0 0-1.95-1.96C18.88 4 12 4 12 4s-6.88 0-8.59.46A2.78 2.78 0 0 0 1.46 6.42 29 29 0 0 0 1 12a29 29 0 0 0 .46 5.58 2.78 2.78 0 0 0 1.95 1.96C5.12 20 12 20 12 20s6.88 0 8.59-.46a2.78 2.78 0 0 0 1.95-1.96A29 29 0 0 0 23 12a29 29 0 0 0-.46-5.58z"/><polygon points="9.75 15.02 15.5 12 9.75 8.98 9.75 15.02" fill="white"/></svg>',
'web' => '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>',
];
// ── Helper: relative time ──────────────────────────────────────────────────────
function relative_time(string $iso): string {
try {
$dt = new DateTime($iso, new DateTimeZone('UTC'));
$now = new DateTime('now', new DateTimeZone('UTC'));
$diff = $now->getTimestamp() - $dt->getTimestamp();
if ($diff < 60) return 'just now';
if ($diff < 3600) return floor($diff / 60) . 'm ago';
if ($diff < 86400) return floor($diff / 3600) . 'h ago';
if ($diff < 604800) return floor($diff / 86400) . 'd ago';
return $dt->format('M j, Y');
} catch (Exception $e) {
return htmlspecialchars($iso);
}
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="<?= htmlspecialchars(SITE_DESCRIPTION) ?>">
<meta name="author" content="<?= htmlspecialchars(SITE_AUTHOR) ?>">
<meta name="keywords" content="<?= htmlspecialchars(SITE_KEYWORDS) ?>">
<meta property="og:url" content="<?= htmlspecialchars(SITE_URL) ?>/?mtm_campaign=homepage">
<meta property="og:type" content="website">
<meta property="og:site_name" content="<?= htmlspecialchars(SITE_NAME) ?> — <?= htmlspecialchars(SITE_TAGLINE) ?>">
<meta property="og:title" content="<?= htmlspecialchars(SITE_NAME) ?> — <?= htmlspecialchars(SITE_TAGLINE) ?>">
<meta property="og:image" content="<?= htmlspecialchars(SITE_OG_IMAGE) ?>">
<meta property="og:image:alt" content="<?= htmlspecialchars(SITE_OG_ALT) ?>">
<meta name="twitter:site" content="<?= htmlspecialchars(SITE_TWITTER) ?>">
<meta name="twitter:title" content="<?= htmlspecialchars(SITE_NAME) ?>">
<meta name="twitter:description" content="<?= htmlspecialchars(SITE_TAGLINE) ?>">
<title><?= htmlspecialchars(SITE_NAME) ?> — <?= htmlspecialchars(SITE_TAGLINE) ?></title>
<link rel="shortcut icon" href="<?= SITE_URL ?>/images/ico/fa-bl.ico">
<link rel="apple-touch-icon" href="<?= SITE_URL ?>/images/ico/fa-bl.jpg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<?php if (CALENDLY_URL !== ''): ?>
<link href="https://assets.calendly.com/assets/external/widget.css" rel="stylesheet">
<?php endif; ?>
<style>
/* ════════════════════════════════════════════════════════════════════════
DESIGN TOKENS — exact match to gate.php
════════════════════════════════════════════════════════════════════════ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #09090f;
--surface: #0f0f1a;
--surface-2: #141425;
--border: #1e1e38;
--border-2: #2a2a48;
--text: #e8e8f8;
--text-2: #9898c0;
--text-3: #58587a;
--green: #00e87a;
--blue: #00c8ff;
--purple: #b060ff;
--red: #ff3355;
--orange: #ff8800;
--yellow: #ffd040;
--pink: #ff50a0;
--peach: #ffb07a;
--sky: #38bdf8;
--hotpink: #f472b6;
--pat: #ff424d;
--pat-lite: rgba(255,66,77,.10);
--pat-mid: rgba(255,66,77,.22);
--pat-border: rgba(255,66,77,.32);
--pat-glow: rgba(255,66,77,.18);
--libera: #f6c915;
--paypal: #009cde;
--r: 6px;
--gap: 1.25rem;
--pp: 1rem;
}
@media (min-width: 480px) { :root { --pp: 1.5rem; } }
@media (min-width: 900px) { :root { --pp: 2rem; } }
@media (min-width: 1200px) { :root { --pp: 2.5rem; } }
html { font-size: 16px; -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
body {
min-height: 100dvh;
background: var(--bg);
font-family: 'Inter', system-ui, sans-serif;
color: var(--text);
display: flex;
flex-direction: column;
align-items: center;
position: relative;
overflow-x: hidden;
}
/* ── Background video ───────────────────────────────────────────────────── */
.bg-video-wrap {
position: fixed; inset: 0; z-index: 0; overflow: hidden;
}
.bg-video-wrap video {
position: absolute; inset: 0;
width: 100%; height: 100%;
object-fit: cover; object-position: center;
}
.bg-video-wrap::after {
content: '';
position: absolute; inset: 0;
background: rgba(6,6,14,<?= BG_OVERLAY ?>);
}
.bg-video-wrap::before {
content: '';
position: absolute; inset: 0; z-index: 1;
background:
radial-gradient(ellipse 70% 50% at 20% 10%, rgba(176,96,255,.12) 0%, transparent 60%),
radial-gradient(ellipse 60% 40% at 80% 80%, rgba(0,200,255,.09) 0%, transparent 60%),
radial-gradient(ellipse 50% 60% at 50% 50%, rgba(255,66,77,.07) 0%, transparent 70%);
}
/* scanline */
body::after {
content: '';
position: fixed; inset: 0; pointer-events: none; z-index: 9998;
background: repeating-linear-gradient(
0deg, transparent 0px, transparent 3px,
rgba(0,0,0,.08) 3px, rgba(0,0,0,.08) 4px
);
}
/* ── Animations ─────────────────────────────────────────────────────────── */
@keyframes rise {
from { opacity: 0; transform: translateY(14px) scale(.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes shimmer {
from { background-position: 0% 50%; }
to { background-position: 200% 50%; }
}
@keyframes pulse-border {
0%,100% { box-shadow: 0 0 0 0 var(--pat-glow); }
50% { box-shadow: 0 0 0 6px transparent; }
}
@keyframes blink-cursor {
0%,100% { opacity: 1; }
50% { opacity: 0; }
}
@keyframes update-flash {
0% { background: rgba(0,232,122,.10); }
100% { background: transparent; }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes slide-down {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
/* ════════════════════════════════════════════════════════════════════════
PAGE WRAPPER
════════════════════════════════════════════════════════════════════════ */
.page {
position: relative; z-index: 1;
width: 100%;
max-width: 1100px;
padding: var(--pp) var(--pp) 2rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
animation: rise .5s cubic-bezier(.22,.68,0,1.2) both;
}
/* ════════════════════════════════════════════════════════════════════════
HERO SECTION
════════════════════════════════════════════════════════════════════════ */
.hero {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.2rem;
padding-top: 1.5rem;
text-align: center;
}
.profile-img-wrap {
width: clamp(80px, 18vw, 108px);
height: clamp(80px, 18vw, 108px);
border-radius: 50%;
padding: 3px;
background: linear-gradient(135deg,
var(--pat), var(--purple), var(--blue), var(--green),
var(--blue), var(--purple), var(--pat));
background-size: 300% 300%;
animation: shimmer 4s linear infinite;
box-shadow: 0 0 32px rgba(176,96,255,.25), 0 4px 24px rgba(0,0,0,.5);
flex-shrink: 0;
}
.profile-img-wrap img {
width: 100%; height: 100%;
border-radius: 50%; object-fit: cover; display: block;
}
.hero-eyebrow {
font-family: 'Share Tech Mono', monospace;
font-size: .62rem; letter-spacing: .22em; text-transform: uppercase;
color: var(--green); text-shadow: 0 0 10px rgba(0,232,122,.5);
}
.hero-eyebrow::before { content: '> '; opacity: .5; }
.hero-h1 {
font-size: clamp(1.55rem, 5vw, 2.3rem);
font-weight: 700; line-height: 1.2; letter-spacing: -.02em; color: var(--text);
}
.hero-h1 .dude-line {
background: linear-gradient(135deg, var(--blue) 0%, var(--purple) 60%, var(--pink) 100%);
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.typed-wrap {
display: inline-flex; align-items: baseline; gap: .2em;
font-size: clamp(1.55rem, 5vw, 2.3rem);
font-weight: 700; letter-spacing: -.02em; line-height: 1.2;
}
.typed-word { display: inline-block; min-width: 2ch; color: var(--text); }
.typed-word.grad-green {
background: linear-gradient(120deg, var(--green), var(--blue));
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.typed-word.grad-purple {
background: linear-gradient(120deg, var(--purple), var(--pink));
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.typed-cursor {
display: inline-block; width: 2px; height: 1em;
background: var(--green); border-radius: 1px; vertical-align: middle; margin-left: 2px;
box-shadow: 0 0 8px rgba(0,232,122,.7);
animation: blink-cursor .9s ease-in-out infinite;
}
.hero-tagline {
font-size: .9rem; color: var(--text-2); font-style: italic; letter-spacing: .01em;
}
.hero-cta { display: flex; gap: .5rem; flex-wrap: wrap; justify-content: center; }
.btn-cta {
display: inline-flex; align-items: center; gap: .4rem;
padding: .5rem 1rem;
border: 1px solid var(--border-2); border-radius: var(--r);
background: rgba(255,255,255,.05);
font-size: .82rem; font-weight: 600;
color: var(--text-2); text-decoration: none; cursor: pointer;
transition: background .2s, border-color .2s, color .2s; min-height: 38px;
}
.btn-cta:hover { background: rgba(255,255,255,.09); color: var(--text); border-color: var(--green); }
/* ════════════════════════════════════════════════════════════════════════
HORIZONTAL NAV PILLS
════════════════════════════════════════════════════════════════════════ */
.nav-pill-bar {
background: rgba(15,15,26,.78);
border: 1px solid var(--border);
border-radius: calc(var(--r) + 4px);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
padding: .75rem 1rem;
position: relative; overflow: hidden;
}
.nav-pill-bar::before {
content: '';
position: absolute; top: 0; left: 0; right: 0; height: 2px;
background: linear-gradient(90deg,
var(--pat), var(--purple), var(--blue), var(--green),
var(--blue), var(--purple), var(--pat));
background-size: 300% 100%;
animation: shimmer 5s linear infinite;
}
.nav-pill-list {
list-style: none;
display: flex; flex-wrap: wrap; gap: .4rem; justify-content: center;
}
@media (min-width: 700px) {
.nav-pill-list {
flex-wrap: nowrap; justify-content: space-evenly;
overflow-x: auto; scrollbar-width: none;
}
.nav-pill-list::-webkit-scrollbar { display: none; }
}
.nav-pill-list a {
display: inline-flex; align-items: center; gap: .35rem;
padding: .38rem .85rem;
border: 1px solid var(--border-2); border-radius: 99px;
background: rgba(255,255,255,.03);
font-size: .82rem; font-weight: 600; letter-spacing: -.01em;
text-decoration: none; color: var(--text-2); white-space: nowrap;
transition: background .2s, border-color .2s, color .2s, transform .1s;
min-height: 34px;
}
.nav-pill-list a:hover { background: rgba(255,255,255,.08); color: var(--text); transform: translateY(-1px); }
.nav-pill-list a:active { transform: scale(.97); }
.pill-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; box-shadow: 0 0 5px currentColor; }
.col-blue { color: var(--blue); }
.col-green { color: var(--green); }
.col-orange { color: var(--orange); }
.col-pink { color: var(--pink); }
.col-peach { color: var(--peach); }
.col-red { color: var(--red); }
.col-hotpink { color: var(--hotpink); }
.col-sky { color: var(--sky); }
/* ════════════════════════════════════════════════════════════════════════
WIDGET GRID
Mobile: 1-col stack (updates first, support below)
>= 640px: 2 equal columns, side-by-side, matching height
════════════════════════════════════════════════════════════════════════ */
.widget-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
align-items: stretch;
}
@media (min-width: 640px) {
.widget-grid {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr;
}
.widget-updates { grid-column: 1; grid-row: 1; }
.widget-support { grid-column: 2; grid-row: 1; }
}
/* ── Shared widget card ──────────────────────────────────────────────────── */
.widget {
background: rgba(15,15,26,.82);
border: 1px solid var(--border);
border-radius: calc(var(--r) + 4px);
overflow: hidden;
position: relative;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
box-shadow: 0 0 0 1px rgba(255,255,255,.03), 0 6px 40px rgba(0,0,0,.55);
display: flex; flex-direction: column;
}
.widget::before {
content: '';
position: absolute; top: 0; left: 0; right: 0; height: 2px; z-index: 2;
background: linear-gradient(90deg,
var(--pat), var(--purple), var(--blue), var(--green),
var(--blue), var(--purple), var(--pat));
background-size: 300% 100%;
animation: shimmer 5s linear infinite;
}
.widget::after {
content: '';
position: absolute; bottom: -1px; right: -1px;
width: 14px; height: 14px;
border-bottom: 2px solid var(--purple);
border-right: 2px solid var(--purple);
border-radius: 0 0 calc(var(--r) + 4px) 0;
opacity: .5;
}
.widget-header {
display: flex; align-items: center; justify-content: space-between;
padding: .9rem 1.2rem .7rem;
border-bottom: 1px solid var(--border);
flex-shrink: 0; gap: .5rem;
}
.widget-header-left { display: flex; align-items: center; gap: .55rem; }
.widget-header-right { display: flex; align-items: center; gap: .55rem; flex-shrink: 0; }
.widget-title {
font-family: 'Share Tech Mono', monospace;
font-size: .62rem; letter-spacing: .2em; text-transform: uppercase;
color: var(--green); text-shadow: 0 0 10px rgba(0,232,122,.5);
}
.widget-title::before { content: '> '; opacity: .5; }
.widget-badge {
font-family: 'Share Tech Mono', monospace;
font-size: .58rem; letter-spacing: .08em; color: var(--text-3);
}
.widget-body {
padding: .9rem 1.2rem 1.1rem;
flex: 1; display: flex; flex-direction: column; gap: 0;
}
/* ── Live indicator dot in updates header ────────────────────────────────── */
.live-dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--green);
box-shadow: 0 0 6px rgba(0,232,122,.7);
flex-shrink: 0;
transition: background .3s, box-shadow .3s;
}
/* dims while polling */
.live-dot.polling {
background: var(--orange);
box-shadow: 0 0 6px rgba(255,136,0,.7);
animation: none;
}
/* pulses when new content detected */
.live-dot.new-content {
background: var(--green);
box-shadow: 0 0 0 0 rgba(0,232,122,.6);
animation: live-ring 1s ease-out 3;
}
@keyframes live-ring {
0% { box-shadow: 0 0 0 0 rgba(0,232,122,.6); }
70% { box-shadow: 0 0 0 7px rgba(0,232,122,0); }
100% { box-shadow: 0 0 0 0 rgba(0,232,122,0); }
}
/* last-checked timestamp in header */
.last-checked {
font-family: 'Share Tech Mono', monospace;
font-size: .55rem; letter-spacing: .06em; color: var(--text-3);
white-space: nowrap;
}
/* spinning loader shown during poll */
.poll-spinner {
display: none;
width: 10px; height: 10px; border-radius: 50%;
border: 1.5px solid rgba(0,232,122,.2);
border-top-color: var(--green);
animation: spin .6s linear infinite;
flex-shrink: 0;
}
.poll-spinner.visible { display: block; }
/* ── "New posts" banner that slides in above the feed ────────────────────── */
.new-posts-banner {
display: none;
align-items: center; justify-content: space-between; gap: .5rem;
padding: .5rem .9rem;
background: rgba(0,232,122,.08);
border: 1px solid rgba(0,232,122,.22);
border-radius: var(--r);
margin-bottom: .75rem;
animation: slide-down .25s ease;
cursor: pointer;
}
.new-posts-banner.visible { display: flex; }
.new-posts-banner-text {
font-family: 'Share Tech Mono', monospace;
font-size: .65rem; letter-spacing: .06em; color: var(--green);
}
.new-posts-banner-btn {
font-family: 'Share Tech Mono', monospace;
font-size: .6rem; letter-spacing: .08em;
color: var(--green); opacity: .7; white-space: nowrap;
}
/* ════════════════════════════════════════════════════════════════════════
UPDATES FEED
════════════════════════════════════════════════════════════════════════ */
.updates-feed {
list-style: none; display: flex; flex-direction: column;
}
.update-item {
padding: .8rem 0;
border-bottom: 1px solid var(--border);
}
.update-item:last-child { border-bottom: none; padding-bottom: 0; }
.update-item:first-child { padding-top: 0; }
/* flash animation on new items */
.update-item.is-new {
animation: update-flash .8s ease-out both;
border-radius: 4px;
padding-left: .4rem; padding-right: .4rem;
}
.update-meta {
display: flex; align-items: center; gap: .5rem;
margin-bottom: .35rem; flex-wrap: wrap;
}
.update-time {
font-family: 'Share Tech Mono', monospace;
font-size: .6rem; color: var(--text-3); letter-spacing: .04em;
}
.update-tag {
display: inline-block;
font-family: 'Share Tech Mono', monospace;
font-size: .55rem; letter-spacing: .08em; text-transform: uppercase;
padding: .05em .4em;
border: 1px solid var(--border-2); border-radius: 3px; color: var(--text-3);
}
.update-text {
font-size: .88rem; color: var(--text); line-height: 1.6; margin-bottom: .35rem;
}
.update-link {
display: inline-flex; align-items: center; gap: .3rem;
font-family: 'Share Tech Mono', monospace;
font-size: .62rem; letter-spacing: .04em;
color: var(--blue); text-decoration: none; opacity: .8; transition: opacity .15s;
}
.update-link:hover { opacity: 1; }
.update-link::after { content: ' →'; }
.widget-empty {
font-family: 'Share Tech Mono', monospace;
font-size: .72rem; color: var(--text-3); text-align: center;
padding: 1.5rem 0; letter-spacing: .06em;
}
.widget-empty::before { content: '// '; opacity: .4; }
/* ════════════════════════════════════════════════════════════════════════
PAGINATION
════════════════════════════════════════════════════════════════════════ */
.pagination {
display: flex; align-items: center; justify-content: space-between; gap: .5rem;
padding-top: .85rem;
border-top: 1px solid var(--border);
margin-top: .5rem;
flex-wrap: wrap;
}
.pagination-info {
font-family: 'Share Tech Mono', monospace;
font-size: .6rem; letter-spacing: .06em; color: var(--text-3);
}
.pagination-btns { display: flex; gap: .35rem; }
.page-btn {
display: inline-flex; align-items: center; justify-content: center;
min-width: 28px; height: 28px; padding: 0 .5rem;
border: 1px solid var(--border-2); border-radius: 4px;
background: rgba(255,255,255,.03);
font-family: 'Share Tech Mono', monospace;
font-size: .65rem; letter-spacing: .04em; color: var(--text-3);
cursor: pointer;
transition: border-color .15s, color .15s, background .15s;
}
.page-btn:hover:not(:disabled) { border-color: var(--green); color: var(--green); background: rgba(0,232,122,.05); }
.page-btn.active { border-color: var(--green); color: var(--green); background: rgba(0,232,122,.08); }
.page-btn:disabled { opacity: .3; cursor: not-allowed; }
/* ════════════════════════════════════════════════════════════════════════
SUPPORT WIDGET
════════════════════════════════════════════════════════════════════════ */
.donate-stack { display: flex; flex-direction: column; gap: .4rem; margin-bottom: 1rem; }
.btn-donate {
display: flex; align-items: center; justify-content: center; gap: .5rem;
padding: .7rem 1rem; border-radius: var(--r);
font-family: 'Inter', sans-serif; font-size: .84rem; font-weight: 700; letter-spacing: .02em;
text-decoration: none; position: relative; overflow: hidden;
transition: transform .15s, filter .15s; min-height: 44px;
}
.btn-donate::before {
content: ''; position: absolute; inset: 0;
background: linear-gradient(180deg, rgba(255,255,255,.1) 0%, transparent 60%);
pointer-events: none;
}
.btn-donate:hover { transform: translateY(-1px); filter: brightness(1.1); }
.btn-donate:active { transform: scale(.98); filter: brightness(.95); }
.btn-patreon-donate { background: var(--pat); color: #fff; box-shadow: 0 0 0 1px rgba(255,255,255,.1) inset, 0 4px 18px rgba(255,66,77,.3); animation: pulse-border 3s ease-in-out infinite; }
.btn-liberapay { background: var(--libera); color: #1a1200; box-shadow: 0 4px 14px rgba(246,201,21,.2); }
.btn-paypal { background: var(--paypal); color: #fff; box-shadow: 0 4px 14px rgba(0,156,222,.2); }
.btn-generic { background: var(--surface-2); color: var(--text-2); border: 1px solid var(--border-2); }
.support-divider {
display: flex; align-items: center; gap: .6rem; margin: .75rem 0;
font-family: 'Share Tech Mono', monospace; font-size: .58rem; letter-spacing: .14em; text-transform: uppercase; color: var(--text-3);
}
.support-divider::before, .support-divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
.social-list { list-style: none; display: flex; flex-wrap: wrap; gap: .4rem; }
.social-list a {
display: inline-flex; align-items: center; gap: .38rem;
padding: .32rem .75rem;
border: 1px solid var(--border-2); border-radius: 99px;
background: rgba(255,255,255,.03);
font-family: 'Share Tech Mono', monospace; font-size: .68rem; letter-spacing: .05em;
color: var(--text-3); text-decoration: none;
transition: border-color .2s, color .2s, background .2s; min-height: 32px;
}
.social-list a:hover { color: var(--text-2); background: rgba(255,255,255,.07); border-color: var(--green); }
.social-list a svg { flex-shrink: 0; }
/* ── Footer ──────────────────────────────────────────────────────────────── */
.page-footer {
text-align: center;
font-family: 'Share Tech Mono', monospace;
font-size: .62rem; letter-spacing: .08em; color: var(--text-3); padding-bottom: .5rem;
}
.page-footer::before { content: '// '; opacity: .4; }
.highlight-name {
display: inline-block; padding: .15em .5em;
background: rgba(176,96,255,.12); border: 1px solid rgba(176,96,255,.22);
border-radius: 4px; color: var(--purple);
text-shadow: 0 0 12px rgba(176,96,255,.4);
font-weight: 700; font-size: .9em; letter-spacing: .01em;
}
/* ── Update item: UID badge + status link ────────────────────────────── */
.update-uid {
display: inline-flex; align-items: center;
font-family: 'Share Tech Mono', monospace;
font-size: .58rem; letter-spacing: .08em;
color: var(--green); text-decoration: none;
background: rgba(0,232,122,.07);
border: 1px solid rgba(0,232,122,.2);
border-radius: 3px;
padding: .08em .45em;
transition: background .15s, border-color .15s;
flex-shrink: 0;
}
.update-uid:hover {
background: rgba(0,232,122,.14);
border-color: rgba(0,232,122,.4);
}
.update-status-link {
display: inline-flex; align-items: center; gap: .25rem;
font-family: 'Share Tech Mono', monospace;
font-size: .6rem; letter-spacing: .05em;
color: var(--text-3); text-decoration: none;
transition: color .15s;
margin-left: auto;
flex-shrink: 0;
}
.update-status-link:hover { color: var(--green); }
.update-status-link::after { content: ' →'; }
.widget-title-row {
display: flex; align-items: center; gap: .55rem; flex: 1;
}
.widget-view-all {
font-family: 'Share Tech Mono', monospace;
font-size: .58rem; letter-spacing: .08em; text-transform: uppercase;
color: var(--text-3); text-decoration: none;
padding: .15rem .5rem;
border: 1px solid var(--border-2);
border-radius: 3px;
transition: color .15s, border-color .15s;
white-space: nowrap;
}
.widget-view-all:hover { color: var(--green); border-color: rgba(0,232,122,.35); }
</style>
</head>
<body>
<?php if (BG_VIDEO_MP4 !== ''): ?>
<div class="bg-video-wrap" aria-hidden="true">
<video autoplay muted loop playsinline poster="<?= htmlspecialchars(BG_VIDEO_POSTER) ?>">
<source src="<?= htmlspecialchars(BG_VIDEO_MP4) ?>" type="video/mp4">
</video>
</div>
<?php endif; ?>
<main class="page" role="main">
<!-- ══════════════════════════════════════════════════════════════════════
HERO
═══════════════════════════════════════════════════════════════════════ -->
<header class="hero">
<div class="profile-img-wrap">
<img src="<?= htmlspecialchars(PROFILE_IMG) ?>"
alt="<?= htmlspecialchars(PROFILE_IMG_ALT) ?>"
width="108" height="108" loading="eager">
</div>
<div>
<p class="hero-eyebrow">tyclifford.com</p>
<h1 class="hero-h1">
Hi, I'm a dude <span class="dude-line">living in</span>
</h1>
<div class="typed-wrap" aria-live="polite" aria-label="Location cycling text">
<span class="typed-word" id="typed-word"><?= htmlspecialchars($typed[0][0]) ?></span><span class="typed-cursor" aria-hidden="true"></span>
</div>
<p class="hero-tagline"><?= htmlspecialchars(SITE_TAGLINE) ?></p>
</div>
<?php if (CALENDLY_URL !== '' || SCHEDULE_URL !== ''): ?>
<div class="hero-cta">
<?php if (CALENDLY_URL !== ''): ?>
<a class="btn-cta" href=""
onclick="Calendly.initPopupWidget({url:'<?= htmlspecialchars(CALENDLY_URL) ?>'});return false;"
aria-label="Apply to be a guest">🎤 Guest</a>
<?php endif; ?>
<?php if (SCHEDULE_URL !== ''): ?>
<a class="btn-cta" href="<?= htmlspecialchars(SCHEDULE_URL) ?>">📅 Schedule</a>
<?php endif; ?>
</div>
<?php endif; ?>
</header>
<!-- ══════════════════════════════════════════════════════════════════════
HORIZONTAL NAV PILLS
═══════════════════════════════════════════════════════════════════════ -->
<nav class="nav-pill-bar" aria-label="Site navigation">
<ul class="nav-pill-list">
<?php foreach ($navs as [$label, $url, $col, $emoji]): ?>
<li>
<a href="<?= htmlspecialchars($url) ?>"
<?= str_starts_with($url, 'http') ? 'target="_blank" rel="noopener noreferrer"' : '' ?>>
<span class="pill-dot <?= htmlspecialchars($col) ?>" aria-hidden="true"></span>
<span class="<?= htmlspecialchars($col) ?>">
<?= $emoji !== '' ? htmlspecialchars($emoji) . '&thinsp;' : '' ?><?= htmlspecialchars($label) ?>
</span>
</a>
</li>
<?php endforeach; ?>
</ul>
</nav>
<!-- ══════════════════════════════════════════════════════════════════════
WIDGET GRID
═══════════════════════════════════════════════════════════════════════ -->
<div class="widget-grid">
<!-- ── UPDATES ──────────────────────────────────────────────────────── -->
<div class="widget widget-updates" aria-label="Latest updates" id="updates-widget">
<div class="widget-header">
<div class="widget-header-left">
<span class="live-dot" id="updates-live-dot" title="Auto-refreshing"></span>
<div class="widget-title-row">
<span class="widget-title">Updates</span>
<a class="widget-view-all" href="/status/"
title="View all updates with full permalinks">View all</a>
</div>
</div>
<div class="widget-header-right">
<span class="poll-spinner" id="poll-spinner" aria-hidden="true"></span>
<span class="last-checked" id="last-checked" aria-live="polite"></span>
<span class="widget-badge" id="updates-count-badge">
<?= count($all_updates) ?> post<?= count($all_updates) !== 1 ? 's' : '' ?>
</span>
</div>
</div>
<div class="widget-body">
<!-- "New posts available" banner — shown by JS when refresh finds new items -->
<div class="new-posts-banner" id="new-posts-banner"
role="status" aria-live="polite"
onclick="Updates.applyPending()">
<span class="new-posts-banner-text" id="new-posts-text">New updates available</span>
<span class="new-posts-banner-btn">↑ Show</span>
</div>
<!-- Feed list — JS owns this after first render -->
<ul class="updates-feed" id="updates-feed" aria-label="Updates feed">
<?php if (empty($updates_page1)): ?>
<li><p class="widget-empty">no updates yet</p></li>
<?php else: ?>
<?php foreach ($updates_page1 as $u): ?>
<li class="update-item" data-id="<?= htmlspecialchars($u['id'] ?? '') ?>"
data-date="<?= htmlspecialchars($u['date'] ?? '') ?>">
<div class="update-meta">
<?php if (!empty($u['id'])): ?>
<a class="update-uid"
href="/status/post.php?id=<?= rawurlencode($u['id']) ?>"
title="Permalink to update #<?= htmlspecialchars($u['id']) ?>">#<?= htmlspecialchars($u['id']) ?></a>
<?php endif; ?>
<span class="update-time"><?= htmlspecialchars(relative_time($u['date'] ?? '')) ?></span>
<?php foreach ((array)($u['tags'] ?? []) as $tag): ?>
<span class="update-tag"><?= htmlspecialchars($tag) ?></span>
<?php endforeach; ?>
<?php if (!empty($u['id'])): ?>
<a class="update-status-link"
href="/status/post.php?id=<?= rawurlencode($u['id']) ?>">View post</a>
<?php endif; ?>
</div>
<p class="update-text"><?= htmlspecialchars($u['text'] ?? '') ?></p>
<?php if (!empty($u['link'])): ?>
<a class="update-link"
href="<?= htmlspecialchars($u['link']) ?>"
<?= str_starts_with($u['link'], 'http') ? 'target="_blank" rel="noopener noreferrer"' : '' ?>>
<?= htmlspecialchars($u['link_label'] ?? $u['link']) ?>
</a>
<?php endif; ?>
</li>
<?php endforeach; ?>
<?php endif; ?>
</ul>
<!-- Pagination — built by JS -->
<div id="updates-pagination"></div>
</div>
</div><!-- /widget-updates -->
<!-- ── SUPPORT ───────────────────────────────────────────────────────── -->
<div class="widget widget-support" aria-label="Support and links">
<div class="widget-header">
<span class="widget-title">Support</span>
</div>
<div class="widget-body">
<div class="donate-stack">
<?php
$donate_icons = [
'patreon' => '<svg width="12" height="14" viewBox="0 0 12 14" fill="white" aria-hidden="true"><ellipse cx="7.5" cy="4.8" rx="4.5" ry="4.5"/><rect x="0" y="0" width="2.8" height="14" rx="1.2"/></svg>',
'liberapay' => '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>',
'paypal' => '<svg width="13" height="15" viewBox="0 0 24 28" fill="white" aria-hidden="true"><path d="M7.8 27H3.5L6.3 9.5h7.6c3.1 0 5.3.7 6.4 2 1.1 1.3 1.3 3 .5 5.1-.9 2.4-2.4 4.2-4.5 5.3-2 1-4.5 1.5-7.3 1.5H7l-.8 3.6zm1.1-6.5h1.6c1.5 0 2.8-.3 3.8-.9 1-.6 1.7-1.5 2.1-2.7.4-1.2.2-2-.5-2.6-.7-.5-1.9-.8-3.6-.8H10L8.9 20.5z"/></svg>',
'generic' => '',
];
$donate_cls = [
'patreon' => 'btn-patreon-donate',
'liberapay' => 'btn-liberapay',
'paypal' => 'btn-paypal',
'generic' => 'btn-generic',
];
foreach ($donates as [$label, $url, $style]):
$cls = $donate_cls[$style] ?? 'btn-generic';
$icon = $donate_icons[$style] ?? '';
?>
<a class="btn-donate <?= $cls ?>"
href="<?= htmlspecialchars($url) ?>"
target="_blank" rel="noopener noreferrer"
aria-label="<?= htmlspecialchars($label) ?>">
<?= $icon ?>
<?= htmlspecialchars($label) ?>
</a>
<?php endforeach; ?>
</div>
<div class="support-divider">Find me</div>
<ul class="social-list" aria-label="Social and contact links">
<?php foreach ($socials as [$label, $url, $icon_key]):
$svg = $svg_icons[$icon_key] ?? $svg_icons['web'];
?>
<li>
<a href="<?= htmlspecialchars($url) ?>"
<?= str_starts_with($url, 'http') ? 'target="_blank" rel="noopener noreferrer"' : '' ?>
aria-label="<?= htmlspecialchars($label) ?>">
<?= $svg ?>
<?= htmlspecialchars($label) ?>
</a>
</li>
<?php endforeach; ?>
</ul>
</div>
</div><!-- /widget-support -->
</div><!-- /widget-grid -->
<footer class="page-footer" role="contentinfo">
<span class="highlight-name"><?= htmlspecialchars(SITE_NAME) ?></span>
&nbsp;·&nbsp; <?= htmlspecialchars(SITE_TAGLINE) ?>
</footer>
</main>
<script src="https://bin.tyclifford.com/tracker/tracker.php?js=1"></script>
<?php if (CALENDLY_URL !== ''): ?>
<script src="https://assets.calendly.com/assets/external/widget.js" async></script>
<?php endif; ?>
<script>
/* ── Typed word cycler ──────────────────────────────────────────────────────── */
(function () {
var strings = <?= json_encode(array_map(fn($s) => ['text' => $s[0], 'cls' => $s[1]], $typed)) ?>;
var el = document.getElementById('typed-word');
var idx = 0, HOLD = 2400, FADE = 300;
function next() {
idx = (idx + 1) % strings.length;
var e = strings[idx];
el.style.transition = 'opacity '+FADE+'ms ease,transform '+FADE+'ms ease';
el.style.opacity = '0'; el.style.transform = 'translateY(-5px)';
setTimeout(function () {
el.textContent = e.text;
el.className = 'typed-word' + (e.cls ? ' ' + e.cls : '');
el.style.transform = 'translateY(6px)';
requestAnimationFrame(function () { requestAnimationFrame(function () {
el.style.opacity = '1'; el.style.transform = 'translateY(0)';
}); });
setTimeout(next, HOLD);
}, FADE);
}
setTimeout(next, HOLD);
})();
/* ════════════════════════════════════════════════════════════════════════════
UPDATES — auto-refresh, pagination, sound alert
════════════════════════════════════════════════════════════════════════════
Config values injected from PHP / config.php:
FEED_URL — endpoint that returns JSON
INTERVAL_MS — poll interval in milliseconds
PER_PAGE — items per page
SOUND_URL — audio file URL ('' = disabled)
════════════════════════════════════════════════════════════════════════════ */
var Updates = (function () {
'use strict';
/* ── Config (injected from PHP) ─────────────────────────────────────── */
var FEED_URL = <?= json_encode(UPDATES_FEED_URL) ?>;
var INTERVAL_MS = <?= max(5000, (int)UPDATES_REFRESH_INTERVAL * 1000) ?>;
var PER_PAGE = <?= max(1, (int)UPDATES_PER_PAGE) ?>;
var SOUND_URL = <?= json_encode(UPDATES_REFRESH_SOUND) ?>;
/* ── State ───────────────────────────────────────────────────────────── */
var allPosts = []; // full dataset from last successful poll
var pendingPosts = null; // non-null when new data is waiting for user action
var currentPage = 1;
var lastSeenIds = []; // IDs visible on page-load (seeded from PHP)
/* ── DOM refs ────────────────────────────────────────────────────────── */
var feedEl = document.getElementById('updates-feed');
var paginEl = document.getElementById('updates-pagination');
var dotEl = document.getElementById('updates-live-dot');
var spinnerEl = document.getElementById('poll-spinner');
var checkedEl = document.getElementById('last-checked');
var bannerEl = document.getElementById('new-posts-banner');
var bannerTextEl= document.getElementById('new-posts-text');
var badgeEl = document.getElementById('updates-count-badge');
/* ── Audio ───────────────────────────────────────────────────────────── */
var audioCtx = null;
var audioBuffer = null;
function loadSound() {
if (!SOUND_URL) return;
// Use Web Audio API for reliable autoplay without user gesture requirement
try {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
fetch(SOUND_URL)
.then(function (r) { return r.arrayBuffer(); })
.then(function (buf) { return audioCtx.decodeAudioData(buf); })
.then(function (decoded) { audioBuffer = decoded; })
.catch(function () { audioCtx = null; }); // graceful degradation
} catch(e) { audioCtx = null; }
}
function playSound() {
if (!audioCtx || !audioBuffer) return;
try {
var src = audioCtx.createBufferSource();
src.buffer = audioBuffer;
src.connect(audioCtx.destination);
src.start(0);
} catch(e) {}
}
/* ── Helpers ─────────────────────────────────────────────────────────── */
function esc(str) {
return String(str || '')
.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function relTime(iso) {
if (!iso) return '';
var diff = Math.floor((Date.now() - new Date(iso)) / 1000);
if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff/60) + 'm ago';
if (diff < 86400) return Math.floor(diff/3600) + 'h ago';
if (diff < 604800) return Math.floor(diff/86400) + 'd ago';
return new Date(iso).toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'});
}
function fmtChecked() {
var d = new Date();
return d.getHours().toString().padStart(2,'0') + ':'
+ d.getMinutes().toString().padStart(2,'0') + ':'
+ d.getSeconds().toString().padStart(2,'0');
}
function pluralPost(n) { return n + ' post' + (n !== 1 ? 's' : ''); }
/* ── Seed known IDs from PHP-rendered markup ─────────────────────────── */
function seedIds() {
var items = feedEl.querySelectorAll('.update-item[data-id]');
lastSeenIds = [];
items.forEach(function(li) {
var id = li.getAttribute('data-id');
if (id) lastSeenIds.push(id);
});
// Also populate allPosts from server immediately so pagination works
poll(true);
}
/* ── Render a page of posts ──────────────────────────────────────────── */
function renderPage(posts, page, isNew, newIds) {
newIds = newIds || [];
var total = posts.length;
var pages = Math.max(1, Math.ceil(total / PER_PAGE));
page = Math.min(page, pages);
var start = (page - 1) * PER_PAGE;
var slice = posts.slice(start, start + PER_PAGE);
var html = '';
if (!slice.length) {
html = '<li><p class="widget-empty">no updates yet</p></li>';
} else {
slice.forEach(function(u) {
var isNewItem = newIds.indexOf(String(u.id || '')) !== -1;
var tags = (u.tags || []).map(function(t) {
return '<span class="update-tag">' + esc(t) + '</span>';
}).join('');
var link = u.link
? '<a class="update-link" href="' + esc(u.link) + '"'
+ (/^https?:\/\//.test(u.link) ? ' target="_blank" rel="noopener noreferrer"' : '')
+ '>' + esc(u.link_label || u.link) + '</a>'
: '';
var uid = esc(String(u.id || ''));
var statusUrl = '/status/post.php?id=' + encodeURIComponent(String(u.id || ''));
var uidBadge = uid
? '<a class="update-uid" href="' + statusUrl + '" title="Permalink #' + uid + '">#' + uid + '</a>'
: '';
var statusLink = uid
? '<a class="update-status-link" href="' + statusUrl + '">View post</a>'
: '';
html +=
'<li class="update-item' + (isNewItem ? ' is-new' : '') + '"'
+ ' data-id="' + uid + '"'
+ ' data-date="' + esc(u.date || '') + '">'
+ '<div class="update-meta">'
+ uidBadge
+ '<span class="update-time">' + esc(relTime(u.date)) + '</span>'
+ tags
+ statusLink
+ '</div>'
+ '<p class="update-text">' + esc(u.text || '') + '</p>'
+ link
+ '</li>';
});
}
feedEl.innerHTML = html;
currentPage = page;
badgeEl.textContent = pluralPost(total);
renderPagination(total, page, pages);
}
/* ── Pagination controls ─────────────────────────────────────────────── */
function renderPagination(total, page, pages) {
if (pages <= 1) { paginEl.innerHTML = ''; return; }
var start = (page - 1) * PER_PAGE + 1;
var end = Math.min(page * PER_PAGE, total);
var btns = '';
btns += '<button class="page-btn"' + (page <= 1 ? ' disabled' : '')
+ ' onclick="Updates.goPage(' + (page-1) + ')"></button>';
// Show window of page numbers
var lo = Math.max(1, page - 2), hi = Math.min(pages, page + 2);
if (lo > 1) btns += '<button class="page-btn" onclick="Updates.goPage(1)">1</button>'
+ (lo > 2 ? '<span class="page-btn" style="cursor:default;opacity:.4">…</span>' : '');
for (var p = lo; p <= hi; p++) {
btns += '<button class="page-btn' + (p === page ? ' active' : '') + '"'
+ ' onclick="Updates.goPage(' + p + ')">' + p + '</button>';
}
if (hi < pages) btns += (hi < pages - 1 ? '<span class="page-btn" style="cursor:default;opacity:.4">…</span>' : '')
+ '<button class="page-btn" onclick="Updates.goPage(' + pages + ')">' + pages + '</button>';
btns += '<button class="page-btn"' + (page >= pages ? ' disabled' : '')
+ ' onclick="Updates.goPage(' + (page+1) + ')"></button>';
paginEl.innerHTML =
'<div class="pagination">'
+ '<span class="pagination-info">' + start + '' + end + ' of ' + total + '</span>'
+ '<div class="pagination-btns">' + btns + '</div>'
+ '</div>';
}
/* ── Poll the feed endpoint ──────────────────────────────────────────── */
function poll(silent) {
if (!silent) {
dotEl.classList.add('polling');
spinnerEl.classList.add('visible');
}
fetch(FEED_URL + '?_=' + Date.now())
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.ok || !Array.isArray(data.items)) return;
var fresh = data.items;
var freshIds = fresh.map(function(p) { return String(p.id || ''); });
var newIds = freshIds.filter(function(id) { return lastSeenIds.indexOf(id) === -1; });
if (!silent) {
dotEl.classList.remove('polling');
spinnerEl.classList.remove('visible');
checkedEl.textContent = 'checked ' + fmtChecked();
}
if (newIds.length > 0 && allPosts.length > 0) {
// New posts found — stash them, show banner, play sound
pendingPosts = fresh;
var n = newIds.length;
bannerTextEl.textContent = n + ' new update' + (n !== 1 ? 's' : '') + ' available';
bannerEl.classList.add('visible');
dotEl.classList.remove('polling');
dotEl.classList.add('new-content');
setTimeout(function() { dotEl.classList.remove('new-content'); }, 3100);
playSound();
} else {
// No new posts — silently update dataset (timestamps refresh, etc.)
allPosts = fresh;
if (allPosts.length) renderPage(allPosts, currentPage, false, []);
lastSeenIds = freshIds;
}
})
.catch(function() {
if (!silent) {
dotEl.classList.remove('polling');
spinnerEl.classList.remove('visible');
}
});
}
/* ── Apply pending posts (user clicked banner) ───────────────────────── */
function applyPending() {
if (!pendingPosts) return;
var fresh = pendingPosts;
var freshIds = fresh.map(function(p) { return String(p.id || ''); });
var newIds = freshIds.filter(function(id) { return lastSeenIds.indexOf(id) === -1; });
allPosts = fresh;
lastSeenIds = freshIds;
pendingPosts = null;
bannerEl.classList.remove('visible');
currentPage = 1; // jump to top to show new posts
renderPage(allPosts, 1, true, newIds);
}
/* ── Init ────────────────────────────────────────────────────────────── */
function init() {
loadSound();
seedIds(); // seeds lastSeenIds + fires first poll to get full dataset
setInterval(function() { poll(false); }, INTERVAL_MS);
}
/* ── Public API ──────────────────────────────────────────────────────── */
return {
init: init,
goPage: function(p) { if (allPosts.length) renderPage(allPosts, p, false, []); },
applyPending: applyPending,
};
})();
// Boot after DOM ready
document.addEventListener('DOMContentLoaded', function() { Updates.init(); });
</script>
</body>
</html>