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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
769 lines · 28.7 KB Raw History
// An image host: drop a file in, get a link to share. Deploy it to your own
// PDS with PDS_URL, PDS_DID and PDS_APP_PASSWORD set; see docs/apps.md.
//
// Images are com.chadtmiller.drop.image records in the owner's repo and the
// bytes are ordinary blobs. The shared link is the site's /.blobs/<cid>
// route, which serves the original bytes immutably. Reads are public through
// dev.pdsjs.query.run; uploads need an app password, which the app exchanges
// for a session against the owner's own PDS.
//
// Usage: PDS_APP_PASSWORD=... node drop.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 = 'drop-app';
const SITES = 'dev.pdsjs.site.deploy';
const DEFS = 'dev.pdsjs.query.def';
// The collection images are written to. An NSID's authority is every segment
// but the last, reversed, so this names a domain you control.
const IMAGES = process.env.DROP_COLLECTION || 'com.chadtmiller.drop.image';

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 query the app reads ----

await put(DEFS, 'drop-images', {
  collection: IMAGES,
  sort: { field: 'createdAt', direction: 'desc' },
  createdAt: new Date().toISOString(),
});

// ---- 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>Drop</title>
<meta name="description" content="Images kept as records in an atproto repo. Drop one in, share the link.">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%230e0e11'/%3E%3Cpath d='M32 12c7 10 12 15.5 12 22a12 12 0 0 1-24 0c0-6.5 5-12 12-22z' fill='%235b8cff'/%3E%3C/svg%3E">
<style>
:root{color-scheme:dark;--bg:#0e0e11;--card:#17171c;--line:#26262e;--fg:#ececf1;--dim:#9a9aa4;--accent:#5b8cff}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font-family:system-ui,-apple-system,sans-serif;line-height:1.5;-webkit-text-size-adjust:100%}
a{color:inherit;text-decoration:none}
button{font:inherit;cursor:pointer}
input{font:inherit}
.wrap{max-width:1080px;margin:0 auto;padding:0 18px 80px}
header.site{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding:30px 0 18px;flex-wrap:wrap}
header.site h1{margin:0;font-size:23px;letter-spacing:-.02em}
header.site .who{color:var(--dim);font-size:13px}
.btn{border:0;background:var(--accent);color:#07142e;padding:9px 16px;border-radius:10px;font-weight:650;font-size:14px}
.btn:hover{filter:brightness(1.08)}
.btn.ghost{background:transparent;color:var(--dim);border:1px solid var(--line)}
.btn.ghost:hover{color:var(--fg)}
.btn.danger{background:transparent;color:#e8748a;border:1px solid var(--line)}
.btn.danger:hover{background:#2a1a1e;color:#ff93a6}
.btn:disabled{opacity:.45;cursor:default;filter:none}
.drop{border:1.5px dashed var(--line);border-radius:16px;padding:44px 20px;text-align:center;background:var(--card);transition:border-color .15s,background .15s}
.drop.over{border-color:var(--accent);background:#151a26}
.drop h2{margin:0 0 6px;font-size:17px;font-weight:600}
.drop p{margin:0 0 16px;color:var(--dim);font-size:13.5px}
.drop .hint{margin:14px 0 0;font-size:12.5px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(168px,1fr));gap:12px;margin-top:26px}
.tile{position:relative;display:block;aspect-ratio:1;border-radius:12px;overflow:hidden;background:var(--card);border:1px solid var(--line)}
.tile img{width:100%;height:100%;object-fit:cover;display:block}
.tile .copy{position:absolute;right:8px;bottom:8px;opacity:0;transition:opacity .15s;background:rgba(10,10,14,.82);color:var(--fg);border:1px solid var(--line);border-radius:8px;padding:5px 10px;font-size:12px}
.tile:hover .copy,.tile:focus-within .copy{opacity:1}
.tile.pending{display:flex;align-items:center;justify-content:center;color:var(--dim);font-size:12.5px}
.tile.pending .bar{position:absolute;left:0;bottom:0;height:3px;background:var(--accent);width:35%;animation:slide 1.1s ease-in-out infinite}
@keyframes slide{0%{left:-35%}100%{left:100%}}
.state{color:var(--dim);padding:44px 0;text-align:center}
.detail{padding:14px 0 0}
.detail .frame{background:var(--card);border:1px solid var(--line);border-radius:14px;display:flex;align-items:center;justify-content:center;overflow:hidden;width:max-content;max-width:100%}
.detail .frame img{max-width:100%;max-height:78vh;width:auto;height:auto;display:block}
.detail .meta{display:flex;flex-wrap:wrap;gap:8px 18px;color:var(--dim);font-size:13px;margin-top:14px}
.detail .acts{display:flex;flex-wrap:wrap;gap:8px;margin-top:16px}
.detail .alt{display:flex;gap:8px;margin-top:16px}
.detail .alt input{flex:1;min-width:0;background:var(--card);border:1px solid var(--line);color:var(--fg);border-radius:10px;padding:9px 12px;font-size:14px}
.detail .alt input:focus{outline:0;border-color:var(--accent)}
.detail p.cap{margin:16px 0 0;font-size:15px;color:#c9c9d2}
nav.back{padding:26px 0 0;font-size:14px;color:var(--dim)}
nav.back a:hover{color:var(--fg)}
.sheet{position:fixed;inset:0;background:rgba(6,6,9,.72);display:flex;align-items:center;justify-content:center;padding:18px;z-index:20}
.sheet .box{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:22px;width:100%;max-width:380px}
.sheet h2{margin:0 0 6px;font-size:18px}
.sheet p{margin:0 0 16px;color:var(--dim);font-size:13px}
.sheet label{display:block;color:var(--dim);font-size:12px;margin:12px 0 5px}
.sheet input{width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);border-radius:10px;padding:10px 12px;font-size:14px}
.sheet input:focus{outline:0;border-color:var(--accent)}
.sheet .row{display:flex;gap:8px;margin-top:18px}
.sheet .err{color:#e8748a;font-size:13px;margin-top:12px}
.overlay{position:fixed;inset:0;background:rgba(11,14,24,.88);border:2px dashed var(--accent);display:flex;align-items:center;justify-content:center;font-size:20px;font-weight:600;z-index:30;pointer-events:none}
#toast{position:fixed;left:50%;bottom:26px;transform:translate(-50%,14px);background:#1f1f27;border:1px solid var(--line);color:var(--fg);padding:10px 18px;border-radius:999px;font-size:14px;opacity:0;transition:opacity .18s,transform .18s;pointer-events:none;z-index:40}
#toast.on{opacity:1;transform:translate(-50%,0)}
footer{margin-top:52px;color:#6b6b74;font-size:12.5px;text-align:center}
footer a{color:var(--dim)}
</style>
</head>
<body>
<div class="wrap" id="app"><p class="state">Loading…</p></div>
<div id="toast"></div>
<input type="file" id="picker" accept="image/*" multiple hidden>
<script>
const IMAGES = ${JSON.stringify(IMAGES)};
const MAX_BYTES = 50 * 1024 * 1024;

let PDS = null;
let DID = null;
let HANDLE = null;
let SESSION = null;
let LIST = [];
let PENDING = 0;
let CONFIG = { gallery: true };

// ---- talking to the PDS ----

async function discover() {
  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();
    PDS = (doc.service || []).find(
      (s) => s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer',
    ).serviceEndpoint;
  }
  try {
    const repo = await (await fetch(PDS + '/xrpc/com.atproto.repo.describeRepo?repo=' + DID)).json();
    HANDLE = repo.handle || DID;
  } catch { HANDLE = DID; }
  try { SESSION = JSON.parse(localStorage.getItem('drop-session') || 'null'); } catch {}

  // Settings chosen at install, from the public install record naming this
  // site. Absent, the 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 entryOf = (uri, value) => ({
  rkey: uri.split('/').pop(),
  cid: value.image && value.image.ref ? (value.image.ref.$link || value.image.ref) : null,
  size: (value.image && value.image.size) || 0,
  mimeType: (value.image && value.image.mimeType) || 'image/jpeg',
  ratio: value.aspectRatio && value.aspectRatio.height
    ? value.aspectRatio.width / value.aspectRatio.height
    : null,
  aspectRatio: value.aspectRatio || null,
  alt: value.alt || '',
  createdAt: value.createdAt || '',
});

async function loadImages() {
  // The query def sorts by createdAt; listRecords is the fallback when a
  // deployment does not serve queries.
  try {
    const r = await fetch(PDS + '/xrpc/dev.pdsjs.query.run?query=drop-images&limit=500');
    if (r.ok) {
      LIST = (await r.json()).records.map((x) => entryOf(x.uri, x.value)).filter((x) => x.cid);
      return;
    }
  } catch {}
  const r = await fetch(PDS + '/xrpc/com.atproto.repo.listRecords?repo=' + DID + '&collection=' + IMAGES + '&limit=100&reverse=true');
  LIST = r.ok
    ? (await r.json()).records.map((x) => entryOf(x.uri, x.value)).filter((x) => x.cid)
        .sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))
    : [];
}

async function signIn(identifier, appPassword) {
  const r = await fetch(PDS + '/xrpc/com.atproto.server.createSession', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ identifier, password: appPassword }),
  });
  if (!r.ok) throw new Error('That did not sign in. Check the app password.');
  const s = await r.json();
  if (s.did !== DID) throw new Error('That account does not own this site.');
  SESSION = s;
  localStorage.setItem('drop-session', JSON.stringify(s));
}

