chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// 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: '© OpenStreetMap contributors © 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.',
);