Compare commits

..
3 Commits
Author SHA1 Message Date
travandClaude Opus 5 09798f7146 Drop the "You are reading content from Scuttlebutt" banner
Upstream ssb-viewer printed it above every page, aimed at people who had
arrived at a stray scuttlebutt message and needed context. On cust.ooo the
context is the site itself, so it was explaining the wrong thing.

Removes the function, all four call sites and the now-dead .top-tip CSS.

Adds a "Changing how pages look" section to the README, since the real
question was where this lives. There is no template directory - pages are
built as strings in render.js with one <style> block - so the answer is
"grep for the text you can see", and the table maps each visible piece to the
function that emits it. It also records the two things that will bite an
editor: hyperscript silently drops attributes it mistakes for DOM properties
(why loading="lazy" uses setAttribute), and only text passed through h() is
escaped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0192zBTNZKZn5svyJ5HTnYds
2026-08-22 20:39:11 -04:00
travandClaude Opus 5 fc9539a618 /items shows items only, not custody transfers
A `nft: "give"` message is a change of custody, not a thing. It has no photo of
its own, so it borrowed the photo of the item it transferred - which meant the
same object appeared on the grid twice, a few cards apart, distinguished only
by a dimmed image and an id overlay. That reads as a duplicate, not as a
hand-off.

Custody is better answered one level down. Each card already links to its
item's thread, where the mint and every hand-off since are in order, with the
steward resolved. So the overlay was showing a truncated feed id on the grid to
save a click that is worth making.

Filters on nft === "mint" rather than excluding gives: every custodisco message
carries one of the two - checked across all 407 - so matching mint is exact.
301 items from 3 feeds, down from 407 entries.

The feed count now counts feeds that actually contributed an item rather than
the size of the follow set. The pub follows itself and publishes no items, so
the old number was one too high and would have drifted further as feeds get
followed for other reasons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0192zBTNZKZn5svyJ5HTnYds
2026-08-22 20:30:46 -04:00
travandClaude Opus 5 cc0ad8c1a8 Add /items: one merged grid of every item the pub knows about
cust.ooo linked to one viewer page per kiosk, so every new kiosk meant
another hand-added link. /items selects on "whatever this pub follows"
instead: follow a new kiosk from the pub and its items appear on the next
cache miss, with no code change and no edit to the site.

serveUserFeed already reduced a feed's contact messages into a follow set;
that moves to lib/follows.js so both routes share one implementation rather
than drifting. It also picks up a fix on the way: the old filter passed
messages with no .value through and then dereferenced .value.content on them.

Two properties of the data drive items.js, both verified against the live log
and both quietly wrong to assume otherwise:

  * custodisco is the STRING "true", not a boolean.
  * no custo message has ever set content.channel, so the channel index finds
    nothing and the filter has to read content fields directly. This is why
    /channel/custodisco renders an empty page.

Log order is arrival order, which diverges from publish order whenever an old
feed is backfilled, so the collected set is sorted by timestamp. That makes
"newest first" and the ?before cursor agree; verified as zero overlap between
consecutive pages.

Cards rather than articles: 300 photos in a single column reads like a mailing
list, not an archive. A transfer borrows the photo of the item it transfers and
links to that item's thread, so the grid stays regular and the give is still
legible as a hand-off. Images are lazy, and 267 blobs are held against 301
mints, so a missing photo degrades to a placeholder instead of a broken icon.

serveHome now shows the grid instead of a bare id-lookup box, which was a dead
end for anyone arriving without an id already in hand. The ?id= redirect stays.