function signOut() {
  SESSION = null;
  localStorage.removeItem('drop-session');
  route();
}

/** An authorized call, refreshing the session once if the token has aged out. */
async function authed(path, init = {}, retry = true) {
  if (!SESSION) throw new Error('Sign in to upload.');
  const r = await fetch(PDS + '/xrpc/' + path, {
    ...init,
    headers: { ...(init.headers || {}), Authorization: 'Bearer ' + SESSION.accessJwt },
  });
  if (r.status === 401 && retry) {
    const rf = await fetch(PDS + '/xrpc/com.atproto.server.refreshSession', {
      method: 'POST',
      headers: { Authorization: 'Bearer ' + SESSION.refreshJwt },
    });
    if (!rf.ok) { signOut(); throw new Error('Session expired. Sign in again.'); }
    SESSION = await rf.json();
    localStorage.setItem('drop-session', JSON.stringify(SESSION));
    return authed(path, init, false);
  }
  if (!r.ok) {
    const body = await r.json().catch(() => ({}));
    throw new Error(body.message || body.error || ('Request failed (' + r.status + ')'));
  }
  return r.json();
}

// ---- images ----

const blobUrl = (cid) => '/.blobs/' + cid;
const shareUrl = (cid) => location.origin + '/.blobs/' + cid;
const pageUrl = (rkey) => location.origin + '/i/' + rkey;

