From 054078908a1e4c7429ea0f5a3a0605addfccc46c Mon Sep 17 00:00:00 2001 From: Přemysl Eric Janouch
Date: Fri, 8 Dec 2023 02:16:04 +0100 Subject: Initial commit --- public/gallery.js | 675 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 675 insertions(+) create mode 100644 public/gallery.js (limited to 'public/gallery.js') diff --git a/public/gallery.js b/public/gallery.js new file mode 100644 index 0000000..9d3b067 --- /dev/null +++ b/public/gallery.js @@ -0,0 +1,675 @@ +'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, + }) +}) -- cgit v1.2.3-70-g09d2