Compare commits
3
Commits
996e45379a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09798f7146 | ||
|
|
fc9539a618 | ||
|
|
cc0ad8c1a8 |
@@ -1 +1,2 @@
|
||||
node_modules/
|
||||
.deployed-commit
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
`bin.js` connects to a local `ssb-server` and serves on `conf.viewer.port` (8807).
|
||||
|
||||
@@ -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,16 @@ 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,
|
||||
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 +140,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 +160,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 +257,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 +616,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,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
|
||||
@@ -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
|
||||
@@ -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() {
|
||||
return h('a.call-to-action',
|
||||
{ href: 'https://www.scuttlebutt.nz' },
|
||||
'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 = "") {
|
||||
if (about.publicWebHosting === false || (about.publicWebHosting == null && opts.requireOptIn)) {
|
||||
return pull(
|
||||
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)
|
||||
return pull(
|
||||
pull.map(renderMsg.bind(this, opts, '')),
|
||||
wrap(toolTipTop() + '<main>' +
|
||||
wrap('<main>' +
|
||||
h('article',
|
||||
h('header',
|
||||
h('figure',
|
||||
@@ -161,7 +169,7 @@ function renderAbout(opts, about, showAllHTML = "") {
|
||||
function renderThread(opts, id, showAllHTML = "") {
|
||||
return pull(
|
||||
pull.map(renderMsg.bind(this, opts, id)),
|
||||
wrap(toolTipTop() + '<main>',
|
||||
wrap('<main>',
|
||||
showAllHTML + '</main>' + callToAction())
|
||||
)
|
||||
}
|
||||
@@ -172,10 +180,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 +211,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 +
|
||||
@@ -227,15 +246,6 @@ var styles = `
|
||||
}
|
||||
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 article:first-child { border-radius: 3px 3px 0 0; }
|
||||
main article:last-child { border-radius: 0 0 3px 3px; }
|
||||
@@ -339,6 +349,65 @@ 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-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 +702,87 @@ 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. 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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user