chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// The club feed page. Server-rendered, self-contained: no external requests,
// system fonts only, matching the rest of pds.js.
/**
* @param {string} s
*/
const esc = (s) =>
String(s)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
/** @param {number} meters */
const km = (meters) => (meters / 1000).toFixed(1);
/** @param {number} seconds */
function duration(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
const ms = `${String(m).padStart(h ? 2 : 1, '0')}:${String(s).padStart(2, '0')}`;
return h ? `${h}:${ms}` : ms;
}
/** @param {number} meters @param {number} seconds */
function pace(meters, seconds) {
const secPerKm = seconds / (meters / 1000);
const m = Math.floor(secPerKm / 60);
const s = Math.round(secPerKm % 60);
return `${m}:${String(s).padStart(2, '0')}/km`;
}
/** @param {string} iso */
function dayLabel(iso) {
return new Date(iso).toLocaleDateString('en-US', { weekday: 'short' });
}
/**
* @param {Object} feed
* @param {string} feed.clubName
* @param {string} feed.space
* @param {Array<{did: string, handle: string, rev: string, hashHex: string, runs: Array<{startedAt: string, distanceMeters: number, durationSec: number, note: string}>}>} feed.members
* @returns {string}
*/
export function renderFeedPage({ clubName, space, members }) {
const totalMeters = members
.flatMap((m) => m.runs)
.reduce((sum, r) => sum + r.distanceMeters, 0);
const maxMeters = Math.max(
...members.map((m) => m.runs.reduce((sum, r) => sum + r.distanceMeters, 0)),
1,
);
// The lane board: each member is a track lane, bar length is their share of
// the week's distance. Sorted like a results board, most distance first.
const lanes = members
.map((m) => ({
...m,
meters: m.runs.reduce((sum, r) => sum + r.distanceMeters, 0),
}))
.sort((a, b) => b.meters - a.meters)
.map(
(m, i) => `<div class="lane">
<span class="lane-no">${i + 1}</span>
<span class="lane-name">${esc(m.handle)}</span>
<span class="lane-track"><span class="lane-bar" style="--w:${((m.meters / maxMeters) * 100).toFixed(1)}%"></span></span>
<span class="lane-km">${km(m.meters)} km</span>
</div>`,
)
.join('');
const runs = members
.flatMap((m) => m.runs.map((r) => ({ ...r, handle: m.handle })))
.sort((a, b) => b.startedAt.localeCompare(a.startedAt))
.map(
(r) => `<div class="run">
<span class="run-day">${dayLabel(r.startedAt)}</span>
<span class="run-who">${esc(r.handle)}</span>
<span class="run-note">${esc(r.note)}</span>
<span class="run-num">${km(r.distanceMeters)} km</span>
<span class="run-num">${duration(r.durationSec)}</span>
<span class="run-num run-pace">${pace(r.distanceMeters, r.durationSec)}</span>
</div>`,
)
.join('');
// The receipts: this feed was assembled by verifying each member's commit
// against its MAC, so show what was checked. This is the point of the
// example.
const receipts = members
.map(
(m) => `<div class="receipt">
<span class="receipt-check">✓</span>
<span>${esc(m.handle)}</span>
<span class="receipt-mono">${esc(m.did)}</span>
<span class="receipt-mono">rev ${esc(m.rev)}</span>
<span class="receipt-mono">hash ${esc(m.hashHex.slice(0, 12))}…</span>
</div>`,
)
.join('');
return `<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(clubName)}</title>
<style>
:root {
--asphalt: #101014;
--infield: #17171c;
--track: #b8412a;
--track-bright: #d4552f;
--lane-line: #e8e4dc;
--cinder: #8b8b93;
--rule: #26262c;
}
* { box-sizing: border-box; margin: 0 }
body {
background: var(--asphalt);
color: var(--lane-line);
font-family: system-ui, sans-serif;
max-width: 640px;
margin: 0 auto;
padding: 48px 24px 64px;
}
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace }
.eyebrow {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
color: var(--cinder);
letter-spacing: 0.04em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
h1 {
font-size: 40px;
font-weight: 800;
letter-spacing: -0.02em;
margin: 6px 0 2px;
}
.week {
color: var(--cinder);
font-size: 15px;
}
.week b { color: var(--lane-line); font-weight: 600 }
section { margin-top: 36px }
.section-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--cinder);
padding-bottom: 8px;
border-bottom: 1px solid var(--rule);
}
/* The lane board */
.lane {
display: grid;
grid-template-columns: 24px 72px 1fr 72px;
gap: 12px;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--rule);
}
.lane-no {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
color: var(--cinder);
text-align: center;
}
.lane-name { font-weight: 600; font-size: 15px }
.lane-track {
position: relative;
height: 14px;
border-top: 1px solid var(--rule);
border-bottom: 1px solid var(--rule);
}
.lane-bar {
position: absolute;
inset: 2px auto 2px 0;
width: var(--w);
background: linear-gradient(90deg, var(--track), var(--track-bright));
border-radius: 999px;
animation: stagger 0.7s cubic-bezier(0.2, 0.8, 0.2, 1) backwards;
}
.lane:nth-child(3) .lane-bar { animation-delay: 0.12s }
.lane:nth-child(4) .lane-bar { animation-delay: 0.24s }
@keyframes stagger { from { width: 0 } }
@media (prefers-reduced-motion: reduce) { .lane-bar { animation: none } }
.lane-km {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
text-align: right;
font-variant-numeric: tabular-nums;
}
/* The run feed */
.run {
display: grid;
grid-template-columns: 34px 64px 1fr 58px 52px 72px;
gap: 10px;
align-items: baseline;
padding: 11px 0;
border-bottom: 1px solid var(--rule);
font-size: 14px;
}
.run-day { color: var(--cinder); font-size: 12px }
.run-who { font-weight: 600 }
.run-note {
color: var(--cinder);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.run-num {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.run-pace { color: var(--track-bright) }
/* The receipts */
.receipts-note { color: var(--cinder); font-size: 13px; margin: 10px 0 2px }
.receipt {
display: flex;
gap: 10px;
align-items: baseline;
padding: 7px 0;
font-size: 12px;
color: var(--cinder);
border-bottom: 1px solid var(--rule);
}
.receipt-check { color: var(--track-bright) }
.receipt-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 480px) {
h1 { font-size: 32px }
.run { grid-template-columns: 30px 56px 1fr 60px; }
.run-note, .run-num:nth-of-type(5) { display: none }
}
</style></head>
<body>
<header>
<div class="eyebrow">${esc(space)}</div>
<h1>${esc(clubName)}</h1>
<p class="week">This week the club ran <b>${km(totalMeters)} km</b> together.</p>
</header>
<section aria-label="Weekly distance by member">
<div class="section-label">Lanes · this week</div>
${lanes}
</section>
<section aria-label="Recent runs">
<div class="section-label">Runs</div>
${runs}
</section>
<section aria-label="Sync verification">
<div class="section-label">Synced & verified</div>
<p class="receipts-note">Each member's repo was pulled from their own PDS and
verified before rendering: the commit's MAC, and the records against its
set hash.</p>
${receipts}
</section>
</body></html>`;
}