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