// Cloudflare image transformations, when the zone has them enabled. One probe
// per session decides; without them every thumbnail is the original blob.
let TRANSFORMS = false;
const BUCKETS = [400, 800, 1600];

async function detectTransforms(cid) {
  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/' + cid);
    TRANSFORMS = probe.ok && (probe.headers.get('content-type') || '').startsWith('image/');
  } catch { TRANSFORMS = false; }
  sessionStorage.setItem('cf-transforms', TRANSFORMS ? '1' : '0');
}

function sizedUrl(cid, cssWidth) {
  if (!TRANSFORMS) return blobUrl(cid);
  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/' + cid;
}

const EXTENSIONS = {
  'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp',
  'image/avif': 'avif', 'image/svg+xml': 'svg', 'image/heic': 'heic',
};

const fmtBytes = (n) =>
  n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : Math.max(1, Math.round(n / 1024)) + ' KB';

const fmtDate = (iso) =>
  iso ? new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) : '';

// Seven characters of base36: short enough to read out, wide enough that
// keys do not collide in a repo this size.
const newKey = () => {
  let key = '';
  const bytes = crypto.getRandomValues(new Uint8Array(7));
  for (const b of bytes) key += (b % 36).toString(36);
  return key;
};

async function measure(file) {
  try {
    const bitmap = await createImageBitmap(file);
    const size = { width: bitmap.width, height: bitmap.height };
    bitmap.close();
    return size;
  } catch {
    return null;
  }
}

