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