mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
feat(landing): show animated live demo stats counters
Add a "Live from the demo instance" section to the landing page that fetches feeds_created and emails_received from the demo /api/stats and counts them up on scroll into view. Make /api/stats publicly readable (CORS *) and refresh the stale allowlist origins to kill-the.news. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -446,6 +446,35 @@
|
|||||||
.demo-note { text-align: left; }
|
.demo-note { text-align: left; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Live stats ── */
|
||||||
|
.stats-section { padding: 4rem 2rem; }
|
||||||
|
.stats-inner { max-width: 1100px; margin: 0 auto; text-align: center; }
|
||||||
|
.stats-live {
|
||||||
|
display: inline-flex; align-items: center; gap: 0.5rem;
|
||||||
|
font-size: 0.78rem; color: var(--muted);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.stats-dot {
|
||||||
|
width: 8px; height: 8px; border-radius: 50%; background: var(--accent);
|
||||||
|
animation: stats-pulse 2s infinite;
|
||||||
|
}
|
||||||
|
@keyframes stats-pulse {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(246,130,31,0.5); }
|
||||||
|
70% { box-shadow: 0 0 0 8px rgba(246,130,31,0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(246,130,31,0); }
|
||||||
|
}
|
||||||
|
.stats-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 2rem; max-width: 600px; margin: 0 auto;
|
||||||
|
}
|
||||||
|
.stat-num {
|
||||||
|
font-size: clamp(2.5rem, 6vw, 4rem); font-weight: 700;
|
||||||
|
color: var(--accent); letter-spacing: -0.03em;
|
||||||
|
font-variant-numeric: tabular-nums; line-height: 1;
|
||||||
|
}
|
||||||
|
.stat-label { font-size: 0.9rem; color: var(--muted); margin-top: 0.6rem; }
|
||||||
|
@media (max-width: 600px) { .stats-grid { gap: 1.5rem; } }
|
||||||
|
|
||||||
/* ── Nav links ── */
|
/* ── Nav links ── */
|
||||||
nav-links {
|
nav-links {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -661,6 +690,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Live stats (demo instance) -->
|
||||||
|
<section id="stats" class="stats-section" hidden>
|
||||||
|
<div class="stats-inner">
|
||||||
|
<div class="stats-live"><span class="stats-dot"></span> Live from the demo instance</div>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-num" data-stat="feeds_created">0</div>
|
||||||
|
<div class="stat-label">Feeds created</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-num" data-stat="emails_received">0</div>
|
||||||
|
<div class="stat-label">Emails received</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Features -->
|
<!-- Features -->
|
||||||
<section id="features">
|
<section id="features">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
@@ -1064,5 +1110,41 @@ bucket_name = "kill-the-news-attachments"</span></pre>
|
|||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(async function () {
|
||||||
|
const section = document.getElementById('stats');
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
const res = await fetch('https://demo.kill-the.news/api/stats', { cache: 'no-store' });
|
||||||
|
if (!res.ok) return; // section stays hidden
|
||||||
|
data = await res.json();
|
||||||
|
} catch { return; }
|
||||||
|
|
||||||
|
const nums = section.querySelectorAll('.stat-num');
|
||||||
|
nums.forEach(el => { el.dataset.value = data[el.dataset.stat] ?? 0; });
|
||||||
|
section.hidden = false;
|
||||||
|
|
||||||
|
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
const fmt = n => n.toLocaleString('en-US');
|
||||||
|
const ease = t => 1 - Math.pow(1 - t, 3);
|
||||||
|
|
||||||
|
function animate(el) {
|
||||||
|
const target = Number(el.dataset.value) || 0;
|
||||||
|
if (reduce || target === 0) { el.textContent = fmt(target); return; }
|
||||||
|
const dur = 1400, start = performance.now();
|
||||||
|
(function step(now) {
|
||||||
|
const t = Math.min((now - start) / dur, 1);
|
||||||
|
el.textContent = fmt(Math.round(ease(t) * target));
|
||||||
|
if (t < 1) requestAnimationFrame(step);
|
||||||
|
})(performance.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
const io = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach(e => { if (e.isIntersecting) { animate(e.target); io.unobserve(e.target); } });
|
||||||
|
}, { threshold: 0.4 });
|
||||||
|
nums.forEach(el => io.observe(el));
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+13
-4
@@ -12,11 +12,11 @@ function req(path: string, init: RequestInit = {}): Request {
|
|||||||
describe("CORS middleware", () => {
|
describe("CORS middleware", () => {
|
||||||
it("adds CORS headers for an allowed origin", async () => {
|
it("adds CORS headers for an allowed origin", async () => {
|
||||||
const res = await worker.fetch(
|
const res = await worker.fetch(
|
||||||
req("/rss/some-feed", { headers: { Origin: "https://getmynews.app" } }),
|
req("/rss/some-feed", { headers: { Origin: "https://kill-the.news" } }),
|
||||||
env as unknown as Env,
|
env as unknown as Env,
|
||||||
);
|
);
|
||||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBe(
|
expect(res.headers.get("Access-Control-Allow-Origin")).toBe(
|
||||||
"https://getmynews.app",
|
"https://kill-the.news",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ describe("CORS middleware", () => {
|
|||||||
req("/rss/some-feed", {
|
req("/rss/some-feed", {
|
||||||
method: "OPTIONS",
|
method: "OPTIONS",
|
||||||
headers: {
|
headers: {
|
||||||
Origin: "https://getmynews.app",
|
Origin: "https://kill-the.news",
|
||||||
"Access-Control-Request-Method": "GET",
|
"Access-Control-Request-Method": "GET",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -41,7 +41,16 @@ describe("CORS middleware", () => {
|
|||||||
);
|
);
|
||||||
expect(res.status).toBe(204);
|
expect(res.status).toBe(204);
|
||||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBe(
|
expect(res.headers.get("Access-Control-Allow-Origin")).toBe(
|
||||||
"https://getmynews.app",
|
"https://kill-the.news",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("makes /api/stats readable from any origin", async () => {
|
||||||
|
const res = await worker.fetch(
|
||||||
|
req("/api/stats", { headers: { Origin: "https://example.com" } }),
|
||||||
|
env as unknown as Env,
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+3
-2
@@ -22,7 +22,7 @@ import { FORWARD_EMAIL_IPS_CACHE_TTL_MS } from "./config/constants";
|
|||||||
|
|
||||||
type AppEnv = { Bindings: Env };
|
type AppEnv = { Bindings: Env };
|
||||||
|
|
||||||
const ALLOWED_ORIGINS = ["https://getmynews.app", "https://www.getmynews.app"];
|
const ALLOWED_ORIGINS = ["https://kill-the.news", "https://www.kill-the.news"];
|
||||||
|
|
||||||
// Fallback ForwardEmail.net IP addresses in case API fetch fails
|
// Fallback ForwardEmail.net IP addresses in case API fetch fails
|
||||||
const FALLBACK_FORWARD_EMAIL_IPS = [
|
const FALLBACK_FORWARD_EMAIL_IPS = [
|
||||||
@@ -140,7 +140,8 @@ api.use("/inbound", async (c, next) => {
|
|||||||
// API routes (inbound webhook)
|
// API routes (inbound webhook)
|
||||||
api.post("/inbound", handleInbound);
|
api.post("/inbound", handleInbound);
|
||||||
|
|
||||||
// Public monitoring stats (JSON)
|
// Public monitoring stats (JSON) — readable from any origin (landing page, embeds)
|
||||||
|
api.use("/stats", cors({ origin: "*" }));
|
||||||
api.get("/stats", handleStats);
|
api.get("/stats", handleStats);
|
||||||
|
|
||||||
// RSS feed routes (public)
|
// RSS feed routes (public)
|
||||||
|
|||||||
Reference in New Issue
Block a user