async function uploadOne(file) {
  if (!(file.type || '').startsWith('image/')) {
    throw new Error(file.name + ' is not an image.');
  }
  if (file.size > MAX_BYTES) {
    throw new Error(file.name + ' is ' + fmtBytes(file.size) + '. Images over 50 MB are not uploaded.');
  }
  const size = await measure(file);
  const out = await authed('com.atproto.repo.uploadBlob', {
    method: 'POST',
    headers: { 'Content-Type': file.type },
    body: new Uint8Array(await file.arrayBuffer()),
  });
  const rkey = newKey();
  const record = { image: out.blob, createdAt: new Date().toISOString() };
  if (size) record.aspectRatio = size;
  await authed('com.atproto.repo.putRecord', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ repo: DID, collection: IMAGES, rkey, record: { $type: IMAGES, ...record } }),
  });
  return entryOf('at://x/y/' + rkey, record);
}

async function accept(files) {
  const images = [...files].filter((f) => (f.type || '').startsWith('image/'));
  if (images.length === 0) { toast('Only images can be dropped here.'); return; }
  PENDING += images.length;
  render();
  const done = [];
  for (const file of images) {
    try {
      const entry = await uploadOne(file);
      LIST.unshift(entry);
      done.push(entry);
      if (sessionStorage.getItem('cf-transforms') === null) await detectTransforms(entry.cid);
    } catch (err) {
      toast(err.message);
    }
    PENDING -= 1;
    render();
  }
  if (done.length === 1) copy(shareUrl(done[0].cid), 'Link copied');
  else if (done.length > 1) toast(done.length + ' images uploaded');
}

async function remove(entry) {
  await authed('com.atproto.repo.deleteRecord', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ repo: DID, collection: IMAGES, rkey: entry.rkey }),
  });
  LIST = LIST.filter((x) => x.rkey !== entry.rkey);
}

async function saveAlt(entry, alt) {
  const record = {
    $type: IMAGES,
    image: { $type: 'blob', ref: { $link: entry.cid }, mimeType: entry.mimeType, size: entry.size },
    createdAt: entry.createdAt,
  };
  if (entry.aspectRatio) record.aspectRatio = entry.aspectRatio;
  if (alt) record.alt = alt;
  await authed('com.atproto.repo.putRecord', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ repo: DID, collection: IMAGES, rkey: entry.rkey, record }),
  });
  entry.alt = alt;
}

// ---- interface ----

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 if (k === 'on') for (const [evt, fn] of Object.entries(v)) node.addEventListener(evt, fn);
    else if (v !== null && v !== undefined) node.setAttribute(k, v);
  }
  for (const child of children || []) if (child) node.appendChild(child);
  return node;
};

let toastTimer;
function toast(message) {
  const node = document.getElementById('toast');
  node.textContent = message;
  node.classList.add('on');
  clearTimeout(toastTimer);
  toastTimer = setTimeout(() => node.classList.remove('on'), 2600);
}

async function copy(text, message) {
  try {
    await navigator.clipboard.writeText(text);
  } catch {
    const field = el('textarea', { style: 'position:fixed;opacity:0' });
    field.value = text;
    document.body.appendChild(field);
    field.select();
    document.execCommand('copy');
    field.remove();
  }
  toast(message || 'Copied');
}

function go(path) {
  history.pushState({}, '', path);
  route();
}

const link = (path, attrs, children) =>
  el('a', { href: path, ...attrs, on: { click: (e) => { e.preventDefault(); go(path); } } }, children);

