diff --git a/cmd/serve/api.go b/cmd/serve/api.go index 516748c..cdf3c8f 100644 --- a/cmd/serve/api.go +++ b/cmd/serve/api.go @@ -22,27 +22,14 @@ type AdminRouter struct { ts *tailscale.LocalClient } -type handler struct { - get func() - post func() - put func() - delete func() -} +type handler map[string]func() func (h handler) Handle(w http.ResponseWriter, req *http.Request) { - if req.Method == http.MethodHead && h.get != nil { - h.get() - } else if req.Method == http.MethodGet && h.get != nil { - h.get() - } else if req.Method == http.MethodPost && h.post != nil { - h.post() - } else if req.Method == http.MethodPut && h.put != nil { - h.put() - } else if req.Method == http.MethodDelete && h.delete != nil { - h.delete() - } else { - badMethod(w) + if f, ok := h[req.Method]; ok { + f() + return } + badMethod(w) } func writeJSONerror(w http.ResponseWriter, err string, status int) { @@ -65,11 +52,11 @@ func (router *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/records": handler{ - get: func() { getRecords(router.rcol, w, r) }, + http.MethodGet: func() { getRecords(router.rcol, w, r) }, }.Handle(w, r) case "/api/books": handler{ - get: func() { getBooks(router.lib, w, r) }, + http.MethodGet: func() { getBooks(router.lib, w, r) }, }.Handle(w, r) default: static(router.static).ServeHTTP(w, r) @@ -80,13 +67,13 @@ func (router *AdminRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/whoami": handler{ - get: func() { getWhoAmI(router.ts, w, r) }, + http.MethodGet: func() { getWhoAmI(router.ts, w, r) }, }.Handle(w, r) case "/api/books": handler{ - get: func() { getBooks(router.lib, w, r) }, - post: func() { addBook(router.lib, w, r) }, - delete: func() { deleteBook(router.lib, w, r) }, + http.MethodGet: func() { getBooks(router.lib, w, r) }, + http.MethodPost: func() { addBook(router.lib, w, r) }, + http.MethodDelete: func() { deleteBook(router.lib, w, r) }, }.Handle(w, r) default: static(router.static).ServeHTTP(w, r) diff --git a/frontend/admin/app.js b/frontend/admin/app.js new file mode 100644 index 0000000..d8c2d4c --- /dev/null +++ b/frontend/admin/app.js @@ -0,0 +1,217 @@ +var sortState = { + sortBy: "sortAuthor", + sortOrder: "asc", +}; + +function init() { + fetch("/api/books") + .then((response) => response.json()) + .then((books) => { + // prepare response + books.forEach(apiResponseParsing); + document.getElementById("search").addEventListener("input", (e) => { + renderTable( + search( + books, + e.target.value, + document.getElementById("childrens").checked + ) + ); + }); + document.getElementById("childrens").addEventListener("change", (e) => { + renderTable( + search( + books, + document.getElementById("search").value, + e.target.checked + ) + ); + }); + renderTable( + search(books, "", document.getElementById("childrens").checked) + ); + }); +} + +function renderTable(books, sortField) { + if (sortField) { + if (sortState.sortBy === sortField && sortState.sortOrder === "asc") { + sortState.sortOrder = "desc"; + } else { + sortState.sortOrder = "asc"; + } + sortState.sortBy = sortField; + } + books.sort((one, two) => + (one[sortState.sortBy] + one["sortTitle"]).localeCompare( + two[sortState.sortBy] + two["sortTitle"] + ) + ); + if (sortState.sortOrder === "desc") { + books.reverse(); + } + books.forEach((e, i) => (e.rowNumber = i)); // re-key + + // rendering + var bookElement = document.getElementById("books"); + bookElement.innerHTML = TableTemplate(books); + + var bookCount = document.getElementById("bookCount"); + bookCount.innerHTML = `${books.length} books`; + + // add listeners for selecting book to view + Array.from(bookElement.querySelectorAll("tbody tr")) + .slice(1) // remove header from Array + .forEach((row) => { + row.addEventListener("click", (e) => { + // add listener to swap current book + document.getElementById("current").innerHTML = BookTemplate( + books[e.currentTarget.id] + ); + }); + }); + // add sorting callbacks + Array.from(bookElement.querySelectorAll("tbody tr th[data-sort-by]")).forEach( + (row) => { + row.addEventListener("click", function (e) { + renderTable(books, e.target.dataset.sortBy); // only add callback when there's a sortBy attribute + }); + } + ); + // mark currently active column + bookElement + .querySelector("tbody tr th[data-sort-by=" + sortState.sortBy + "]") + .classList.add(sortState.sortOrder); +} + +function apiResponseParsing(book) { + book.sortTitle = titleCleaner(book.title); + if (!book["isbn-10"] && book["isbn-13"]) { + book["isbn-10"] = ISBNfromEAN(book["isbn-13"]); + } + if (!book.coverURL && book["isbn-10"]) { + book.coverURL = + `https://images-na.ssl-images-amazon.com/images/P/` + + book["isbn-10"] + + `.01.LZZ.jpg`; + } + return book; +} + +function search(books, searchBy, includeChildrensBooks) { + searchBy = searchCleaner(searchBy); + books = books.filter( + ({ title, authors, genre, publisher, series, year, childrens }) => { + var inSearch = true; + if (searchBy !== "") { + inSearch = Object.values({ + title, + authors: authors.join(" "), + genre, + publisher, + series, + year, + }).find((field) => searchCleaner(field).indexOf(searchBy) !== -1); + } + if (!includeChildrensBooks) { + return inSearch && !childrens; + } + return inSearch; + } + ); + return books; +} + +function titleCleaner(title) { + return title + .replace('"', "") + .replace(":", "") + .replace(/^(An?|The)\s/i, ""); +} + +function searchCleaner(str) { + return str + .toLowerCase() + .replaceAll('"', "") + .replaceAll(":", "") + .replaceAll("'", "") + .replaceAll(" ", ""); +} + +function ISBNfromEAN(EAN) { + ISBN = EAN.slice(3, 12); + var checkdigit = + (11 - (ISBN.split("").reduce((s, n, k) => s + n * (10 - k), 0) % 11)) % 11; + return ISBN + (checkdigit === 10 ? "X" : checkdigit); +} + +function BookTemplate({ + "isbn-13": isbn13, + "isbn-10": isbn10, + authors, + coverURL, + description, + format, + notes, + publisher, + series, + signed, + title, + volume, + year, +}) { + return ` +
+

${title}

+

${authors}

+ ${[isbn10, isbn13].filter((v) => v != "").join(" / ")}
+ ${publisher}, ${year}
+ ${ + series + ? `${series}${volume ? `, Volume ${volume}` : ""}
` + : "" + } + ${signed ? "Signed by the author ✒
" : ""} + ${format} +
`; +} + +function TableRowTemplate({ + "isbn-13": isbn13, + "isbn-10": isbn10, + authors, + publisher, + rowNumber, + signed, + title, + year, +}) { + return ` + + ${title} ${ + signed ? '' : "" + } + + ${authors} + ${publisher} + ${year} + ${isbn13 ? isbn13 : isbn10} + `; +} + +function TableTemplate(books) { + return ` + + + + + + + ${books.reduce((acc, book) => { + return acc.concat(TableRowTemplate(book)); + }, "")}
TitleAuthorPublisherYearISBN
`; +} + +function AddBookTemplate() { + return `
add book form goes here
`; +} diff --git a/frontend/admin/favicon.ico b/frontend/admin/favicon.ico new file mode 100644 index 0000000..668a4f1 Binary files /dev/null and b/frontend/admin/favicon.ico differ diff --git a/frontend/admin/favicon.png b/frontend/admin/favicon.png new file mode 100644 index 0000000..ce2be3f Binary files /dev/null and b/frontend/admin/favicon.png differ diff --git a/frontend/admin/index.html b/frontend/admin/index.html index d3e2931..3b08956 100644 --- a/frontend/admin/index.html +++ b/frontend/admin/index.html @@ -1,5 +1,47 @@ - - hello world - - \ No newline at end of file + + + Library + + + + + + + + +
+ +
No Book Selected
+
+
+ + diff --git a/frontend/admin/style.css b/frontend/admin/style.css new file mode 100644 index 0000000..43047cf --- /dev/null +++ b/frontend/admin/style.css @@ -0,0 +1,284 @@ +/* http://meyerweb.com/eric/tools/css/reset/ + v2.0 | 20110126 + License: none (public domain) +*/ + +html, +body, +div, +span, +applet, +object, +iframe, +h1, +h2, +h3, +h4, +h5, +h6, +p, +blockquote, +pre, +a, +abbr, +acronym, +address, +big, +cite, +code, +del, +dfn, +em, +img, +ins, +kbd, +q, +s, +samp, +small, +strike, +strong, +sub, +sup, +tt, +var, +b, +u, +i, +center, +dl, +dt, +dd, +ol, +ul, +li, +fieldset, +form, +label, +legend, +table, +caption, +tbody, +tfoot, +thead, +tr, +th, +td, +article, +aside, +canvas, +details, +embed, +figure, +figcaption, +footer, +header, +hgroup, +menu, +nav, +output, +ruby, +section, +summary, +time, +mark, +audio, +video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; +} +article, +aside, +details, +figcaption, +figure, +footer, +header, +hgroup, +menu, +nav, +section { + display: block; +} +body { + line-height: 1; +} +ol, +ul { + list-style: none; +} +blockquote, +q { + quotes: none; +} +blockquote:before, +blockquote:after, +q:before, +q:after { + content: ""; + content: none; +} +table { + border-collapse: collapse; + border-spacing: 0; +} + +/* site CSS starts here */ + +body { + overflow: hidden; +} + +#header { + height: 30px; + width: calc(100vw - 20px); + padding: 4px 10px; + background-color: #f7f3dc; + border-bottom: 2px solid #d8d0a0; + font-family: "Libre Baskerville", sans-serif; +} + +#header h1 { + font-size: xx-large; + display: inline; +} + +#header .bookCount { + font-size: small; + color: #a29c77; +} + +#searchBox { + position: absolute; + right: 10px; + top: 7px; + text-align: right; + width: 800px; +} + +#searchBox input#search { + width: 300px; + font-size: 16px; + background: #f9f8ed; + padding: 2px 5px; + border: none; + border-bottom: 2px solid #d8d0a0; + font-family: "Libre Baskerville", sans-serif; +} + +#searchBox input:focus { + outline: none; +} + +#searchBox input::placeholder { + font-family: "Libre Baskerville", sans-serif; + color: #d8d0a0; +} + +#current { + background-color: #f7f3dc; + width: calc(40vw - 40px); + height: calc(100vh - 80px); + padding: 20px; + overflow: auto; + float: left; + position: relative; +} + +#books { + width: calc(60vw - 40px); + height: calc(100vh - 80px); + padding: 20px; + overflow: auto; + float: left; +} + +.bookTable th { + font-weight: bold; + text-align: left; + font-family: "Libre Baskerville", sans-serif; +} + +.bookTable th[data-sort-by] { + cursor: pointer; +} + +.bookTable th[data-sort-by]::after { + content: "\2195"; + position: relative; + left: 4px; +} + +.bookTable th.asc::after { + content: "\2191"; + font-size: small; + position: relative; + left: 4px; + bottom: 1px; +} + +.bookTable th.desc::after { + content: "\2193"; + font-size: small; + position: relative; + left: 4px; + bottom: 1px; +} + +.bookTable td, +.bookTable th { + padding: 5px; + min-width: 50px; +} + +.tRow:nth-child(odd) { + background: #f9f8ed; + border-bottom: 1px solid #d8d0a0; +} + +.bookTable .tRow { + cursor: pointer; +} + +.bookTable .tRow .title { + font-style: italic; + max-width: 600px; +} + +#current h1 { + font-size: x-large; + font-weight: bold; + font-style: italic; + padding: 0 0 5px 0; +} + +#current h2 { + font-size: large; + padding: 7px 0; +} + +#current img { + opacity: 0.5; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: auto; +} + +#current .bookDetails { + position: relative; + background-color: rgba(255, 255, 255, 0.8); + padding: 10px; + margin: 0; + width: 75%; + border-radius: 5px; +} + +#current .description p { + padding: 20px 0; +} diff --git a/frontend/frontend.go b/frontend/frontend.go index 5693779..03e9789 100644 --- a/frontend/frontend.go +++ b/frontend/frontend.go @@ -8,10 +8,13 @@ import ( //go:embed files var static embed.FS +//go:embed admin +var admin embed.FS + func Root() (fs.FS, error) { return fs.Sub(static, "files") } func AdminRoot() (fs.FS, error) { - return fs.Sub(static, "admin") + return fs.Sub(admin, "admin") }