aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--initialize.sql1
-rw-r--r--main.go91
-rw-r--r--public/gallery.js76
-rw-r--r--public/style.css15
4 files changed, 175 insertions, 8 deletions
diff --git a/initialize.sql b/initialize.sql
index fc7d468..84a37a9 100644
--- a/initialize.sql
+++ b/initialize.sql
@@ -75,6 +75,7 @@ CREATE TABLE IF NOT EXISTS tag_space(
id INTEGER NOT NULL,
name TEXT NOT NULL,
description TEXT,
+ CHECK (name NOT LIKE '%:%'),
PRIMARY KEY (id)
) STRICT;
diff --git a/main.go b/main.go
index a89a096..29b08d3 100644
--- a/main.go
+++ b/main.go
@@ -367,6 +367,92 @@ func handleAPIBrowse(w http.ResponseWriter, r *http.Request) {
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+type webTagNamespace struct {
+ Description string `json:"description"`
+ Tags map[string]int64 `json:"tags"`
+}
+
+func getTags(nsID int64) (result map[string]int64, err error) {
+ rows, err := db.Query(`
+ SELECT t.name, COUNT(ta.tag) AS count
+ FROM tag AS t
+ LEFT JOIN tag_assignment AS ta ON t.id = ta.tag
+ WHERE t.space = ?
+ GROUP BY t.id`, nsID)
+ if err != nil {
+ return
+ }
+ defer rows.Close()
+
+ result = make(map[string]int64)
+ for rows.Next() {
+ var (
+ name string
+ count int64
+ )
+ if err = rows.Scan(&name, &count); err != nil {
+ return
+ }
+ result[name] = count
+ }
+ return result, rows.Err()
+}
+
+func getTagNamespaces(match *string) (
+ result map[string]webTagNamespace, err error) {
+ var rows *sql.Rows
+ if match != nil {
+ rows, err = db.Query(`SELECT id, name, IFNULL(description, '')
+ FROM tag_space WHERE name = ?`, *match)
+ } else {
+ rows, err = db.Query(`SELECT id, name, IFNULL(description, '')
+ FROM tag_space`)
+ }
+ if err != nil {
+ return
+ }
+ defer rows.Close()
+
+ result = make(map[string]webTagNamespace)
+ for rows.Next() {
+ var (
+ id int64
+ name string
+ ns webTagNamespace
+ )
+ if err = rows.Scan(&id, &name, &ns.Description); err != nil {
+ return
+ }
+ if ns.Tags, err = getTags(id); err != nil {
+ return
+ }
+ result[name] = ns
+ }
+ return result, rows.Err()
+}
+
+func handleAPITags(w http.ResponseWriter, r *http.Request) {
+ var params struct {
+ Namespace *string
+ }
+ if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ result, err := getTagNamespaces(params.Namespace)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ if err := json.NewEncoder(w).Encode(result); err != nil {
+ log.Println(err)
+ }
+}
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
func getImageDimensions(sha1 string) (w int64, h int64, err error) {
err = db.QueryRow(`SELECT width, height FROM image WHERE sha1 = ?`,
sha1).Scan(&w, &h)
@@ -832,10 +918,11 @@ func cmdWeb(args []string) error {
http.HandleFunc("/image/", handleImages)
http.HandleFunc("/thumb/", handleThumbs)
http.HandleFunc("/api/browse", handleAPIBrowse)
- http.HandleFunc("/api/info", handleAPIInfo)
- http.HandleFunc("/api/similar", handleAPISimilar)
+ http.HandleFunc("/api/tags", handleAPITags)
http.HandleFunc("/api/duplicates", handleAPIDuplicates)
http.HandleFunc("/api/orphans", handleAPIOrphans)
+ http.HandleFunc("/api/info", handleAPIInfo)
+ http.HandleFunc("/api/similar", handleAPISimilar)
host, port, err := net.SplitHostPort(address)
if err != nil {
diff --git a/public/gallery.js b/public/gallery.js
index f97a162..4933064 100644
--- a/public/gallery.js
+++ b/public/gallery.js
@@ -185,6 +185,74 @@ let Browse = {
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+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", `${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(name => {
+ const ns = TagsModel.namespaces[name]
+ return [
+ m("h2", name),
+ ns.description ? m("p", ns.description) : [],
+ m(TagsList, {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 ViewModel = {
sha1: undefined,
width: 0,
@@ -242,7 +310,7 @@ let ViewBar = {
m('li', m(ViewBarPath, {path})))),
m('h2', "Tags"),
Object.entries(ViewModel.tags).map(([group, tags]) => [
- m("h3", group),
+ m("h3", m(m.route.Link, {href: `/tags/${group}`}, group)),
m("ul.tags", Object.entries(tags)
.sort(([t1, w1], [t2, w2]) => (w2 - w1))
.map(([tag, weight]) => m("li", [
@@ -478,14 +546,14 @@ window.addEventListener('load', () => {
// 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,
- "/tags": undefined,
- "/tags/:space": undefined,
- "/tags/:space/:tag": undefined,
+ "/search/:space/:tag": undefined,
})
})
diff --git a/public/style.css b/public/style.css
index 42d7a59..701dc8a 100644
--- a/public/style.css
+++ b/public/style.css
@@ -1,3 +1,5 @@
+:root { --shade-color: #eee; }
+
body { margin: 0; padding: 0; font-family: sans-serif; }
a { color: inherit; }
@@ -17,11 +19,11 @@ a { color: inherit; }
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(#ddd, #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; }
-ul.sidebar { margin: 0; padding: .5rem 0; background: #eee;
+ul.sidebar { margin: 0; padding: .5rem 0; background: var(--shade-color);
min-width: 10rem; overflow: auto; border-right: 1px solid #ccc; }
ul.sidebar li { margin: 0; padding: 0; }
@@ -46,6 +48,15 @@ ul.sidebar li.child a {
gap: 3px; padding: 9px; }
.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; }