// A shared link is for whoever receives it, so the single image page carries
// no account controls.
function header(auth) {
  const right = SESSION
    ? el('button', { class: 'btn ghost', text: 'Sign out', on: { click: signOut } })
    : el('button', { class: 'btn', text: 'Sign in', on: { click: signInSheet } });
  return el('header', { class: 'site' }, [
    el('div', {}, [
      link('/', {}, [el('h1', { text: 'Drop' })]),
      el('div', { class: 'who', text: HANDLE }),
    ]),
    auth ? right : null,
  ]);
}

function footer() {
  const node = el('footer');
  node.append('Every image here is a blob in ');
  node.appendChild(el('a', { href: 'https://bsky.app/profile/' + HANDLE, text: HANDLE }));
  node.append("'s atproto repo. Served by pds.js.");
  return node;
}

function dropZone() {
  const zone = el('div', { class: 'drop' }, [
    el('h2', { text: 'Drop an image' }),
    el('p', { text: 'Paste one, drag one in, or choose a file. The link copies itself.' }),
    el('button', { class: 'btn', text: 'Choose a file', on: { click: () => document.getElementById('picker').click() } }),
    el('p', { class: 'hint', text: 'JPEG, PNG, GIF, WebP, AVIF or SVG, up to 50 MB.' }),
  ]);
  return zone;
}

function tile(entry) {
  const img = el('img', {
    src: sizedUrl(entry.cid, 240),
    alt: entry.alt,
    loading: 'lazy',
    decoding: 'async',
  });
  img.addEventListener('error', () => { if (img.src.includes('/cdn-cgi/')) img.src = blobUrl(entry.cid); });
  const copyButton = el('button', {
    class: 'copy',
    text: 'Copy link',
    on: { click: (e) => { e.preventDefault(); e.stopPropagation(); copy(shareUrl(entry.cid), 'Link copied'); } },
  });
  return link('/i/' + entry.rkey, { class: 'tile' }, [img, copyButton]);
}

function pendingTile() {
  return el('div', { class: 'tile pending', text: 'Uploading…' }, [el('div', { class: 'bar' })]);
}

function indexView() {
  const app = document.getElementById('app');
  app.textContent = '';
  document.title = 'Drop';
  app.appendChild(header(true));
  if (SESSION) app.appendChild(dropZone());

  if (!SESSION && !CONFIG.gallery) {
    app.appendChild(el('p', { class: 'state', text: 'This gallery is private. A link to an image still opens it.' }));
    app.appendChild(footer());
    return;
  }

  const grid = el('div', { class: 'grid' });
  for (let i = 0; i < PENDING; i++) grid.appendChild(pendingTile());
  for (const entry of LIST) grid.appendChild(tile(entry));
  if (LIST.length === 0 && PENDING === 0) {
    app.appendChild(el('p', { class: 'state', text: SESSION ? 'Nothing dropped yet.' : 'No images here yet.' }));
  } else {
    app.appendChild(grid);
  }
  app.appendChild(footer());
}

