diff --git a/cmd/serve/api.go b/cmd/serve/api.go index cdf3c8f..d76977c 100644 --- a/cmd/serve/api.go +++ b/cmd/serve/api.go @@ -11,21 +11,17 @@ import ( ) type Router struct { - static fs.FS - lib Library - rcol RecordCollection + static fs.FS + lib Library + rcol RecordCollection + ts *tailscale.LocalClient + isAdmin bool } -type AdminRouter struct { - static fs.FS - lib Library - ts *tailscale.LocalClient -} +type path map[string]func() -type handler map[string]func() - -func (h handler) Handle(w http.ResponseWriter, req *http.Request) { - if f, ok := h[req.Method]; ok { +func (h path) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if f, ok := h[r.Method]; ok { f() return } @@ -50,31 +46,33 @@ func writeJSON(w http.ResponseWriter, b any, status int) { func (router *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { - case "/api/records": - handler{ - http.MethodGet: func() { getRecords(router.rcol, w, r) }, - }.Handle(w, r) - case "/api/books": - handler{ - http.MethodGet: func() { getBooks(router.lib, w, r) }, - }.Handle(w, r) - default: - static(router.static).ServeHTTP(w, r) - } -} - -func (router *AdminRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { + case "/api/mode": + path{ + http.MethodGet: func() { + writeJSON(w, struct{ Admin bool }{Admin: router.isAdmin}, http.StatusOK) + }, + }.ServeHTTP(w, r) case "/api/whoami": - handler{ + if !router.isAdmin { + http.NotFoundHandler().ServeHTTP(w, r) + return + } + path{ http.MethodGet: func() { getWhoAmI(router.ts, w, r) }, - }.Handle(w, r) + }.ServeHTTP(w, r) + case "/api/records": + path{ + http.MethodGet: func() { getRecords(router.rcol, w, r) }, + }.ServeHTTP(w, r) case "/api/books": - handler{ - 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) + p := path{ + http.MethodGet: func() { getBooks(router.lib, w, r) }, + } + if router.isAdmin { + p[http.MethodPost] = func() { addBook(router.lib, w, r) } + p[http.MethodDelete] = func() { deleteBook(router.lib, w, r) } + } + p.ServeHTTP(w, r) default: static(router.static).ServeHTTP(w, r) } diff --git a/cmd/serve/main.go b/cmd/serve/main.go index 267a23d..2c2e6dc 100644 --- a/cmd/serve/main.go +++ b/cmd/serve/main.go @@ -55,24 +55,26 @@ func main() { c.DiscogsToken, time.Hour*24, c.DiscogsUser, c.DiscogsPersist, c.DiscogsCacheFile, )) - frontendRoot := must.Get(frontend.Root()) - adminRoot := must.Get(frontend.AdminRoot()) + staticRoot := must.Get(frontend.Root()) servers := make(chan (*http.Server), 3) errGroup := errgroup.Group{} errGroup.Go(func() error { return start(servers)( publicServer(8080, &Router{ - static: frontendRoot, - lib: lib, - rcol: discogsCache, + static: staticRoot, + lib: lib, + rcol: discogsCache, + isAdmin: false, })) }) errGroup.Go(func() error { return start(servers)( - tailscaleListener("library-admin", &AdminRouter{ - static: adminRoot, - lib: lib, + tailscaleListener("library-admin", &Router{ + static: staticRoot, + lib: lib, + rcol: discogsCache, + isAdmin: true, })) }) errGroup.Go(func() error { @@ -143,10 +145,15 @@ func publicServer(port int, handler http.Handler) (*http.Server, net.Listener, e return server, ln, nil } -func tailscaleListener(hostname string, handler *AdminRouter) (*http.Server, net.Listener, error) { +func tailscaleListener(hostname string, handler *Router) (*http.Server, net.Listener, error) { s := &tsnet.Server{ Dir: ".config/" + hostname, Hostname: hostname, + Logf: func(s string, a ...any) { // silence most tsnet logs + if strings.HasPrefix(s, "To start this tsnet server") { + log.Printf(s, a...) + } + }, } ln, err := s.Listen("tcp", ":80") if err != nil { diff --git a/frontend/admin/app.js b/frontend/admin/app.js deleted file mode 100644 index d8c2d4c..0000000 --- a/frontend/admin/app.js +++ /dev/null @@ -1,217 +0,0 @@ -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 deleted file mode 100644 index 668a4f1..0000000 Binary files a/frontend/admin/favicon.ico and /dev/null differ diff --git a/frontend/admin/favicon.png b/frontend/admin/favicon.png deleted file mode 100644 index ce2be3f..0000000 Binary files a/frontend/admin/favicon.png and /dev/null differ diff --git a/frontend/admin/index.html b/frontend/admin/index.html deleted file mode 100644 index 3b08956..0000000 --- a/frontend/admin/index.html +++ /dev/null @@ -1,47 +0,0 @@ - - - - Library - - - - - - - - -
- -
No Book Selected
-
-
- - diff --git a/frontend/admin/style.css b/frontend/admin/style.css deleted file mode 100644 index 43047cf..0000000 --- a/frontend/admin/style.css +++ /dev/null @@ -1,284 +0,0 @@ -/* 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 03e9789..49c6b61 100644 --- a/frontend/frontend.go +++ b/frontend/frontend.go @@ -8,13 +8,6 @@ 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(admin, "admin") -}