The footer commit now comes from .deployed-commit, written by the deploy
script. Deploys are rsync, so the server's checkout keeps whatever HEAD it was
cloned at and `git rev-parse` there names a commit unrelated to the files
actually running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0192zBTNZKZn5svyJ5HTnYds
2026-08-22 19:40:27 -04:00
6 changed files with 460 additions and 83 deletions
+1
View File
@@ -1 +1,2 @@
node_modules/ node_modules/
.deployed-commit
+46
View File
@@ -42,6 +42,52 @@ Two things to know before writing a query against this:
Messages are published by the kiosks (`/home/trav/custodisco-kiosk/ssb-post.sh`), not by Messages are published by the kiosks (`/home/trav/custodisco-kiosk/ssb-post.sh`), not by
this viewer. The viewer is read-only. this viewer. The viewer is read-only.
## Changing how pages look
Everything visual is in **`render.js`**. There is no template directory and no CSS
files to hunt through — pages are built as strings and there is exactly one `<style>`
block, near the middle of the file.
Find things by searching for the text you can see on the page. `grep -n` on a phrase
from the rendered page lands you on the line that produces it:
```bash
grep -n "Join Scuttlebutt" render.js
```
The pieces, in the order you meet them on a page:
| What you see | Where |
|---|---|
| `<title>` and the whole `<head>` | `wrapPage()` |
| "custo items", the count, the search box | `itemsHeader()` |
| an item card: photo, caption, kiosk, time | `renderItemCard()` |
| "older items" / "show everything" | `itemsFooter()` |
| the "Join Scuttlebutt now" button | `callToAction()` |
| licence, repo link, deployed commit | the `footer` constant |
| all colours, spacing, the grid itself | the `styles` template string |
The grid rules are the `.item-*` selectors in `styles`. Card width is one number —
`minmax(190px, 1fr)` in `main.item-grid` — raise it for fewer, larger cards.
Two gotchas worth knowing before you edit:
- **Attributes set through `h()` can vanish.** hyperscript assigns anything that looks
like a DOM property rather than an attribute, and `outerHTML` then drops it. That is
why `loading="lazy"` is applied with `media.setAttribute(...)` instead. If an
attribute you added does not appear in the output, this is why.
- **Text passed to `h()` is escaped; strings concatenated into `wrap()` are not.**
Anything derived from a message must go through `h()`.
To see a change: edit, then from the ops repo
```bash
bin/deploy-viewer.sh --yes
```
which syncs, restarts the viewer and checks `/items` answers before it returns. Commit
and push separately — the deploy only moves files.
## Running it ## Running it
`bin.js` connects to a local `ssb-server` and serves on `conf.viewer.port` (8807). `bin.js` connects to a local `ssb-server` and serves on `conf.viewer.port` (8807).
+36 -59
View File
@@ -24,7 +24,10 @@ var {
renderShowAll, renderShowAll,
renderRssItem, renderRssItem,
wrapRss, wrapRss,
renderItemGrid,
} = require('./render') } = require('./render')
var getFollows = require('./lib/follows')
var createItemsHandler = require('./items')
var appHash = hash([fs.readFileSync(__filename)]) var appHash = hash([fs.readFileSync(__filename)])
@@ -112,6 +115,16 @@ exports.init = function (sbot, config) {
var getAbout = memo({cache: lru(100)}, require('./lib/about'), sbot) var getAbout = memo({cache: lru(100)}, require('./lib/about'), sbot)
var serveAcmeChallenge = require('ssb-acme-validator')(sbot) var serveAcmeChallenge = require('ssb-acme-validator')(sbot)
var serveItems = createItemsHandler({
sbot: sbot,
defaultOpts: defaultOpts,
addAuthorAbout: addAuthorAbout,
renderItemGrid: renderItemGrid,
wrapPage: wrapPage,
respond: respond,
toPull: toPull,
})
http.createServer(serve).listen(port, host, function () { http.createServer(serve).listen(port, host, function () {
if (/:/.test(host)) host = '[' + host + ']' if (/:/.test(host)) host = '[' + host + ']'
console.log('[viewer] Listening on http://' + host + ':' + port) console.log('[viewer] Listening on http://' + host + ':' + port)
@@ -127,6 +140,7 @@ exports.init = function (sbot, config) {
if (m[4] === '/robots.txt') return serveRobots(req, res, conf) if (m[4] === '/robots.txt') return serveRobots(req, res, conf)
if (req.url.startsWith('/static/')) return serveStatic(req, res, m[4]) if (req.url.startsWith('/static/')) return serveStatic(req, res, m[4])
if (req.url.startsWith('/emoji/')) return serveEmoji(req, res, m[4]) if (req.url.startsWith('/emoji/')) return serveEmoji(req, res, m[4])
if (m[4] === '/items') return serveItems(req, res, m[5])
if (req.url.startsWith('/user-feed/')) return serveUserFeed(req, res, m[4]) if (req.url.startsWith('/user-feed/')) return serveUserFeed(req, res, m[4])
else if (req.url.startsWith('/channel/')) return serveChannel(req, res, m[4]) else if (req.url.startsWith('/channel/')) return serveChannel(req, res, m[4])
else if (req.url.startsWith('/.well-known/acme-challenge')) return serveAcmeChallenge(req, res) else if (req.url.startsWith('/.well-known/acme-challenge')) return serveAcmeChallenge(req, res)
@@ -146,6 +160,23 @@ exports.init = function (sbot, config) {
return respond(res, 404, 'Not found') return respond(res, 404, 'Not found')
} }
// The bare id-lookup form this used to render was a dead end for anyone
// arriving without an id already in hand. Keep the ?id= redirect, then show
// the item grid instead of an empty box.
function serveHome(req, res, query) {
var q = query ? qs.parse(query) : {}
var id = asLink(q.id)
if (id) {
res.writeHead(303, {
Location: '/' + (
id[0] === '#' ? 'channel/' + id.substr(1) :
refs.isMsgId(id) ? encodeURIComponent(id) : id)
})
return res.end()
}
return serveItems(req, res, query)
}
function serveFeed(req, res, feedId, ext) { function serveFeed(req, res, feedId, ext) {
console.log('serving feed: ' + feedId) console.log('serving feed: ' + feedId)
@@ -226,40 +257,12 @@ exports.init = function (sbot, config) {
var feedId = url.substring(url.lastIndexOf('user-feed/')+10, 100) var feedId = url.substring(url.lastIndexOf('user-feed/')+10, 100)
console.log('serving user feed: ' + feedId) console.log('serving user feed: ' + feedId)
var following = []
var channelSubscriptions = []
getAbout(feedId, function (err, about) { getAbout(feedId, function (err, about) {
pull( getFollows(sbot, feedId, function (err, sets) {
sbot.createUserStream({ id: feedId }), if (err) return respond(res, 500, err.stack || err)
pull.filter((msg) => { serveFeeds(req, res, sets.following, sets.channelSubscriptions, feedId,
return !msg.value || 'user feed ' + (about ? about.name : ''))
msg.value.content.type == 'contact' || })
(msg.value.content.type == 'channel' &&
typeof msg.value.content.subscribed != 'undefined')
}),
pull.collect(function (err, msgs) {
msgs.forEach((msg) => {
if (msg.value.content.type == 'contact')
{
if (msg.value.content.following)
following[msg.value.content.contact] = 1
else
delete following[msg.value.content.contact]
}
else // channel subscription
{
if (msg.value.content.subscribed)
channelSubscriptions[msg.value.content.channel] = 1
else
delete channelSubscriptions[msg.value.content.channel]
}
})
serveFeeds(req, res, following, channelSubscriptions, feedId,
'user feed ' + (about ? about.name : ''))
})
)
}) })
} }
@@ -613,32 +616,6 @@ function asLink(id) {
if (refs.isLink(id)) return id if (refs.isLink(id)) return id
} }
function serveHome(req, res, query, conf) {
var q = query ? qs.parse(query) : {}
var id = asLink(q.id)
if (id) {
res.writeHead(303, {
Location: '/' + (
id[0] === '#' ? 'channel/' + id.substr(1) :
refs.isMsgId(id) ? encodeURIComponent(id) : id)
})
return res.end()
}
res.writeHead(200, {
'Content-Type': 'text/html'
})
pull(
pull.once(h('form', {method: 'get', action: ''},
h('input', {name: 'id', placeholder: 'id', size: 60, value: q.id || ''}), ' ',
h('input', {type: 'submit', value: 'Go'})
).outerHTML),
wrapPage('ssb-viewer'),
toPull(res, function (err) {
if (err) console.error('[viewer]', err)
})
)
}
function serveRobots(req, res, conf) { function serveRobots(req, res, conf) {
var disallow = conf.disallowRobots == null ? true : conf.disallowRobots var disallow = conf.disallowRobots == null ? true : conf.disallowRobots
res.end('User-agent: *\n' res.end('User-agent: *\n'
+162
View File
@@ -0,0 +1,162 @@
var pull = require('pull-stream')
var paramap = require('pull-paramap')
var qs = require('querystring')
var getFollows = require('./lib/follows')
// One merged view of every custo item the pub knows about.
//
// The selection rule is deliberately "whatever this pub follows", not a list of
// kiosk feed ids: adding a kiosk means following it from the pub, and its items
// appear here on the next cache miss. No code change, no edit to cust.ooo.
//
// ITEMS ONLY. A `nft: "give"` message is a change of custody, not a thing - it
// has no photo of its own and describes an item already on the page, so showing
// it here listed the same object twice. Custody is answered one level down: the
// card links to the item's thread, where the mint and every hand-off since are
// in order.
//
// Two facts about the data that this file depends on, both verified against the
// live log and both easy to get wrong:
// * `custodisco` is the STRING "true", not a boolean.
// * no custo message has ever set `content.channel`, so the channel index is
// useless here and the filter has to look at content fields directly.
var CACHE_MS = 60 * 1000
var PAGE_SIZE = 60
var IMAGE_MD = /!\[[^\]]*\]\((&[A-Za-z0-9+/]{43}=\.sha256)\)/
// The item's photo: a mints's first image mention, falling back to the markdown
// embed in the text for messages whose mentions array is missing or unhelpful.
function itemPhoto (c) {
if (c && Array.isArray(c.mentions)) {
for (var i = 0; i < c.mentions.length; i++) {
var m = c.mentions[i]
if (!m || typeof m.link !== 'string' || m.link[0] !== '&') continue
if (typeof m.type === 'string' && !/^image\//.test(m.type)) continue
return m.link
}
}
var md = IMAGE_MD.exec(String((c && c.text) || ''))
return md ? md[1] : null
}
// Strip the parts of the post text that are furniture rather than description:
// the image embed itself, and the kiosk's " a #custodisco item " sign-off.
function itemCaption (c) {
return String((c && c.text) || '')
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
.replace(/\s*a\s+#custodisco\s+item\s*$/i, ' ')
.replace(/\s+/g, ' ')
.trim()
}
// Every custodisco message carries nft: "mint" or "give" - checked across all
// 407 of them, none lacks the field - so matching "mint" is exact rather than
// merely excluding gives.
function isItem (msg) {
var c = msg && msg.value && msg.value.content
return !!c && typeof c === 'object' &&
c.custodisco === 'true' && c.nft === 'mint'
}
// ctx supplies the pieces that live in index.js's init closure:
// sbot, defaultOpts, addAuthorAbout, respond, toPull, renderItemGrid, wrapPage
module.exports = function createItemsHandler (ctx) {
var cache = null // { at, msgs, feeds }
// A full log scan takes well under a second on this node (11k messages,
// 360 KB), so this cache exists to keep a page refresh from redoing it, not
// because the scan is expensive.
function load (cb) {
if (cache && Date.now() - cache.at < CACHE_MS) return cb(null, cache)
ctx.sbot.whoami(function (err, feed) {
if (err) return cb(err)
var me = feed.id
getFollows(ctx.sbot, me, function (err, sets) {
if (err) return cb(err)
var authors = sets.following
authors[me] = true // the pub's own items count too
pull(
ctx.sbot.createLogStream({ reverse: true }),
pull.filter(function (msg) {
return isItem(msg) && authors[msg.value.author] === true
}),
pull.collect(function (err, msgs) {
if (err) return cb(err)
// Log order is arrival order, which diverges from publish order
// whenever an old feed is backfilled. Sort so "newest first" and
// the ?before cursor both mean the same thing.
msgs.sort(function (a, b) { return b.value.timestamp - a.value.timestamp })
// Count the feeds that actually contributed an item, not the size
// of the follow set: the pub follows itself and publishes no items,
// so the follow set overstates it by one and would keep drifting
// as feeds are followed for other reasons.
var contributing = Object.create(null)
msgs.forEach(function (m) { contributing[m.value.author] = true })
cache = {
at: Date.now(),
msgs: msgs,
feeds: Object.keys(contributing).length
}
cb(null, cache)
})
)
})
})
}
function addItemContext (msg, cb) {
var c = msg.value.content
msg.itemPhoto = itemPhoto(c)
msg.itemCaption = itemCaption(c)
cb(null, msg)
}
return function serveItems (req, res, query) {
var q = query ? qs.parse(query) : {}
var showAll = 'showAll' in q
var before = q.before ? Number(q.before) : null
load(function (err, data) {
if (err) return ctx.respond(res, 500, err.stack || err)
var msgs = data.msgs
if (before) {
msgs = msgs.filter(function (m) { return m.value.timestamp < before })
}
var page = showAll ? msgs : msgs.slice(0, PAGE_SIZE)
var last = page[page.length - 1]
var older = (!showAll && msgs.length > page.length && last)
? last.value.timestamp
: null
res.writeHead(200, { 'Content-Type': 'text/html' })
pull(
pull.values(page),
paramap(ctx.addAuthorAbout, 8),
paramap(addItemContext, 8),
pull(
ctx.renderItemGrid(ctx.defaultOpts, {
total: data.msgs.length,
shown: page.length,
feeds: data.feeds,
older: older,
showAll: showAll
}),
ctx.wrapPage('items')
),
ctx.toPull(res, function (err) {
if (err) console.error('[viewer]', err)
})
)
})
}
}
module.exports.itemPhoto = itemPhoto
module.exports.itemCaption = itemCaption
module.exports.isItem = isItem
+38
View File
@@ -0,0 +1,38 @@
var pull = require('pull-stream')
// Reduce a feed's own contact and channel-subscription messages into the sets
// they describe. createUserStream runs oldest-first, so a later unfollow
// correctly undoes an earlier follow.
//
// Both /user-feed/ and /items are built on this: "who does this feed listen
// to" is the whole selection rule, which is what lets a new kiosk show up by
// being followed rather than by being added to a list somewhere.
module.exports = function getFollows (sbot, feedId, cb) {
var following = Object.create(null)
var channelSubscriptions = Object.create(null)
pull(
sbot.createUserStream({ id: feedId }),
pull.filter(function (msg) {
var c = msg && msg.value && msg.value.content
if (!c || typeof c !== 'object') return false // also skips private (string) content
return c.type === 'contact' ||
(c.type === 'channel' && typeof c.subscribed !== 'undefined')
}),
pull.drain(function (msg) {
var c = msg.value.content
if (c.type === 'contact') {
if (!c.contact) return
if (c.following) following[c.contact] = true
else delete following[c.contact]
} else {
if (!c.channel) return
if (c.subscribed) channelSubscriptions[c.channel] = true
else delete channelSubscriptions[c.channel]
}
}, function (err) {
if (err) return cb(err)
cb(null, { following: following, channelSubscriptions: channelSubscriptions })
})
)
}
+177 -24
View File
@@ -17,6 +17,7 @@ exports.MdRenderer = MdRenderer
exports.renderEmoji = renderEmoji exports.renderEmoji = renderEmoji
exports.formatMsgs = formatMsgs exports.formatMsgs = formatMsgs
exports.renderThread = renderThread exports.renderThread = renderThread
exports.renderItemGrid = renderItemGrid
exports.renderAbout = renderAbout exports.renderAbout = renderAbout
exports.renderShowAll = renderShowAll exports.renderShowAll = renderShowAll
exports.renderRssItem = renderRssItem exports.renderRssItem = renderRssItem
@@ -119,24 +120,31 @@ function wrap(before, after) {
} }
} }
// ---------------------------------------------------------------------------
// Page furniture. These are the bits of chrome wrapped around every page, and
// they are the things most likely to want changing.
//
// callToAction() the "Join Scuttlebutt now" button at the foot of a page
// footer just below it: licence, repo name, deployed commit
// styles the single <style> block; .item-* rules are the grid
// itemsHeader() the /items title, count and search box
//
// Removed already: toolTipTop(), which printed "You are reading content from
// Scuttlebutt" above every page. It is in the git history if it is ever wanted
// back.
// ---------------------------------------------------------------------------
function callToAction() { function callToAction() {
return h('a.call-to-action', return h('a.call-to-action',
{ href: 'https://www.scuttlebutt.nz' }, { href: 'https://www.scuttlebutt.nz' },
'Join Scuttlebutt now').outerHTML 'Join Scuttlebutt now').outerHTML
} }
function toolTipTop() {
return h('span.top-tip',
'You are reading content from ',
h('a', { href: 'https://www.scuttlebutt.nz' },
'Scuttlebutt')).outerHTML
}
function renderAbout(opts, about, showAllHTML = "") { function renderAbout(opts, about, showAllHTML = "") {
if (about.publicWebHosting === false || (about.publicWebHosting == null && opts.requireOptIn)) { if (about.publicWebHosting === false || (about.publicWebHosting == null && opts.requireOptIn)) {
return pull( return pull(
pull.map(renderMsg.bind(this, opts, '')), pull.map(renderMsg.bind(this, opts, '')),
wrap(toolTipTop() + '<main>', '</main>' + callToAction()) wrap('<main>', '</main>' + callToAction())
) )
} }
@@ -144,7 +152,7 @@ function renderAbout(opts, about, showAllHTML = "") {
figCaption.innerHTML = 'Feed of ' + escape(about.name) + '<br>' + marked(String(about.description || ''), opts.marked) figCaption.innerHTML = 'Feed of ' + escape(about.name) + '<br>' + marked(String(about.description || ''), opts.marked)
return pull( return pull(
pull.map(renderMsg.bind(this, opts, '')), pull.map(renderMsg.bind(this, opts, '')),
wrap(toolTipTop() + '<main>' + wrap('<main>' +
h('article', h('article',
h('header', h('header',
h('figure', h('figure',
@@ -161,7 +169,7 @@ function renderAbout(opts, about, showAllHTML = "") {
function renderThread(opts, id, showAllHTML = "") { function renderThread(opts, id, showAllHTML = "") {
return pull( return pull(
pull.map(renderMsg.bind(this, opts, id)), pull.map(renderMsg.bind(this, opts, id)),
wrap(toolTipTop() + '<main>', wrap('<main>',
showAllHTML + '</main>' + callToAction()) showAllHTML + '</main>' + callToAction())
) )
} }
@@ -172,10 +180,21 @@ function renderRssItem(opts) {
) )
} }
const gitHead = proc.spawnSync('git', ['rev-parse', 'HEAD'], { // Which commit is actually running. Deploys are rsync, not `git pull`, so the
encoding: 'utf8', // checkout on the server keeps whatever HEAD it was cloned at and `git
cwd: __dirname // rev-parse` there would name a commit that has nothing to do with these files.
}).stdout.trim() // bin/deploy-viewer.sh writes the real one to .deployed-commit; fall back to
// git only when running straight from a working copy.
const gitHead = (function () {
try {
var stamped = fs.readFileSync(path.join(__dirname, '.deployed-commit'), 'utf8').trim()
if (stamped) return stamped
} catch (e) { /* not a deploy, ask git */ }
return proc.spawnSync('git', ['rev-parse', 'HEAD'], {
encoding: 'utf8',
cwd: __dirname
}).stdout.trim()
}())
const gitHeadShort = gitHead && gitHead.substr(0, 7) const gitHeadShort = gitHead && gitHead.substr(0, 7)
const commitUrl = pkg.homepage && const commitUrl = pkg.homepage &&
pkg.homepage.replace(/\/+$/, '') + '/commit/' + gitHead pkg.homepage.replace(/\/+$/, '') + '/commit/' + gitHead
@@ -192,7 +211,7 @@ function wrapPage(id) {
"<!doctype html><html><head>" + "<!doctype html><html><head>" +
"<meta charset=utf-8>" + "<meta charset=utf-8>" +
"<title>" + "<title>" +
id + " | ssb-viewer" + id + " | custo" +
"</title>" + "</title>" +
'<meta name=viewport content="width=device-width,initial-scale=1">' + '<meta name=viewport content="width=device-width,initial-scale=1">' +
styles + styles +
@@ -227,15 +246,6 @@ var styles = `
} }
a { color: #364fc7; } a { color: #364fc7; }
.top-tip, .top-tip a {
color: #868e96;
}
.top-tip {
text-align: center;
display: block;
margin-bottom: 10px;
font-size: 14px;
}
main { margin: 0 auto; max-width: 40rem; } main { margin: 0 auto; max-width: 40rem; }
main article:first-child { border-radius: 3px 3px 0 0; } main article:first-child { border-radius: 3px 3px 0 0; }
main article:last-child { border-radius: 0 0 3px 3px; } main article:last-child { border-radius: 0 0 3px 3px; }
@@ -339,6 +349,65 @@ var styles = `
font-size: 14px; font-size: 14px;
color: #868e96; color: #868e96;
} }
.items-header { max-width: 60rem; margin: 0 auto 24px; text-align: center; }
.items-header h1 { margin: 0 0 4px; font-size: 1.6em; letter-spacing: 0.04em; }
.items-count { margin: 0 0 14px; color: #868e96; font-size: 14px; }
.items-search input[type=submit] { cursor: pointer; }
main.item-grid {
max-width: 60rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 14px;
}
/* the single-column rules above target articles; cards are anchors */
.item-card {
display: flex;
flex-direction: column;
background-color: white;
border-radius: 3px;
box-shadow: 0 1px 3px #949494;
overflow: hidden;
text-decoration: none;
color: #212529;
}
.item-card:hover { box-shadow: 0 2px 8px #6c757d; }
.item-media { position: relative; }
.item-photo {
display: block;
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
background-color: #e9ecef;
}
.item-photo-missing {
display: flex;
align-items: center;
justify-content: center;
color: #adb5bd;
font-size: 12px;
text-align: center;
}
.item-photo-missing::after { content: "photo not fetched yet"; }
.item-meta { padding: 10px 12px; display: flex; flex-direction: column; gap: 3px; }
.item-caption {
line-height: 1.35em;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.item-by { color: #495057; font-size: 13px; }
.item-meta time { color: #868e96; font-size: 12px; }
.items-more {
max-width: 60rem;
margin: 20px auto 0;
text-align: center;
display: flex;
gap: 20px;
justify-content: center;
}
</style> </style>
` `
@@ -633,3 +702,87 @@ function renderShowAll(showAll, url) {
if (!showAll) if (!showAll)
return '<br>' + h('a', { href : url + '?showAll' }, 'Show whole feed').outerHTML return '<br>' + h('a', { href : url + '?showAll' }, 'Show whole feed').outerHTML
} }
// ---------------------------------------------------------------------------
// The custo item grid (/items)
//
// Cards, not articles: this is a collection of things, and a single-column feed
// of 300 photos reads like a mailing list rather than an archive. Items only -
// who holds an item is answered by its thread, one click in, not by an overlay
// on the grid.
// ---------------------------------------------------------------------------
function renderItemGrid(opts, meta) {
return pull(
pull.map(renderItemCard.bind(this, opts)),
wrap(itemsHeader(opts, meta) + '<main class="item-grid">',
'</main>' + itemsFooter(opts, meta) + callToAction())
)
}
function itemsHeader(opts, meta) {
var counted = meta.total === 1 ? '1 item' : meta.total + ' items'
var feeds = meta.feeds === 1 ? '1 feed' : meta.feeds + ' feeds'
return h('header.items-header',
h('h1', 'custo items'),
h('p.items-count', counted + ' from ' + feeds),
h('form.items-search', { method: 'get', action: opts.base },
h('input', { name: 'id', size: 34,
placeholder: 'paste a message or feed id' }), ' ',
h('input', { type: 'submit', value: 'Go' }))
).outerHTML
}
function itemsFooter(opts, meta) {
var links = []
if (meta.older)
links.push(h('a', { href: opts.base + 'items?before=' + meta.older }, 'older items'))
if (!meta.showAll && meta.total > meta.shown)
links.push(h('a', { href: opts.base + 'items?showAll' }, 'show everything'))
if (!links.length) return ''
return h('div.items-more', links).outerHTML
}
function renderItemCard(opts, msg) {
var c = msg.value.content
var media
if (msg.itemPhoto) {
media = h('img.item-photo', { src: opts.img_base + msg.itemPhoto, alt: '' })
// hyperscript assigns these as DOM properties, which outerHTML then drops,
// so they have to be set as attributes explicitly.
media.setAttribute('loading', 'lazy')
media.setAttribute('decoding', 'async')
// 267 blobs are held against 301 mints, so some photos genuinely are not
// here yet. Fall back to the placeholder rather than a broken-image icon.
media.setAttribute('onerror',
"this.className='item-photo item-photo-missing';this.removeAttribute('src')")
} else {
media = h('div.item-photo.item-photo-missing')
}
var caption = msg.itemCaption || itemFallbackCaption(c)
// Links to the item's own thread: the mint, and every hand-off since.
return h('a.item-card',
{ href: opts.base + encodeURIComponent(msg.key) },
h('div.item-media', media),
h('div.item-meta',
h('span.item-caption', caption),
h('span.item-by', msg.author && msg.author.name),
itemTime(msg))
).outerHTML
}
function itemFallbackCaption(c) {
return String(c.text || '').replace(/\s+/g, ' ').trim().substr(0, 140) || 'untitled'
}
// Like msgTimestamp, but without the nested <a> \u2014 the whole card is a link.
function itemTime(msg) {
var date = new Date(msg.value.timestamp)
var isoStr = date.toISOString()
return h('time.ssb-timestamp',
{ datetime: isoStr, title: isoStr },
formatDate(date))
}