function detailView(rkey) {
  const app = document.getElementById('app');
  app.textContent = '';
  app.appendChild(header(false));
  const entry = LIST.find((x) => x.rkey === rkey);
  if (!entry) {
    app.appendChild(el('p', { class: 'state', text: 'No image at this address.' }));
    app.appendChild(el('nav', { class: 'back' }, [link('/', { text: '← All images' })]));
    app.appendChild(footer());
    return;
  }
  document.title = (entry.alt || 'Image') + ' · Drop';

  const img = el('img', { src: sizedUrl(entry.cid, 1000), alt: entry.alt });
  img.addEventListener('error', () => { if (img.src.includes('/cdn-cgi/')) img.src = blobUrl(entry.cid); });

  const meta = el('div', { class: 'meta' }, [
    el('span', { text: fmtDate(entry.createdAt) }),
    entry.aspectRatio ? el('span', { text: entry.aspectRatio.width + ' × ' + entry.aspectRatio.height }) : null,
    el('span', { text: fmtBytes(entry.size) }),
    el('span', { text: entry.mimeType }),
  ]);

  const acts = el('div', { class: 'acts' }, [
    el('button', { class: 'btn', text: 'Copy link', on: { click: () => copy(shareUrl(entry.cid), 'Link copied') } }),
    el('button', { class: 'btn ghost', text: 'Copy page link', on: { click: () => copy(pageUrl(entry.rkey), 'Page link copied') } }),
    el('a', {
      class: 'btn ghost',
      href: blobUrl(entry.cid),
      download: entry.rkey + '.' + (EXTENSIONS[entry.mimeType] || 'img'),
      text: 'Download',
    }),
  ]);
  if (SESSION) {
    acts.appendChild(el('button', {
      class: 'btn danger',
      text: 'Delete',
      on: {
        click: async (e) => {
          if (!confirm('Delete this image? The link stops working.')) return;
          e.target.disabled = true;
          try {
            await remove(entry);
            toast('Deleted');
            go('/');
          } catch (err) {
            e.target.disabled = false;
            toast(err.message);
          }
        },
      },
    }));
  }

  const detail = el('div', { class: 'detail' }, [
    el('div', { class: 'frame' }, [img]),
    meta,
    acts,
  ]);

  if (SESSION) {
    const field = el('input', { type: 'text', placeholder: 'Describe the image', value: entry.alt, maxlength: '600' });
    const save = el('button', {
      class: 'btn ghost',
      text: 'Save',
      on: {
        click: async () => {
          save.disabled = true;
          try {
            await saveAlt(entry, field.value.trim());
            document.title = (entry.alt || 'Image') + ' · Drop';
            toast('Description saved');
          } catch (err) {
            toast(err.message);
          }
          save.disabled = false;
        },
      },
    });
    detail.appendChild(el('div', { class: 'alt' }, [field, save]));
  } else if (entry.alt) {
    detail.appendChild(el('p', { class: 'cap', text: entry.alt }));
  }

  detail.appendChild(el('nav', { class: 'back' }, [link('/', { text: '← All images' })]));
  app.appendChild(detail);
  app.appendChild(footer());
}

function signInSheet() {
  const identifier = el('input', { type: 'text', value: HANDLE || '', autocapitalize: 'none', spellcheck: 'false' });
  const secret = el('input', { type: 'password', placeholder: 'xxxx-xxxx-xxxx-xxxx' });
  const error = el('div', { class: 'err' });
  const close = () => sheet.remove();
  const submit = async () => {
    error.textContent = '';
    go2.disabled = true;
    try {
      await signIn(identifier.value.trim(), secret.value.trim());
      close();
      route();
    } catch (err) {
      error.textContent = err.message;
      go2.disabled = false;
    }
  };
  const go2 = el('button', { class: 'btn', text: 'Sign in', on: { click: submit } });
  const box = el('div', { class: 'box' }, [
    el('h2', { text: 'Sign in to upload' }),
    el('p', { text: 'Only the account this site belongs to can add or delete images.' }),
    el('label', { text: 'Handle or DID' }),
    identifier,
    el('label', { text: 'App password' }),
    secret,
    error,
    el('div', { class: 'row' }, [go2, el('button', { class: 'btn ghost', text: 'Cancel', on: { click: close } })]),
  ]);
  secret.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });
  const sheet = el('div', { class: 'sheet', on: { click: (e) => { if (e.target === sheet) close(); } } }, [box]);
  document.body.appendChild(sheet);
  secret.focus();
}

// ---- routing ----

function route() {
  const match = location.pathname.match(/^\\/i\\/([^/]+)\\/?$/);
  if (match) detailView(decodeURIComponent(match[1]));
  else indexView();
}

const render = () => route();

window.addEventListener('popstate', route);

document.getElementById('picker').addEventListener('change', (e) => {
  accept(e.target.files);
  e.target.value = '';
});

// Drag anywhere on the page, not only onto the zone. The counter survives
// dragleave firing for every child element the pointer crosses.
let dragDepth = 0;
let overlay = null;

