diff options
-rw-r--r-- | initialize.sql | 1 | ||||
-rw-r--r-- | main.go | 91 | ||||
-rw-r--r-- | public/gallery.js | 76 | ||||
-rw-r--r-- | public/style.css | 15 |
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; @@ -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(¶ms); 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; } |