'use strict' let callActive = false let callFaulty = false function call(method, params) { // XXX: At least with POST, unsuccessful requests result // in catched errors containing Errors with a null message. // This is an issue within XMLHttpRequest. callActive++ return m.request({ method: "POST", url: `/api/${method}`, body: params, }).then(result => { callActive-- callFaulty = false return result }).catch(error => { callActive-- callFaulty = true throw error }) } const loading = (window.location.hostname !== 'localhost') ? 'lazy' : undefined let Header = { global: [ {name: "Browse", route: '/browse'}, {name: "Tags", route: '/tags'}, {name: "Duplicates", route: '/duplicates'}, {name: "Orphans", route: '/orphans'}, ], image: [ { route: '/view', render: () => m(m.route.Link, { href: `/view/:key`, params: {key: m.route.param('key')}, class: m.route.get().startsWith('/view') ? 'active' : undefined, }, "View"), }, { route: '/similar', render: () => m(m.route.Link, { href: `/similar/:key`, params: {key: m.route.param('key')}, class: m.route.get().startsWith('/similar') ? 'active' : undefined, }, "Similar"), }, ], search: [ { route: '/search', render: () => m(m.route.Link, { href: `/search/:key`, params: {key: m.route.param('key')}, class: m.route.get().startsWith('/search') ? 'active' : undefined, }, "Search"), }, ], view(vnode) { const route = m.route.get() const main = this.global.map(x => m(m.route.Link, { href: x.route, class: route.startsWith(x.route) ? 'active' : undefined, }, x.name)) let context if (this.image.some(x => route.startsWith(x.route))) context = this.image.map(x => x.render()) if (this.search.some(x => route.startsWith(x.route))) context = this.search.map(x => x.render()) return m('.header', {}, [ m('nav', main), m('nav', context), callFaulty ? m('.activity.error[title=Error]', '●') : callActive ? m('.activity[title=Busy]', '●') : m('.activity[title=Idle]', '○'), ]) }, } let Thumbnail = { view(vnode) { const e = vnode.attrs.info if (!e.thumbW || !e.thumbH) return m('.thumbnail.missing', {...vnode.attrs, info: null}) return m('img.thumbnail', {...vnode.attrs, info: null, src: `/thumb/${e.sha1}`, width: e.thumbW, height: e.thumbH, loading}) }, } let ScoredTag = { view(vnode) { const {space, tagname, score} = vnode.attrs return m('li', [ m("meter[max=1.0]", {value: score, title: score}, score), ` `, m(m.route.Link, { href: `/search/:key`, params: {key: `${space}:${tagname}`}, }, ` ${tagname}`), ]) }, } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - let BrowseModel = { path: undefined, subdirectories: [], entries: [], collator: new Intl.Collator(undefined, {numeric: true}), async reload(path) { if (this.path !== path) { this.path = path this.subdirectories = [] this.entries = [] } let resp = await call('browse', {path}) this.subdirectories = resp.subdirectories this.entries = resp.entries.sort((a, b) => this.collator.compare(a.name, b.name)) }, joinPath(parent, child) { if (!parent) return child if (!child) return parent return `${parent}/${child}` }, getBrowseLinks() { if (this.path === undefined) return [] let links = [{name: "Root", path: "", level: -1}], path for (const crumb of this.path.split('/').filter(s => !!s)) { path = this.joinPath(path, crumb) links.push({name: crumb, path: path, level: -1}) } links[links.length - 1].level = 0 for (const sub of this.subdirectories) { links.push( {name: sub, path: this.joinPath(this.path, sub), level: +1}) } return links }, } let BrowseBarLink = { view(vnode) { const link = vnode.attrs.link let c = 'selected' if (link.level < 0) c = 'parent' if (link.level > 0) c = 'child' return m('li', { class: c, }, m(m.route.Link, { href: `/browse/:key`, params: {key: link.path}, }, link.name)) }, } let BrowseView = { // So that Page Up/Down, etc., work after changing directories. // Programmatically focusing a scrollable element requires setting tabindex, // and causes :focus-visible on page load, which we suppress in CSS. // I wish there was another way, but the workaround isn't particularly bad. // focus({focusVisible: true}) is FF 104+ only and experimental. oncreate(vnode) { vnode.dom.focus() }, view(vnode) { return m('.browser[tabindex=0]', { // Trying to force the oncreate on path changes. key: BrowseModel.path, }, BrowseModel.entries.map(info => { return m(m.route.Link, {href: `/view/${info.sha1}`}, m(Thumbnail, {info, title: info.name})) })) }, } let Browse = { // Reload the model immediately, to improve responsivity. // But we don't need to: https://mithril.js.org/route.html#preloading-data // Also see: https://mithril.js.org/route.html#route-cancellation--blocking oninit(vnode) { let path = vnode.attrs.key || "" BrowseModel.reload(path) }, view(vnode) { return m('.container', {}, [ m(Header), m('.body', {}, [ m('.sidebar', [ m('ul.path', BrowseModel.getBrowseLinks() .map(link => m(BrowseBarLink, {link}))), ]), m(BrowseView), ]), ]) }, } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - let TagsModel = { ns: null, namespaces: {}, async reload(ns) { if (this.ns !== ns) { this.ns = ns this.namespaces = {} } this.namespaces = await call('tags', {namespace: ns}) }, } let TagsList = { view(vnode) { // TODO: Make it possible to sort by count. const tags = Object.entries(vnode.attrs.tags) .sort(([a, b]) => a[0].localeCompare(b[0])) return (tags.length == 0) ? "No tags" : m("ul", tags.map(([name, count]) => m("li", [ m(m.route.Link, { href: `/search/:key`, params: {key: `${vnode.attrs.space}:${name}`}, }, ` ${name}`), ` ×${count}`, ]))) }, } let TagsView = { // See BrowseView. oncreate(vnode) { vnode.dom.focus() }, view(vnode) { // XXX: The empty-named tag namespace gets a bit shafted, // in particular in the router, as well as with its header. // Maybe we could refer to it by its numeric ID in routing. const names = Object.keys(TagsModel.namespaces) .sort((a, b) => a.localeCompare(b)) let children = (names.length == 0) ? "No namespaces" : names.map(space => { const ns = TagsModel.namespaces[space] return [ m("h2", space), ns.description ? m("p", ns.description) : [], m(TagsList, {space, tags: ns.tags}), ] }) return m('.tags[tabindex=0]', {}, children) }, } let Tags = { oninit(vnode) { let ns = vnode.attrs.key TagsModel.reload(ns) }, view(vnode) { return m('.container', {}, [ m(Header), m('.body', {}, m(TagsView)), ]) }, } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - let DuplicatesModel = { entries: [], async reload() { this.entries = await call('duplicates', {}) }, } let DuplicatesThumbnail = { view(vnode) { const info = vnode.attrs.info return [ m(m.route.Link, {href: `/similar/${info.sha1}`}, m(Thumbnail, {info})), (info.occurences != 1) ? ` ×${info.occurences}` : [], ] }, } let DuplicatesList = { // See BrowseView. oncreate(vnode) { vnode.dom.focus() }, view(vnode) { let children = (DuplicatesModel.entries.length == 0) ? "No duplicates" : DuplicatesModel.entries.map(group => m('.row', group.map(entry => m(DuplicatesThumbnail, {info: entry})))) return m('.duplicates[tabindex=0]', {}, children) }, } let Duplicates = { oninit(vnode) { DuplicatesModel.reload() }, view(vnode) { return m('.container', {}, [ m(Header), m('.body', {}, m(DuplicatesList)), ]) }, } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - let OrphansModel = { entries: [], async reload() { this.entries = await call('orphans', {}) }, } let OrphansReplacement = { view(vnode) { const info = vnode.attrs.info if (!info) return [] return [ ` → `, m(m.route.Link, {href: `/view/${info.sha1}`}, m(Thumbnail, {info})), `${info.tags} tags`, ] }, } let OrphansRow = { view(vnode) { const info = vnode.attrs.info return m('.row', [ // It might not load, but still allow tag viewing. m(m.route.Link, {href: `/view/${info.sha1}`}, m(Thumbnail, {info})), `${info.tags} tags`, m(OrphansReplacement, {info: info.replacement}), ]) }, } let OrphansList = { // See BrowseView. oncreate(vnode) { vnode.dom.focus() }, view(vnode) { let children = (OrphansModel.entries.length == 0) ? "No orphans" : OrphansModel.entries.map(info => [ m("h2", info.lastPath), m(OrphansRow, {info}), ]) return m('.orphans[tabindex=0]', {}, children) }, } let Orphans = { oninit(vnode) { OrphansModel.reload() }, view(vnode) { return m('.container', {}, [ m(Header), m('.body', {}, m(OrphansList)), ]) }, } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - let ViewModel = { sha1: undefined, width: 0, height: 0, paths: [], tags: {}, async reload(sha1) { if (this.sha1 !== sha1) { this.sha1 = sha1 this.width = this.height = 0 this.paths = [] this.tags = {} } let resp = await call('info', {sha1: sha1}) this.width = resp.width this.height = resp.height this.paths = resp.paths this.tags = resp.tags }, } let ViewBarBrowseLink = { view(vnode) { return m(m.route.Link, { href: `/browse/:key`, params: {key: vnode.attrs.path}, }, vnode.attrs.name) }, } let ViewBarPath = { view(vnode) { const parents = vnode.attrs.path.split('/') const basename = parents.pop() let result = [], path if (!parents.length) result.push(m(ViewBarBrowseLink, {path: "", name: "Root"}), "/") for (const crumb of parents) { path = BrowseModel.joinPath(path, crumb) result.push(m(ViewBarBrowseLink, {path, name: crumb}), "/") } result.push(basename) return result }, } let ViewBar = { view(vnode) { return m('.viewbar', [ m('h2', "Locations"), m('ul', ViewModel.paths.map(path => m('li', m(ViewBarPath, {path})))), m('h2', "Tags"), Object.entries(ViewModel.tags).map(([space, tags]) => [ m("h3", m(m.route.Link, {href: `/tags/${space}`}, space)), m("ul.tags", Object.entries(tags) .sort(([t1, w1], [t2, w2]) => (w2 - w1)) .map(([tag, score]) => m(ScoredTag, {space, tagname: tag, score}))), ]), ]) }, } let View = { oninit(vnode) { let sha1 = vnode.attrs.key || "" ViewModel.reload(sha1) }, view(vnode) { const view = m('.view', [ ViewModel.sha1 !== undefined ? m('img', {src: `/image/${ViewModel.sha1}`, width: ViewModel.width, height: ViewModel.height}) : "No image.", ]) return m('.container', {}, [ m(Header), m('.body', {}, [view, m(ViewBar)]), ]) }, } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - let SimilarModel = { sha1: undefined, info: {paths: []}, groups: {}, async reload(sha1) { if (this.sha1 !== sha1) { this.sha1 = sha1 this.info = {paths: []} this.groups = {} } let resp = await call('similar', {sha1: sha1}) this.info = resp.info this.groups = resp.groups }, } let SimilarThumbnail = { view(vnode) { const info = vnode.attrs.info return m(m.route.Link, {href: `/view/${info.sha1}`}, m(Thumbnail, {info})) }, } let SimilarGroup = { view(vnode) { const images = vnode.attrs.images let result = [ m('h2', vnode.attrs.name), images.map(info => m('.row', [ m(SimilarThumbnail, {info}), m('ul', [ m('li', Math.round(info.pixelsRatio * 100) + "% pixels of input image"), info.paths.map(path => m('li', m(ViewBarPath, {path}))), ]), ])) ] if (!images.length) result.push("No matches.") return result }, } let SimilarList = { view(vnode) { if (SimilarModel.sha1 === undefined || SimilarModel.info.paths.length == 0) return "No image" const info = SimilarModel.info return m('.similar', {}, [ m('.row', [ m(SimilarThumbnail, {info}), m('ul', info.paths.map(path => m('li', m(ViewBarPath, {path})))), ]), Object.entries(SimilarModel.groups).map(([name, images]) => m(SimilarGroup, {name, images})), ]) }, } let Similar = { oninit(vnode) { let sha1 = vnode.attrs.key || "" SimilarModel.reload(sha1) }, view(vnode) { return m('.container', {}, [ m(Header), m('.body', {}, m(SimilarList)), ]) }, } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - let SearchModel = { query: undefined, matches: [], related: {}, async reload(query) { if (this.query !== query) { this.query = query this.matches = [] this.related = {} } let resp = await call('search', {query}) this.matches = resp.matches this.related = resp.related }, } let SearchRelated = { view(vnode) { return Object.entries(SearchModel.related) .sort((a, b) => a[0].localeCompare(b[0])) .map(([space, tags]) => [ m('h2', space), m('ul.tags', tags .sort((a, b) => (b.score - a.score)) .map(({tag, score}) => m(ScoredTag, {space, tagname: tag, score}))), ]) }, } let SearchView = { // See BrowseView. oncreate(vnode) { vnode.dom.focus() }, view(vnode) { return m('.browser[tabindex=0]', { // Trying to force the oncreate on path changes. key: SearchModel.path, }, SearchModel.matches .sort((a, b) => b.score - a.score) .map(info => { return m(m.route.Link, {href: `/view/${info.sha1}`}, m(Thumbnail, {info, title: info.score})) })) }, } let Search = { oninit(vnode) { SearchModel.reload(vnode.attrs.key) }, view(vnode) { return m('.container', {}, [ m(Header), m('.body', {}, [ m('.sidebar', [ m('p', SearchModel.query), m(SearchRelated), ]), m(SearchView), ]), ]) }, } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - window.addEventListener('load', () => { m.route(document.body, "/browse/", { // The path doesn't need to be escaped, perhaps change that (":key..."). "/browse/": Browse, "/browse/:key": Browse, "/tags": Tags, "/tags/:key": Tags, "/duplicates": Duplicates, "/orphans": Orphans, "/view/:key": View, "/similar/:key": Similar, "/search/:key": Search, }) })