window.addEventListener('dragenter', (e) => {
  if (!SESSION || ![...e.dataTransfer.types].includes('Files')) return;
  e.preventDefault();
  dragDepth += 1;
  if (!overlay) {
    overlay = el('div', { class: 'overlay', text: 'Drop to upload' });
    document.body.appendChild(overlay);
  }
});
window.addEventListener('dragover', (e) => { if (SESSION) e.preventDefault(); });
window.addEventListener('dragleave', () => {
  dragDepth -= 1;
  if (dragDepth <= 0 && overlay) { overlay.remove(); overlay = null; dragDepth = 0; }
});
window.addEventListener('drop', (e) => {
  if (!SESSION) return;
  e.preventDefault();
  dragDepth = 0;
  if (overlay) { overlay.remove(); overlay = null; }
  accept(e.dataTransfer.files);
});

window.addEventListener('paste', (e) => {
  if (!SESSION || !e.clipboardData) return;
  const tag = e.target && e.target.tagName;
  if (tag === 'INPUT' || tag === 'TEXTAREA') return;
  // Chrome and Safari fill files for a pasted screenshot. Firefox leaves it
  // empty and describes the image in items.
  let files = [...(e.clipboardData.files || [])];
  if (files.length === 0) {
    files = [...(e.clipboardData.items || [])]
      .filter((item) => item.kind === 'file')
      .map((item) => item.getAsFile())
      .filter((file) => file && (file.type || '').startsWith('image/'));
  }
  if (files.length) { e.preventDefault(); accept(files); }
});

(async () => {
  try {
    await discover();
    await loadImages();
    if (LIST.length) await detectTransforms(LIST[0].cid);
  } catch (err) {
    document.getElementById('app').textContent = '';
    document.getElementById('app').appendChild(el('p', { class: 'state', text: 'Could not reach the PDS. ' + err.message }));
    return;
  }
  route();
})();
</script>
</body>
</html>
`;

// ---- deploy ----

const upload = await fetch(`${PDS}/xrpc/com.atproto.repo.uploadBlob`, {
  method: 'POST',
  headers: { ...auth, 'Content-Type': 'text/html; charset=utf-8' },
  body: HTML,
});
const blob = (await upload.json()).blob;
await put(SITES, SITE, {
  files: [
    {
      path: 'index.html',
      blob,
      contentType: 'text/html; charset=utf-8',
      cacheControl: 'public, max-age=300',
    },
  ],
  fallback: 'index.html',
  createdAt: new Date().toISOString(),
});
console.error(
  `Deployed ${SITE}: ${Math.round(HTML.length / 1024)}KB, one file.`,
);

// ---- publish it as an installable app ----

const ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#0e0e11"/>
<path d="M32 11c7.5 10.5 12.5 16.5 12.5 23a12.5 12.5 0 0 1-25 0c0-6.5 5-12.5 12.5-23z" fill="#5b8cff"/>
<path d="M26 34c0 4 2.6 7 6.5 7.5" fill="none" stroke="#0e0e11" stroke-width="3" stroke-linecap="round"/>
</svg>`;
const iconUp = await fetch(`${PDS}/xrpc/com.atproto.repo.uploadBlob`, {
  method: 'POST',
  headers: { ...auth, 'Content-Type': 'image/svg+xml' },
  body: ICON,
});
const icon = (await iconUp.json()).blob;

const deployed = await xrpc(
  `com.atproto.repo.getRecord?repo=${DID}&collection=${SITES}&rkey=${SITE}`,
);
await put('dev.pdsjs.app.manifest', 'drop', {
  name: 'Drop',
  description:
    'An image host in your own repo: drag, paste or choose a file and the link copies itself. Images are blobs served immutably from your site, so the link works anywhere an image URL does.',
  icon,
  site: { uri: deployed.uri, cid: deployed.cid },
  suggestedSite: 'drop',
  primitives: ['serve', 'query'],
  external: false,
  queries: [
    {
      name: 'drop-images',
      collection: IMAGES,
      sort: { field: 'createdAt', direction: 'desc' },
    },
  ],
  settings: [
    {
      key: 'gallery',
      label: 'Show every image to visitors',
      type: 'boolean',
      default: true,
    },
  ],
  createdAt: new Date().toISOString(),
});
console.error('Published dev.pdsjs.app.manifest/drop.');