'use strict' function call(method, params) { // TODO: Make it apparent when results result in errors: // - With responseType == "json", m.request() always expects JSON, // and error.message is null if it fails, but we can handle it manually. // - Go can wrap all errors into trivial strings before writing. return m.request({ method: "POST", url: `/api/${method}`, body: params, }) } const loading = (window.location.hostname !== 'localhost') ? 'lazy' : undefined // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 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: 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(e => { return m(m.route.Link, {href: `/view/${e.sha1}`}, m('img.thumbnail', {src: `/thumb/${e.sha1}`, width: e.thumbW, height: e.thumbH, title: e.name, loading})) })) }, } 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', {}, "Browser"), m('.body', {}, [ m('ul.sidebar', BrowseModel.getBrowseLinks().map(link => m(BrowseBarLink, {link}))), m(BrowseView), ]), ]) }, } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 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(([group, tags]) => [ m("h3", group), m("ul.tags", Object.entries(tags) .sort(([t1, w1], [t2, w2]) => (w2 - w1)) .map(([tag, weight]) => m("li", [ m("meter[max=1.0]", {value: weight, title: weight}, weight), ` ${tag}`, ]))), ]), ]) }, } 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', {}, [ "View", m(m.route.Link, { href: `/similar/:key`, params: {key: ViewModel.sha1}, }, "Similar"), ]), 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('img.thumbnail', {src: `/thumb/${info.sha1}`, width: info.thumbW, height: info.thumbH, loading})) }, } 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', {}, [ "Similarity search", m(m.route.Link, { href: `/view/:key`, params: {key: SimilarModel.sha1}, }, "View"), ]), m('.body', {}, m(SimilarList)), ]) }, } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 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('img.thumbnail', {src: `/thumb/${info.sha1}`, width: info.thumbW, height: info.thumbH, loading})), (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', {}, [ "Duplicates", ]), m('.body', {}, m(DuplicatesList)), ]) }, } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 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, "/view/:key": View, "/similar/:key": Similar, "/duplicates": Duplicates, "/tags": undefined, "/tags/:space": undefined, "/tags/:space/:tag": undefined, }) })