Initial commit

This commit is contained in:
2023-12-08 02:16:04 +01:00
commit 054078908a
11 changed files with 3502 additions and 0 deletions

675
public/gallery.js Normal file
View File

@@ -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,
})
})

102
public/style.css Normal file
View File

@@ -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; }