aboutsummaryrefslogtreecommitdiff
path: root/public
diff options
context:
space:
mode:
Diffstat (limited to 'public')
-rw-r--r--public/gallery.js675
-rw-r--r--public/style.css102
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; }