diff options
author | Přemysl Eric Janouch <p@janouch.name> | 2023-12-08 02:16:04 +0100 |
---|---|---|
committer | Přemysl Eric Janouch <p@janouch.name> | 2023-12-29 15:02:28 +0100 |
commit | 054078908a1e4c7429ea0f5a3a0605addfccc46c (patch) | |
tree | 7a6dd29cd4381bd655fa78f5866f25c552d05072 /public | |
download | gallery-054078908a1e4c7429ea0f5a3a0605addfccc46c.tar.gz gallery-054078908a1e4c7429ea0f5a3a0605addfccc46c.tar.xz gallery-054078908a1e4c7429ea0f5a3a0605addfccc46c.zip |
Initial commit
Diffstat (limited to 'public')
-rw-r--r-- | public/gallery.js | 675 | ||||
-rw-r--r-- | public/style.css | 102 |
2 files changed, 777 insertions, 0 deletions
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, + }) +}) diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..1bdeb3f --- /dev/null +++ b/public/style.css @@ -0,0 +1,102 @@ +:root { --shade-color: #eee; } + +body { margin: 0; padding: 0; font-family: sans-serif; } +a { color: inherit; } + +.container { display: flex; flex-direction: column; + height: 100vh; width: 100vw; overflow: hidden; } + +.body { display: flex; flex-grow: 1; overflow: hidden; position: relative; } +.body::after { content: ''; position: absolute; pointer-events: none; + top: 0; left: 0; right: 0; height: .75rem; + background: linear-gradient(#fff, rgb(255 255 255 / 0%)); } + +.header { color: #000; background: #aaa linear-gradient(#888, #999); + display: flex; justify-content: space-between; column-gap: .5rem; } +.header nav { display: flex; margin: 0 .5rem; align-items: end; } +.header nav a { display: block; text-decoration: none; + background: #bbb linear-gradient(#bbb, #ccc); + margin: .25rem 0 0 -1px; padding: .25rem .75rem; + border: 1px solid #888; border-radius: .5rem .5rem 0 0; } +.header nav a.active { font-weight: bold; border-bottom: 1px solid #fff; + background: #fff linear-gradient(#eee, #fff); } +.header nav a.active, .header nav a:hover { padding-bottom: .4rem; } +.header .activity { padding: .25rem .5rem; align-self: center; color: #fff; } +.header .activity.error { color: #f00; } + +.sidebar { padding: .25rem .5rem; background: var(--shade-color); + border-right: 1px solid #ccc; overflow: auto; + min-width: 10rem; max-width: 20rem; flex-shrink: 0; } +.sidebar h2 { margin: 0.5em 0 0.25em 0; padding: 0; font-size: 1.2rem; } +.sidebar ul { margin: .5rem 0; padding: 0; } + +.sidebar .path { margin: .5rem -.5rem; } +.sidebar .path li { margin: 0; padding: 0; } +.sidebar .path li a { padding: .25rem .5rem; padding-left: 30px; + display: block; text-decoration: none; white-space: nowrap; } +.sidebar .path li a:hover { background-color: rgb(0 0 0 / 10%); } + +.sidebar .path li.parent a { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Cpath d='M 4 14 10 8 16 14' stroke='%23888' stroke-width='4' fill='none' /%3E%3C/svg%3E%0A"); + background-repeat: no-repeat; background-position: 5px center; } + +.sidebar .path li.selected a { font-weight: bold; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Ccircle cx='10' cy='10' r='6' fill='%23888' /%3E%3C/svg%3E%0A"); + background-repeat: no-repeat; background-position: 5px center; } + +.sidebar .path li.child a { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Cpath d='M 4 6 10 12 16 6' stroke='%23888' stroke-width='4' fill='none' /%3E%3C/svg%3E%0A"); + background-repeat: no-repeat; background-position: 5px center; } + +.browser { overflow: auto; display: flex; flex-wrap: wrap; + align-content: flex-start; justify-content: center; align-items: center; + gap: 3px; padding: 9px; flex-grow: 1; } +.browser:focus-visible { outline: 0; box-shadow: none; } + +.tags { padding: .5rem; flex-grow: 1; overflow: auto; } +.tags:focus-visible { outline: 0; box-shadow: none; } +.tags h2 { margin: .5em 0 .25em 0; padding: 0; font-size: 1.1rem; } +.tags p { margin: .25em 0; } +.tags ul { display: flex; margin: .5em 0; padding: 0; + flex-wrap: wrap; gap: .25em; } +.tags ul li { display: block; margin: 0; padding: .25em .5em; + border-radius: .5rem; background: var(--shade-color); } + +img.thumbnail { display: block; + background: repeating-conic-gradient(#eee 0% 25%, transparent 0% 50%) + 50% / 20px 20px; } +img.thumbnail, .thumbnail.missing { box-shadow: 0 0 3px rgba(0, 0, 0, 0.75); + margin: 3px; border: 0px solid #000; } +.thumbnail.missing { width: 128px; height: 128px; position: relative; } +.thumbnail.missing::after { content: '?'; font-size: 64px; + position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); } + +.view { display: flex; flex-grow: 1; overflow: hidden; + justify-content: center; align-items: center; } +.view img { max-width: 100%; max-height: 100%; object-fit: contain; } +.view img { z-index: 1; } + +.viewbar { padding: .25rem .5rem; background: #eee; + border-left: 1px solid #ccc; min-width: 20rem; overflow: auto; } +.viewbar h2 { margin: 0.5em 0 0.25em 0; padding: 0; font-size: 1.2rem; } +.viewbar h3 { margin: 0.25em 0; padding: 0; font-size: 1.1rem; } +.viewbar ul { margin: 0; padding: 0 0 0 1.25em; list-style-type: "- "; } +.viewbar ul.tags { padding: 0; list-style-type: none; } +.viewbar li { margin: 0; padding: 0; } + +.sidebar meter, +.viewbar meter { width: 1.25rem; + /* background: white; border: 1px solid #ccc; */ } + +.similar { padding: .5rem; flex-grow: 1; overflow: auto; } +.similar h2 { margin: 1em 0 0.5em 0; padding: 0; font-size: 1.2rem; } +.similar .row { display: flex; margin: .5rem 0; } +.similar .row ul { margin: 0; padding: 0 0 0 1.25em; list-style-type: "- "; } + +.duplicates, +.orphans { padding: .5rem; flex-grow: 1; overflow: auto; } +.duplicates .row, +.orphans .row { display: flex; margin: .5rem 0; align-items: center; gap: 3px; } + +.orphans .row { margin-bottom: 1.25rem; } +.orphans h2 { margin: 0.25em 0; padding: 0; font-size: 1.1rem; } |