~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
278 lines · 7.7 KB Raw History
// 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, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');

/** @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 &amp; 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>`;
}