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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
776 lines · 29.5 KB Raw History
// A photo gallery app: Grain records rendered live from whichever repo
// serves it. Deploy it to your own PDS with PDS_URL, PDS_DID and
// PDS_APP_PASSWORD set; see docs/apps.md. Gallery data
// comes from dev.pdsjs.query.run at page load; images come from the site's
// /.blobs/<cid> route. The site record carries one file, so a new Grain
// gallery appears without a redeploy.
//
// Usage: PDS_APP_PASSWORD=... node build-photos-site.mjs

const PDS = process.env.PDS_URL;
const DID = process.env.PDS_DID;
if (!PDS || !DID) throw new Error('Set PDS_URL and PDS_DID.');
const SITE = 'photos-app';
const SITES = 'dev.pdsjs.site.deploy';
const DEFS = 'dev.pdsjs.query.def';

async function xrpc(path, init = {}) {
  const response = await fetch(`${PDS}/xrpc/${path}`, init);
  if (!response.ok) {
    throw new Error(
      `${path.split('?')[0]} ${response.status}: ${(await response.text()).slice(0, 200)}`,
    );
  }
  return response.json();
}

const password = process.env.PDS_APP_PASSWORD;
if (!password) throw new Error('Set PDS_APP_PASSWORD.');
const session = await xrpc('com.atproto.server.createSession', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ identifier: DID, password }),
});
const auth = { Authorization: `Bearer ${session.accessJwt}` };

/** @param {string} collection @param {string} rkey @param {Record<string, unknown>} record */
async function put(collection, rkey, record) {
  await xrpc('com.atproto.repo.putRecord', {
    method: 'POST',
    headers: { ...auth, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      repo: DID,
      collection,
      rkey,
      record: { $type: collection, ...record },
    }),
  });
  console.error(`put ${collection}/${rkey}`);
}

// ---- the queries the app reads ----

const now = new Date().toISOString();
await put(DEFS, 'grain-galleries', {
  collection: 'social.grain.gallery',
  sort: { field: 'createdAt', direction: 'desc' },
  createdAt: now,
});
await put(DEFS, 'grain-gallery-items', {
  collection: 'social.grain.gallery.item',
  createdAt: now,
});
await put(DEFS, 'grain-photos', {
  collection: 'social.grain.photo',
  createdAt: now,
});

// ---- the app ----

const HTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Photos</title>
<meta name="description" content="Photo galleries from an atproto repo.">
<style>
:root{color-scheme:dark}
*{box-sizing:border-box}
body{margin:0;background:#101014;color:#e8e8ea;font-family:system-ui,-apple-system,sans-serif;line-height:1.5}
a{color:inherit;text-decoration:none}
.wrap{max-width:1160px;margin:0 auto;padding:0 20px 64px;position:relative}
.topo{position:absolute;top:0;left:50%;transform:translateX(-50%);width:100vw;height:320px;overflow:hidden;z-index:-1;opacity:.22;pointer-events:none;mask-image:radial-gradient(130% 105% at 50% 0%,#000 25%,transparent 78%);-webkit-mask-image:radial-gradient(130% 105% at 50% 0%,#000 25%,transparent 78%)}
.topo img{position:absolute;width:256px;height:256px;filter:invert(1) grayscale(1) brightness(1.1) contrast(1.05)}
header.site{padding:44px 0 28px}
header.site h1{margin:0;font-size:26px;letter-spacing:-.01em}
header.site p{margin:6px 0 0;color:#9a9aa2;font-size:14px}
.cover{position:relative;display:block;overflow:hidden}
.cover img{width:100%;height:100%;object-fit:cover;background:#1a1a20;display:block}
.cover .label{position:absolute;left:0;right:0;bottom:0;padding:34px 12px 10px;background:linear-gradient(transparent,rgba(8,8,11,.78))}
.cover h2{margin:0;font-size:14px;font-weight:550}
.cover p{margin:1px 0 0;color:#c9c9cf;font-size:12px}
nav.back{padding:32px 0 8px;font-size:14px;color:#9a9aa2}
nav.back a:hover{color:#e8e8ea}
header.gallery{padding:8px 0 24px}
header.gallery h1{margin:0;font-size:24px;letter-spacing:-.01em}
header.gallery p.meta{margin:6px 0 0;color:#9a9aa2;font-size:14px}
header.gallery p.desc{margin:10px 0 0;max-width:640px;color:#c6c6cc;font-size:15px;white-space:pre-line}
/* Justified rows, computed: photos pack left to right into rows sized so
every image in a row shares one height at its true aspect ratio. */
.row{display:flex;gap:12px;margin-bottom:12px}
.row a{display:block}
.row img{width:100%;height:100%;object-fit:cover;background:#1a1a20;display:block}
.lightbox{position:fixed;inset:0;background:rgba(8,8,11,.96);display:flex;align-items:center;justify-content:center;z-index:10}
.lightbox img{max-width:94vw;max-height:92vh;width:auto;height:auto}
.lightbox button{position:fixed;background:none;border:0;color:#c9c9cf;font:inherit;font-size:30px;line-height:1;cursor:pointer;padding:14px}
.lightbox button:hover{color:#fff}
.lightbox .close{top:8px;right:10px}
.lightbox .prev{left:4px;top:50%;transform:translateY(-50%)}
.lightbox .next{right:4px;top:50%;transform:translateY(-50%)}
.lightbox .count{position:fixed;top:22px;left:20px;color:#9a9aa2;font-size:13px}
p.loc{margin:6px 0 0;color:#9a9aa2;font-size:14px}
.social{display:flex;gap:16px;margin:10px 0 0;font-size:13px}
.social a{display:inline-flex;align-items:center;gap:6px;color:#9a9aa2}
.social a:hover{color:#e8e8ea}
.social svg{width:16px;height:16px}
.social .n{display:inline-block;min-width:16px;text-align:left}
.social .skel{display:inline-block;width:16px;height:11px;border-radius:3px;background:#26262c;animation:pulse 1.2s ease-in-out infinite}
@keyframes pulse{50%{opacity:.4}}
.maproom{height:72vh;min-height:420px;background:#1a1a20;margin-top:8px}
.leaflet-popup-content-wrapper,.leaflet-popup-tip{background:#1a1a20;color:#e8e8ea;border-radius:0}
.leaflet-popup-content{margin:10px}
.leaflet-popup-content a{display:block}
.leaflet-popup-content img{display:block;width:180px;height:110px;object-fit:cover;margin-bottom:6px}
.leaflet-popup-content .t{font-size:13px;font-weight:550}
.leaflet-popup-content .m{font-size:12px;color:#9a9aa2}
footer{margin-top:56px;color:#6e6e76;font-size:13px}
footer a{color:#9a9aa2}
p.state{color:#9a9aa2;padding:24px 0}
</style>
</head>
<body>
<div class="wrap" id="app"><noscript>This page renders with JavaScript.</noscript><p class="state" id="loading">Loading galleries…</p></div>
<script>
// The app is generic: it discovers whose repo it serves from its own
// origin, and reads its settings from the host's install record. Nothing
// about any particular account is baked in.
let PDS = null;
let DID = null;
let HANDLE = null;
let CONFIG = { topo: true, map: true, social: true, rows: 'roomy' };
let GALLERIES = null;

async function discoverHost() {
  DID = (await (await fetch('/.well-known/atproto-did')).text()).trim();
  const override = new URLSearchParams(location.search).get('pds');
  if (override) {
    PDS = override.endsWith('/') ? override.slice(0, -1) : override;
  } else {
    const doc = await (await fetch('https://plc.directory/' + DID)).json();
    const service = (doc.service || []).find(
      (s) => s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer',
    );
    PDS = service.serviceEndpoint;
  }
  const repo = await (await fetch(PDS + '/xrpc/com.atproto.repo.describeRepo?repo=' + DID)).json();
  HANDLE = repo.handle || DID;

  // Settings chosen at install, from the public install record naming this
  // site. Absent (the developer's own instance, say), defaults stand.
  try {
    const label = location.hostname.split('.')[0];
    const installs = await (await fetch(PDS + '/xrpc/com.atproto.repo.listRecords?repo=' + DID + '&collection=dev.pdsjs.app.install&limit=100')).json();
    const mine = (installs.records || []).find((r) => r.value && r.value.site === label);
    if (mine && mine.value.config) CONFIG = Object.assign({}, CONFIG, mine.value.config);
  } catch {}
}

const el = (tag, attrs, children) => {
  const node = document.createElement(tag);
  for (const [k, v] of Object.entries(attrs || {})) {
    if (k === 'text') node.textContent = v;
    else node.setAttribute(k, v);
  }
  for (const child of children || []) node.appendChild(child);
  return node;
};

const fmtDate = (iso) =>
  new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });

const blobUrl = (photo) => '/.blobs/' + photo.photo.ref.$link;

// Cloudflare image transformations, when the zone has them enabled. One
// probe per session decides; without them every image is the raw blob, so
// the site needs nothing from the zone to work. Widths snap to three
// buckets to keep unique transformations far under the plan allowance.
let TRANSFORMS = false;
const BUCKETS = [400, 800, 1600];

async function detectTransforms(sampleCid) {
  const stored = sessionStorage.getItem('cf-transforms');
  if (stored !== null) {
    TRANSFORMS = stored === '1';
    return;
  }
  try {
    const probe = await fetch('/cdn-cgi/image/width=16,format=auto/.blobs/' + sampleCid);
    TRANSFORMS = probe.ok && (probe.headers.get('content-type') || '').startsWith('image/');
  } catch {
    TRANSFORMS = false;
  }
  sessionStorage.setItem('cf-transforms', TRANSFORMS ? '1' : '0');
}

function sizedUrl(photo, cssWidth) {
  if (!TRANSFORMS) return blobUrl(photo);
  const need = cssWidth * Math.min(window.devicePixelRatio || 1, 2);
  const bucket = BUCKETS.find((b) => b >= need) || BUCKETS[BUCKETS.length - 1];
  return '/cdn-cgi/image/width=' + bucket + ',quality=82,format=auto,fit=scale-down/.blobs/' + photo.photo.ref.$link;
}

function withFallback(img, photo) {
  img.addEventListener('error', () => {
    if (img.src.includes('/cdn-cgi/')) img.src = blobUrl(photo);
  });
  return img;
}

const footer = () => {
  const f = el('footer');
  f.append('Every image on this page is a blob in ');
  f.appendChild(el('a', { href: 'https://bsky.app/profile/' + HANDLE, text: HANDLE }));
  f.append("'s atproto repo, and the gallery list is queried live from it. Served by pds.js.");
  return f;
};

async function runQuery(name) {
  const response = await fetch(PDS + '/xrpc/dev.pdsjs.query.run?query=' + name + '&limit=2000');
  if (!response.ok) throw new Error(name + ' failed: ' + response.status);
  return (await response.json()).records;
}

async function load() {
  const [galleries, items, photos] = await Promise.all([
    runQuery('grain-galleries'),
    runQuery('grain-gallery-items'),
    runQuery('grain-photos'),
  ]);
  const photoByUri = new Map(photos.map((r) => [r.uri, r.value]));
  const itemsByGallery = new Map();
  for (const record of items) {
    const value = record.value;
    const photo = photoByUri.get(value.item);
    if (!photo || !photo.photo || !photo.photo.ref) continue;
    const list = itemsByGallery.get(value.gallery) || [];
    list.push({
      photo,
      position: typeof value.position === 'number' ? value.position : Number.MAX_SAFE_INTEGER,
      createdAt: value.createdAt || '',
    });
    itemsByGallery.set(value.gallery, list);
  }
  const firstPhoto = photos.find((r) => r.value.photo && r.value.photo.ref);
  if (firstPhoto) await detectTransforms(firstPhoto.value.photo.ref.$link);
  GALLERIES = galleries
    .map((record) => {
      const list = (itemsByGallery.get(record.uri) || []).sort(
        (a, b) => a.position - b.position || a.createdAt.localeCompare(b.createdAt),
      );
      return {
        rkey: record.uri.split('/').pop(),
        title: record.value.title || 'Untitled',
        description: record.value.description || '',
        createdAt: record.value.createdAt || '',
        location: record.value.location,
        address: record.value.address,
        photos: list.map((entry) => entry.photo),
      };
    })
    .filter((g) => g.photos.length > 0);
}

// Shared by the index and the gallery pages: pack items in order until a
// row, scaled to fill the container at one shared height, drops to the
// target; a final partial row keeps the target height instead of stretching.
function layoutJustified(container, items, ratioOf, target, build) {
  const GAP = 12;
  const width = container.clientWidth || 1120;
  let index = 0;
  let shown = 0;
  while (index < items.length) {
    const row = [];
    let sum = 0;
    let height = target;
    while (index < items.length) {
      row.push(items[index]);
      sum += ratioOf(items[index]);
      index += 1;
      height = (width - GAP * (row.length - 1)) / sum;
      if (height <= target) break;
    }
    height = Math.min(height, target);
    const rowEl = el('div', { class: 'row' });
    for (const item of row) {
      const cellWidth = ratioOf(item) * height;
      const cell = build(item, shown, cellWidth);
      cell.style.width = cellWidth.toFixed(2) + 'px';
      cell.style.height = height.toFixed(2) + 'px';
      rowEl.appendChild(cell);
      shown += 1;
    }
    container.appendChild(rowEl);
  }
}

const photoRatio = (photo) =>
  photo.aspectRatio && photo.aspectRatio.width && photo.aspectRatio.height
    ? photo.aspectRatio.width / photo.aspectRatio.height
    : 1.33;

// Faint terrain behind a located gallery's header: OpenTopoMap tiles for
// the H3 cell's surroundings, inverted onto the dark background and masked
// out toward the page. Built after the photos render; a failed tile just
// leaves the background plain.
async function topoBackdrop(container, gallery) {
  if (!gallery.location || !gallery.location.value) return;
  let lat, lng;
  try {
    const h3 = await import('https://esm.sh/h3-js@4.1.0');
    [lat, lng] = h3.cellToLatLng(gallery.location.value);
  } catch {
    return;
  }
  const Z = 12;
  const n = 2 ** Z;
  const rad = (lat * Math.PI) / 180;
  const fx = ((lng + 180) / 360) * n;
  const fy = ((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n;
  const width = window.innerWidth;
  const HEIGHT = 320;
  const ANCHOR_Y = 110;
  const topo = el('div', { class: 'topo', 'aria-hidden': 'true' });
  for (let dy = -1; dy <= 1; dy++) {
    for (let dx = -4; dx <= 4; dx++) {
      const tx = Math.floor(fx) + dx;
      const ty = Math.floor(fy) + dy;
      if (ty < 0 || ty >= n) continue;
      const left = (tx - fx) * 256 + width / 2;
      const top = (ty - fy) * 256 + ANCHOR_Y;
      if (left > width || left + 256 < 0 || top > HEIGHT || top + 256 < 0) continue;
      const sub = 'abc'[((tx % 3) + 3) % 3];
      const img = el('img', {
        src: 'https://' + sub + '.tile.opentopomap.org/' + Z + '/' + (((tx % n) + n) % n) + '/' + ty + '.png',
        alt: '',
        draggable: 'false',
      });
      img.style.left = left.toFixed(0) + 'px';
      img.style.top = top.toFixed(0) + 'px';
      topo.appendChild(img);
    }
  }
  container.prepend(topo);
}

const HEART = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.6l-1-1a5.5 5.5 0 0 0-7.8 7.8l1 1L12 21.2l7.8-7.8 1-1a5.5 5.5 0 0 0 0-7.8z"/></svg>';
const BUBBLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>';

// Favorite and comment counts from Constellation, microcosm.blue's backlink
// index over the whole network. The row renders at once with skeleton
// counts so the layout never shifts; numbers replace them in place, or a
// dash when the service is unreachable.
const SOCIAL_CACHE = new Map();

function socialRow(header, gallery) {
  const grainUrl = 'https://grain.social/profile/' + HANDLE + '/gallery/' + gallery.rkey;
  const known = SOCIAL_CACHE.get(gallery.rkey);
  const item = (svg, label, value) => {
    const slot =
      value === undefined
        ? el('span', { class: 'n' }, [el('span', { class: 'skel' })])
        : el('span', { class: 'n', text: String(value) });
    const link = el('a', { href: grainUrl, target: '_blank', rel: 'noopener', title: label + ' on Grain' }, [slot]);
    link.insertAdjacentHTML('afterbegin', svg);
    return { link, slot };
  };
  const favs = item(HEART, 'Favorites', known && known.favs);
  const comments = item(BUBBLE, 'Comments', known && known.comments);
  header.appendChild(el('div', { class: 'social' }, [favs.link, comments.link]));
  if (known) return;

  const target = encodeURIComponent('at://' + DID + '/social.grain.gallery/' + gallery.rkey);
  const count = (collection) =>
    fetch('https://constellation.microcosm.blue/links/count?target=' + target + '&collection=' + collection + '&path=.subject')
      .then((response) => response.json())
      .then((body) => body.total);
  Promise.all([count('social.grain.favorite'), count('social.grain.comment')])
    .then(([f, c]) => {
      SOCIAL_CACHE.set(gallery.rkey, { favs: f, comments: c });
      favs.slot.textContent = String(f);
      favs.link.title = f + ' favorites on Grain';
      comments.slot.textContent = String(c);
      comments.link.title = c + ' comments on Grain';
    })
    .catch(() => {
      favs.slot.textContent = '\u2013';
      comments.slot.textContent = '\u2013';
    });
}

function locationLine(gallery) {
  const loc = gallery.location;
  const addr = gallery.address;
  if (!loc && !addr) return null;
  const parts = [];
  if (loc && loc.name) parts.push(loc.name);
  const name = parts[0];
  const region = addr
    ? [addr.locality, addr.region].filter((x) => x && x !== name).join(', ')
    : loc && loc.region && loc.region !== name ? loc.region : '';
  if (region) parts.push(region);
  return parts.join(' \u00b7 ');
}

async function renderMap(app) {
  document.title = 'Map \u00b7 Photos';
  const located = GALLERIES.filter((g) => g.location && g.location.value);
  const back = el('nav', { class: 'back' }, [el('a', { href: '/', text: '\u2190 All galleries' })]);
  back.firstChild.addEventListener('click', goBack);
  const header = el('header', { class: 'gallery' }, [
    el('h1', { text: 'Map' }),
    el('p', { class: 'meta', text: located.length + ' of ' + GALLERIES.length + ' galleries have a place' }),
  ]);
  const room = el('div', { class: 'maproom', text: 'Loading map\u2026' });
  app.replaceChildren(back, header, room, footer());

  if (!document.getElementById('leaflet-css')) {
    document.head.appendChild(el('link', {
      id: 'leaflet-css',
      rel: 'stylesheet',
      href: 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css',
    }));
  }
  const [leaflet, h3] = await Promise.all([
    import('https://esm.sh/leaflet@1.9.4'),
    import('https://esm.sh/h3-js@4.1.0'),
  ]);
  const L = leaflet.default;
  room.textContent = '';
  const map = L.map(room, { scrollWheelZoom: true, worldCopyJump: true });
  L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
    attribution: '&copy; OpenStreetMap contributors &copy; CARTO',
    maxZoom: 19,
  }).addTo(map);

  const bounds = [];
  for (const gallery of located) {
    let lat, lng;
    try {
      [lat, lng] = h3.cellToLatLng(gallery.location.value);
    } catch {
      continue;
    }
    bounds.push([lat, lng]);
    const marker = L.circleMarker([lat, lng], {
      radius: 7,
      color: '#8fb8e8',
      weight: 1.5,
      fillColor: '#8fb8e8',
      fillOpacity: 0.75,
    }).addTo(map);
    const cover = gallery.photos[0];
    const link = el('a', { href: '/g/' + gallery.rkey }, [
      withFallback(el('img', { src: sizedUrl(cover, 180), alt: cover.alt || gallery.title }), cover),
      el('div', { class: 't', text: gallery.title }),
      el('div', { class: 'm', text: fmtDate(gallery.createdAt) + ' \u00b7 ' + gallery.photos.length + ' photos' }),
    ]);
    link.addEventListener('click', (event) => go(event, '/g/' + gallery.rkey));
    marker.bindPopup(link, { closeButton: false });
  }
  if (bounds.length) map.fitBounds(bounds, { padding: [40, 40] });
}

let lightbox = null;

function closeLightbox() {
  if (!lightbox) return;
  lightbox.el.remove();
  document.body.style.overflow = '';
  lightbox = null;
}

function showLightbox() {
  const { photos, index } = lightbox;
  const photo = photos[index];
  const viewWidth = window.innerWidth * 0.94;
  lightbox.img.src = sizedUrl(photo, viewWidth);
  withFallback(lightbox.img, photo);
  lightbox.img.alt = photo.alt || '';
  lightbox.count.textContent = (index + 1) + ' / ' + photos.length;
  for (const delta of [1, -1]) {
    new Image().src = sizedUrl(photos[(index + delta + photos.length) % photos.length], viewWidth);
  }
}

function stepLightbox(delta) {
  if (!lightbox) return;
  const n = lightbox.photos.length;
  lightbox.index = (lightbox.index + delta + n) % n;
  showLightbox();
}

function openLightbox(photos, index) {
  closeLightbox();
  const img = el('img', { decoding: 'async' });
  const count = el('span', { class: 'count' });
  const overlay = el('div', { class: 'lightbox' }, [
    count,
    img,
    el('button', { class: 'prev', 'aria-label': 'Previous photo', text: '\u2039' }),
    el('button', { class: 'next', 'aria-label': 'Next photo', text: '\u203a' }),
    el('button', { class: 'close', 'aria-label': 'Close', text: '\u00d7' }),
  ]);
  overlay.addEventListener('click', (event) => {
    const target = event.target;
    if (target === overlay) closeLightbox();
    else if (target.classList.contains('close')) closeLightbox();
    else if (target.classList.contains('prev')) stepLightbox(-1);
    else if (target.classList.contains('next')) stepLightbox(1);
  });
  document.body.appendChild(overlay);
  document.body.style.overflow = 'hidden';
  lightbox = { photos, index, el: overlay, img, count };
  showLightbox();
}

document.addEventListener('keydown', (event) => {
  if (!lightbox) return;
  if (event.key === 'Escape') closeLightbox();
  else if (event.key === 'ArrowLeft') stepLightbox(-1);
  else if (event.key === 'ArrowRight') stepLightbox(1);
});

function renderIndex(app) {
  document.title = 'Photos · ' + HANDLE;
  const header = el('header', { class: 'site' }, [el('h1', { text: 'Photos' })]);
  const sub = el('p');
  sub.append(GALLERIES.length + ' galleries from ');
  sub.appendChild(el('a', { href: 'https://grain.social/profile/' + HANDLE, text: 'Grain' }));
  sub.append(', queried live from an atproto repo. ');
  if (CONFIG.map) {
    const mapLink = el('a', { href: '/map', text: 'See them on a map \u2192' });
    mapLink.addEventListener('click', (event) => go(event, '/map'));
    sub.appendChild(mapLink);
  }
  header.appendChild(sub);

  const cards = el('div', { class: 'cards' });
  app.replaceChildren(header, cards, footer());
  layoutJustified(
    cards,
    GALLERIES,
    (gallery) => photoRatio(gallery.photos[0]),
    300,
    (gallery, shown, cellWidth) => {
      const cover = gallery.photos[0];
      const count = gallery.photos.length;
      const card = el('a', { class: 'cover', href: '/g/' + gallery.rkey }, [
        withFallback(el('img', {
          src: sizedUrl(cover, cellWidth),
          alt: cover.alt || gallery.title,
          loading: shown >= 6 ? 'lazy' : 'eager',
          decoding: 'async',
        }), cover),
        el('div', { class: 'label' }, [
          el('h2', { text: gallery.title }),
          el('p', { text: fmtDate(gallery.createdAt) + ' · ' + count + ' photo' + (count === 1 ? '' : 's') }),
        ]),
      ]);
      card.addEventListener('click', (event) => go(event, '/g/' + gallery.rkey));
      return card;
    },
  );
}

function renderGallery(app, rkey) {
  const gallery = GALLERIES.find((g) => g.rkey === rkey);
  if (!gallery) return renderIndex(app);
  const hasTopo = Boolean(gallery.location && gallery.location.value);
  document.title = gallery.title + ' · Photos';

  const back = el('nav', { class: 'back' }, [el('a', { href: '/', text: '\\u2190 All galleries' })]);
  back.firstChild.addEventListener('click', goBack);

  const header = el('header', { class: 'gallery' }, [
    el('h1', { text: gallery.title }),
    el('p', { class: 'meta', text: fmtDate(gallery.createdAt) + ' · ' + gallery.photos.length + ' photos' }),
  ]);
  const where = locationLine(gallery);
  if (where) header.appendChild(el('p', { class: 'loc', text: where }));
  if (gallery.description) header.appendChild(el('p', { class: 'desc', text: gallery.description }));
  if (CONFIG.social) socialRow(header, gallery);

  const grid = el('div', { class: 'grid' });
  const foot = footer();
  if (hasTopo) {
    foot.append(' Terrain lines from ');
    foot.appendChild(el('a', { href: 'https://opentopomap.org', text: 'OpenTopoMap' }));
    foot.append(' (CC-BY-SA).');
  }
  app.replaceChildren(back, header, grid, foot);
  const rowTarget = CONFIG.rows === 'compact' ? 200 : 260;
  layoutJustified(grid, gallery.photos, photoRatio, rowTarget, (photo, shown, cellWidth) => {
    const img = withFallback(
      el('img', { src: sizedUrl(photo, cellWidth), alt: photo.alt || '', decoding: 'async' }),
      photo,
    );
    if (shown >= 6) img.loading = 'lazy';
    const link = el('a', { href: blobUrl(photo) }, [img]);
    link.addEventListener('click', (event) => {
      event.preventDefault();
      openLightbox(gallery.photos, shown);
    });
    return link;
  });
  if (CONFIG.topo) topoBackdrop(app, gallery);
}

// The views are rebuilt on every navigation, so the browser cannot restore
// scroll by itself. Each view's position is remembered here and put back
// after any history traversal; explicit navigation starts at the top.
if ('scrollRestoration' in history) history.scrollRestoration = 'manual';
const SCROLL_POS = new Map();
const currentPath = () =>
  location.hash.startsWith('#/') ? location.hash.slice(1) : location.pathname;
let renderedPath = null;

function route() {
  closeLightbox();
  const app = document.getElementById('app');
  const path = currentPath();
  const match = path.match(/^\\/g\\/([a-z0-9.~_:-]+)/i);
  if (match) renderGallery(app, match[1]);
  else if (path === '/map' && CONFIG.map) renderMap(app);
  else renderIndex(app);
  renderedPath = path;
}

// Width only: on phones, scrolling collapses the browser chrome, which
// fires resize with the same width. Re-rendering on that would throw the
// reader back to the top of the page.
let resizeTimer = null;
let lastWidth = window.innerWidth;
window.addEventListener('resize', () => {
  if (window.innerWidth === lastWidth) return;
  lastWidth = window.innerWidth;
  clearTimeout(resizeTimer);
  resizeTimer = setTimeout(route, 150);
});

function go(event, path) {
  event.preventDefault();
  SCROLL_POS.set(currentPath(), window.scrollY);
  history.pushState({ app: true }, '', path);
  route();
  window.scrollTo(0, 0);
}

// A real history step when the current entry is one of ours, so the
// browser's own back joins the same scroll-restoring path; a deep link
// with nothing behind it navigates to the index instead.
function goBack(event) {
  if (history.state && history.state.app) {
    event.preventDefault();
    history.back();
  } else {
    go(event, '/');
  }
}

window.addEventListener('popstate', () => {
  if (renderedPath !== null) SCROLL_POS.set(renderedPath, window.scrollY);
  route();
  window.scrollTo(0, SCROLL_POS.get(currentPath()) || 0);
});
discoverHost()
  .then(load)
  .then(route)
  .catch((err) => {
    const app = document.getElementById('app');
    app.replaceChildren(
      el('p', { class: 'state', text: 'Could not load galleries: ' + err.message }),
    );
  });
</script>
</body>
</html>
`;

const upload = await xrpc('com.atproto.repo.uploadBlob', {
  method: 'POST',
  headers: { ...auth, 'Content-Type': 'text/html; charset=utf-8' },
  body: HTML,
});
await put(SITES, SITE, {
  files: [
    {
      path: 'index.html',
      blob: upload.blob,
      contentType: 'text/html; charset=utf-8',
      cacheControl: 'public, max-age=300',
    },
  ],
  fallback: 'index.html',
  createdAt: new Date().toISOString(),
});
console.error(
  'Deployed photos as a live SPA: 1 file, data and images read from the repo at view time.',
);

// ---- publish the app ----

// The app's icon: its own layout, in miniature.
const ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#101014"/>
<rect x="10" y="12" width="26" height="17" rx="2.5" fill="#8fb8e8"/>
<rect x="39" y="12" width="15" height="17" rx="2.5" fill="#5a6a80"/>
<rect x="10" y="33" width="14" height="19" rx="2.5" fill="#5a6a80"/>
<rect x="27" y="33" width="27" height="19" rx="2.5" fill="#c9d6e8"/>
</svg>`;
const iconUp = await fetch(`${PDS}/xrpc/com.atproto.repo.uploadBlob`, {
  method: 'POST',
  headers: { ...auth, 'Content-Type': 'image/svg+xml' },
  body: ICON_SVG,
});
const icon = (await iconUp.json()).blob;
console.error('uploaded icon', icon.ref.$link);

const deployed = await xrpc(
  `com.atproto.repo.getRecord?repo=${DID}&collection=${SITES}&rkey=${SITE}`,
);
await put('dev.pdsjs.app.manifest', 'photos', {
  name: 'Photos',
  icon,
  suggestedSite: 'photos',
  description:
    'Photo galleries from your Grain records, served straight from your own repo: justified layouts, a lightbox, terrain headers for located galleries, a map view, and favorite counts from the network.',
  site: { uri: deployed.uri, cid: deployed.cid },
  primitives: ['serve', 'query'],
  external: true,
  queries: [
    {
      name: 'grain-galleries',
      collection: 'social.grain.gallery',
      sort: { field: 'createdAt', direction: 'desc' },
    },
    { name: 'grain-gallery-items', collection: 'social.grain.gallery.item' },
    { name: 'grain-photos', collection: 'social.grain.photo' },
  ],
  settings: [
    {
      key: 'topo',
      label: 'Terrain behind gallery headers',
      type: 'boolean',
      default: true,
    },
    { key: 'map', label: 'Map view', type: 'boolean', default: true },
    {
      key: 'social',
      label: 'Favorite and comment counts (asks microcosm.blue)',
      type: 'boolean',
      default: true,
    },
    {
      key: 'rows',
      label: 'Photo row height',
      type: 'select',
      options: ['compact', 'roomy'],
      default: 'roomy',
    },
  ],
  createdAt: new Date().toISOString(),
});
console.error(
  'Published dev.pdsjs.app.manifest/photos pinned to the deployed site.',
);