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
This commit is contained in:
@@ -1 +1,2 @@
|
||||
node_modules/
|
||||
.deployed-commit
|
||||
|
||||
@@ -24,7 +24,10 @@ var {
|
||||
renderShowAll,
|
||||
renderRssItem,
|
||||
wrapRss,
|
||||
renderItemGrid,
|
||||
} = require('./render')
|
||||
var getFollows = require('./lib/follows')
|
||||
var createItemsHandler = require('./items')
|
||||
|
||||
var appHash = hash([fs.readFileSync(__filename)])
|
||||
|
||||
@@ -112,6 +115,18 @@ exports.init = function (sbot, config) {
|
||||
var getAbout = memo({cache: lru(100)}, require('./lib/about'), sbot)
|
||||
var serveAcmeChallenge = require('ssb-acme-validator')(sbot)
|
||||
|
||||
var serveItems = createItemsHandler({
|
||||
sbot: sbot,
|
||||
defaultOpts: defaultOpts,
|
||||
getMsg: getMsg,
|
||||
getAbout: getAbout,
|
||||
addAuthorAbout: addAuthorAbout,
|
||||
renderItemGrid: renderItemGrid,
|
||||
wrapPage: wrapPage,
|
||||
respond: respond,
|
||||
toPull: toPull,
|
||||
})
|
||||
|
||||
http.createServer(serve).listen(port, host, function () {
|
||||
if (/:/.test(host)) host = '[' + host + ']'
|
||||
console.log('[viewer] Listening on http://' + host + ':' + port)
|
||||
@@ -127,6 +142,7 @@ exports.init = function (sbot, config) {
|
||||
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('/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])
|
||||
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)
|
||||
@@ -146,6 +162,23 @@ exports.init = function (sbot, config) {
|
||||
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) {
|
||||
console.log('serving feed: ' + feedId)
|
||||
|
||||
@@ -226,40 +259,12 @@ exports.init = function (sbot, config) {
|
||||
var feedId = url.substring(url.lastIndexOf('user-feed/')+10, 100)
|
||||
console.log('serving user feed: ' + feedId)
|
||||
|
||||
var following = []
|
||||
var channelSubscriptions = []
|
||||
|
||||
getAbout(feedId, function (err, about) {
|
||||
pull(
|
||||
sbot.createUserStream({ id: feedId }),
|
||||
pull.filter((msg) => {
|
||||
return !msg.value ||
|
||||
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 : ''))
|
||||
})
|
||||
)
|
||||
getFollows(sbot, feedId, function (err, sets) {
|
||||
if (err) return respond(res, 500, err.stack || err)
|
||||
serveFeeds(req, res, sets.following, sets.channelSubscriptions, feedId,
|
||||
'user feed ' + (about ? about.name : ''))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -613,32 +618,6 @@ function asLink(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) {
|
||||
var disallow = conf.disallowRobots == null ? true : conf.disallowRobots
|
||||
res.end('User-agent: *\n'
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
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.
|
||||
//
|
||||
// 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()
|
||||
}
|
||||
|
||||
function isCustodisco (msg) {
|
||||
var c = msg && msg.value && msg.value.content
|
||||
return !!c && typeof c === 'object' && c.custodisco === 'true'
|
||||
}
|
||||
|
||||
// ctx supplies the pieces that live in index.js's init closure:
|
||||
// sbot, defaultOpts, getMsg, getAbout, 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 isCustodisco(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 })
|
||||
cache = {
|
||||
at: Date.now(),
|
||||
msgs: msgs,
|
||||
feeds: Object.keys(authors).length
|
||||
}
|
||||
cb(null, cache)
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// A transfer carries no photo of its own, so borrow the parent item's and
|
||||
// resolve the new steward's display name. Both lookups go through the LRU
|
||||
// memos index.js already keeps.
|
||||
function addTransferContext (msg, cb) {
|
||||
var c = msg.value.content
|
||||
if (c.nft !== 'give') {
|
||||
msg.itemPhoto = itemPhoto(c)
|
||||
msg.itemCaption = itemCaption(c)
|
||||
return cb(null, msg)
|
||||
}
|
||||
|
||||
var pending = 2
|
||||
function done () {
|
||||
if (--pending) return
|
||||
cb(null, msg)
|
||||
}
|
||||
|
||||
if (c.root) {
|
||||
ctx.getMsg(c.root, function (err, root) {
|
||||
var rc = root && root.value && root.value.content
|
||||
if (rc) {
|
||||
msg.itemPhoto = itemPhoto(rc)
|
||||
msg.itemCaption = itemCaption(rc)
|
||||
}
|
||||
done()
|
||||
})
|
||||
} else done()
|
||||
|
||||
if (c.target) {
|
||||
ctx.getAbout(c.target, function (err, about) {
|
||||
if (about) msg.stewardAbout = about
|
||||
done()
|
||||
})
|
||||
} else done()
|
||||
}
|
||||
|
||||
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(addTransferContext, 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.isCustodisco = isCustodisco
|
||||
@@ -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 })
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -17,6 +17,7 @@ exports.MdRenderer = MdRenderer
|
||||
exports.renderEmoji = renderEmoji
|
||||
exports.formatMsgs = formatMsgs
|
||||
exports.renderThread = renderThread
|
||||
exports.renderItemGrid = renderItemGrid
|
||||
exports.renderAbout = renderAbout
|
||||
exports.renderShowAll = renderShowAll
|
||||
exports.renderRssItem = renderRssItem
|
||||
@@ -172,10 +173,21 @@ function renderRssItem(opts) {
|
||||
)
|
||||
}
|
||||
|
||||
const gitHead = proc.spawnSync('git', ['rev-parse', 'HEAD'], {
|
||||
encoding: 'utf8',
|
||||
cwd: __dirname
|
||||
}).stdout.trim()
|
||||
// Which commit is actually running. Deploys are rsync, not `git pull`, so the
|
||||
// checkout on the server keeps whatever HEAD it was cloned at and `git
|
||||
// rev-parse` there would name a commit that has nothing to do with these files.
|
||||
// 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 commitUrl = pkg.homepage &&
|
||||
pkg.homepage.replace(/\/+$/, '') + '/commit/' + gitHead
|
||||
@@ -192,7 +204,7 @@ function wrapPage(id) {
|
||||
"<!doctype html><html><head>" +
|
||||
"<meta charset=utf-8>" +
|
||||
"<title>" +
|
||||
id + " | ssb-viewer" +
|
||||
id + " | custo" +
|
||||
"</title>" +
|
||||
'<meta name=viewport content="width=device-width,initial-scale=1">' +
|
||||
styles +
|
||||
@@ -339,6 +351,77 @@ var styles = `
|
||||
font-size: 14px;
|
||||
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-card-give .item-photo { opacity: 0.55; }
|
||||
.item-badge {
|
||||
position: absolute;
|
||||
left: 0; right: 0; bottom: 0;
|
||||
padding: 6px 8px;
|
||||
background-color: rgba(33, 37, 41, 0.82);
|
||||
color: #f1f3f5;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.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>
|
||||
`
|
||||
|
||||
@@ -633,3 +716,99 @@ function renderShowAll(showAll, url) {
|
||||
if (!showAll)
|
||||
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. A transfer
|
||||
// borrows the photo of the item it transfers, so the grid stays regular.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderItemGrid(opts, meta) {
|
||||
return pull(
|
||||
pull.map(renderItemCard.bind(this, opts)),
|
||||
wrap(toolTipTop() + 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 and transfers'
|
||||
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 isGive = c.nft === 'give'
|
||||
|
||||
// A transfer links to the item it transfers, not to itself: the thread page
|
||||
// shows the mint and every hand-off since, which is the interesting view.
|
||||
var target = isGive && c.root ? c.root : msg.key
|
||||
|
||||
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 badge = isGive
|
||||
? h('span.item-badge', '\u2192 ' + stewardName(msg, c))
|
||||
: ''
|
||||
|
||||
var caption = msg.itemCaption || (isGive ? 'an item' : itemFallbackCaption(c))
|
||||
|
||||
return h('a.item-card' + (isGive ? '.item-card-give' : ''),
|
||||
{ href: opts.base + encodeURIComponent(target) },
|
||||
h('div.item-media', media, badge),
|
||||
h('div.item-meta',
|
||||
h('span.item-caption', caption),
|
||||
h('span.item-by', msg.author && msg.author.name),
|
||||
itemTime(msg))
|
||||
).outerHTML
|
||||
}
|
||||
|
||||
function stewardName(msg, c) {
|
||||
if (msg.stewardAbout && msg.stewardAbout.name) return msg.stewardAbout.name
|
||||
return String(c.target || 'someone').substr(0, 10) + '\u2026'
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user