diff --git a/.gitignore b/.gitignore
index 0742ee2..b436743 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
/server
-/manager
+/cli
*.properties
.DS_Store
/vendor
-.recordsCache
\ No newline at end of file
+.recordsCache
+.config
\ No newline at end of file
diff --git a/Makefile b/Makefile
index 178b88d..ae65042 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: up down run-server run-manager test
+.PHONY: up down run-server run-cli test
GOFILES=$(shell find . -name '*.go' -o -name 'go.*')
STATICFILES=$(shell find . -name '*.js' -o -name '*.css' -o -name '*.html')
@@ -9,26 +9,26 @@ include local.properties
export
endif
-build: server manager
+build: server cli
run-server: build
./server
-run-manager: build
- ./manager
+run-cli: build
+ ./cli
server: $(GOFILES) $(STATICFILES)
go build -o server ./cmd/serve
-manager: $(GOFILES) $(SQLFILES)
- go build -o manager ./cmd/manage
+cli: $(GOFILES) $(SQLFILES)
+ go build -o cli ./cmd/cli
test:
go test ./... -cover
# dev dependencies
up:
- docker compose up -d
+ docker-compose up -d
down:
- docker compose down
+ docker-compose down
diff --git a/cmd/manage/events.go b/cmd/cli/events.go
similarity index 100%
rename from cmd/manage/events.go
rename to cmd/cli/events.go
diff --git a/cmd/manage/main.go b/cmd/cli/main.go
similarity index 100%
rename from cmd/manage/main.go
rename to cmd/cli/main.go
diff --git a/cmd/serve/api.go b/cmd/serve/api.go
new file mode 100644
index 0000000..e2c892a
--- /dev/null
+++ b/cmd/serve/api.go
@@ -0,0 +1,179 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "io/fs"
+ "log"
+ "net/http"
+
+ "git.yetaga.in/alazyreader/library/media"
+ "tailscale.com/client/tailscale"
+)
+
+type Router struct {
+ static fs.FS
+ lib Library
+ rcol RecordCollection
+ query Query
+ ts *tailscale.LocalClient
+ isAdmin bool
+}
+
+type path map[string]func()
+
+func (h path) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if f, ok := h[r.Method]; ok {
+ f()
+ return
+ }
+ writeJSONerror(w, "method not supported", http.StatusMethodNotAllowed)
+}
+
+func writeJSONerror(w http.ResponseWriter, err string, status int) {
+ log.Println(err)
+ writeJSON(w, struct{ Status, Reason string }{Status: "error", Reason: err}, status)
+}
+
+func writeJSON(w http.ResponseWriter, b any, status int) {
+ bytes, err := json.Marshal(b)
+ if err != nil {
+ writeJSONerror(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.WriteHeader(status)
+ w.Write(bytes)
+ w.Write([]byte("\n"))
+}
+
+func (router *Router) 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":
+ if !router.isAdmin {
+ http.NotFoundHandler().ServeHTTP(w, r)
+ return
+ }
+ path{
+ http.MethodGet: func() { getWhoAmI(router.ts, w, r) },
+ }.ServeHTTP(w, r)
+ case "/api/records":
+ path{
+ http.MethodGet: func() { getRecords(router.rcol, w, r) },
+ }.ServeHTTP(w, r)
+ case "/api/books":
+ 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)
+ case "/api/query":
+ if !router.isAdmin {
+ http.NotFoundHandler().ServeHTTP(w, r)
+ return
+ }
+ path{
+ http.MethodPost: func() { lookupBook(router.query, w, r) },
+ }.ServeHTTP(w, r)
+ default:
+ static(router.static).ServeHTTP(w, r)
+ }
+}
+
+func getBooks(l Library, w http.ResponseWriter, r *http.Request) {
+ books, err := l.GetAllBooks(r.Context())
+ if err != nil {
+ writeJSONerror(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, books, http.StatusOK)
+}
+
+func addBook(l Library, w http.ResponseWriter, r *http.Request) {
+ book, err := ReadBody[media.Book](r.Body)
+ if err != nil {
+ writeJSONerror(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ if err = l.AddBook(r.Context(), book); err != nil {
+ writeJSONerror(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusAccepted)
+}
+
+func deleteBook(l Library, w http.ResponseWriter, r *http.Request) {
+ book, err := ReadBody[media.Book](r.Body)
+ if err != nil {
+ writeJSONerror(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ if err = l.DeleteBook(r.Context(), book); err != nil {
+ writeJSONerror(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusAccepted)
+}
+
+func getRecords(l RecordCollection, w http.ResponseWriter, r *http.Request) {
+ records, err := l.GetAllRecords(r.Context())
+ if err != nil {
+ writeJSONerror(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, records, http.StatusOK)
+}
+
+func getWhoAmI(ts *tailscale.LocalClient, w http.ResponseWriter, r *http.Request) {
+ whois, err := ts.WhoIs(r.Context(), r.RemoteAddr)
+ if err != nil {
+ writeJSONerror(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, whois.UserProfile, http.StatusOK)
+}
+
+func lookupBook(query Query, w http.ResponseWriter, r *http.Request) {
+ req, err := ReadBody[media.Book](r.Body)
+ if err != nil {
+ writeJSONerror(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ book, err := query.GetByISBN(req.ISBN13)
+ if err != nil {
+ writeJSONerror(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, book, http.StatusOK)
+}
+
+func static(f fs.FS) http.Handler {
+ return http.FileServer(http.FS(f))
+}
+
+func ReadBody[T any](r io.ReadCloser) (*T, error) {
+ t := new(T)
+ if r == nil {
+ return t, fmt.Errorf("no body provided")
+ }
+ defer r.Close()
+ b, err := io.ReadAll(r)
+ if err != nil {
+ return t, fmt.Errorf("error reading body: %w", err)
+ }
+ err = json.Unmarshal(b, t)
+ if err != nil {
+ return t, fmt.Errorf("error reading body: %w", err)
+ }
+ return t, nil
+}
diff --git a/cmd/serve/main.go b/cmd/serve/main.go
index 1f71c5b..adb6471 100644
--- a/cmd/serve/main.go
+++ b/cmd/serve/main.go
@@ -2,10 +2,12 @@ package main
import (
"context"
- "encoding/json"
- "io/fs"
+ "fmt"
"log"
+ "net"
"net/http"
+ "os"
+ "os/signal"
"strings"
"time"
@@ -13,100 +15,78 @@ import (
"git.yetaga.in/alazyreader/library/database"
"git.yetaga.in/alazyreader/library/frontend"
"git.yetaga.in/alazyreader/library/media"
+ "git.yetaga.in/alazyreader/library/query"
"github.com/kelseyhightower/envconfig"
+ "golang.org/x/sync/errgroup"
+ "tailscale.com/tsnet"
+ "tailscale.com/util/must"
)
-func max(a, b int) int {
- if a > b {
- return a
- }
- return b
-}
-
func obscureStr(in string, l int) string {
return in[0:max(l, len(in))] + strings.Repeat("*", max(0, len(in)-l))
}
type Library interface {
GetAllBooks(context.Context) ([]media.Book, error)
+ AddBook(context.Context, *media.Book) error
+ DeleteBook(context.Context, *media.Book) error
}
type RecordCollection interface {
GetAllRecords(context.Context) ([]media.Record, error)
}
-type Router struct {
- static fs.FS
- lib Library
- rcol RecordCollection
-}
-
-func writeJSON(w http.ResponseWriter, b []byte, status int) {
- w.Header().Set("Content-Type", "application/json; charset=utf-8")
- w.WriteHeader(status)
- w.Write(b)
- w.Write([]byte("\n"))
-}
-
-func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
- if req.URL.Path == "/api/records" {
- RecordsAPIHandler(r.rcol).ServeHTTP(w, req)
- return
- }
- if req.URL.Path == "/api/books" {
- BooksAPIHandler(r.lib).ServeHTTP(w, req)
- return
- }
- StaticHandler(r.static).ServeHTTP(w, req)
-}
-
-func BooksAPIHandler(l Library) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- books, err := l.GetAllBooks(r.Context())
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- b, err := json.Marshal(books)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
-
- }
- writeJSON(w, b, http.StatusOK)
- })
-}
-
-func RecordsAPIHandler(l RecordCollection) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- books, err := l.GetAllRecords(r.Context())
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- b, err := json.Marshal(books)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- writeJSON(w, b, http.StatusOK)
- })
-}
-
-func StaticHandler(f fs.FS) http.Handler {
- return http.FileServer(http.FS(f))
+type Query interface {
+ GetByISBN(string) (*media.Book, error)
}
func main() {
var c config.Config
- err := envconfig.Process("library", &c)
- if err != nil {
- log.Fatalln(err)
- }
- f, err := frontend.Root()
- if err != nil {
- log.Fatalln(err)
+ must.Do(envconfig.Process("library", &c))
+
+ var lib Library
+ if c.DBType == "memory" {
+ lib = &database.Memory{}
+ } else if c.DBType == "sql" {
+ sqllib, latest, run, err := setupSQL(c)
+ if err != nil {
+ log.Fatalf("sql connection err: %v", err)
+ }
+ log.Printf("latest migration: %d; migrations run: %d", latest, run)
+ lib = sqllib
}
+ discogsCache := must.Get(database.NewDiscogsCache(
+ c.DiscogsToken, time.Hour*24, c.DiscogsUser, c.DiscogsPersist, c.DiscogsCacheFile,
+ ))
+ queryProvider := &query.GoogleBooks{}
+ 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: staticRoot,
+ lib: lib,
+ rcol: discogsCache,
+ isAdmin: false,
+ }))
+ })
+ errGroup.Go(func() error {
+ return start(servers)(tailscaleListener("library-admin", &Router{
+ static: staticRoot,
+ lib: lib,
+ rcol: discogsCache,
+ query: queryProvider,
+ isAdmin: true,
+ }))
+ })
+ errGroup.Go(func() error {
+ return shutdown(servers)
+ })
+ log.Println(errGroup.Wait())
+}
+
+func setupSQL(c config.Config) (Library, int, int, error) {
if c.DBUser == "" || c.DBPass == "" || c.DBHost == "" || c.DBPort == "" || c.DBName == "" {
if c.DBPass != "" {
c.DBPass = obscureStr(c.DBPass, 3)
@@ -114,30 +94,77 @@ func main() {
if c.DiscogsToken != "" {
c.DiscogsToken = obscureStr(c.DiscogsToken, 3)
}
- log.Fatalf("vars: %+v", c)
+ return nil, 0, 0, fmt.Errorf("invalid config; vars provided: %+v", c)
}
- lib, err := database.NewMySQLConnection(c.DBUser, c.DBPass, c.DBHost, c.DBPort, c.DBName)
+ sql, err := database.NewMySQLConnection(c.DBUser, c.DBPass, c.DBHost, c.DBPort, c.DBName)
if err != nil {
- log.Fatalln(err)
+ return nil, 0, 0, err
}
- err = lib.PrepareDatabase(context.Background())
+ err = sql.PrepareDatabase(context.Background())
if err != nil {
- log.Fatalln(err)
+ return nil, 0, 0, err
}
- latest, run, err := lib.RunMigrations(context.Background())
+ latest, run, err := sql.RunMigrations(context.Background())
if err != nil {
- log.Fatalln(err)
+ return nil, 0, 0, err
}
- log.Printf("latest migration: %d; migrations run: %d", latest, run)
- discogsCache, err := database.NewDiscogsCache(c.DiscogsToken, time.Hour*24, c.DiscogsUser, c.DiscogsPersist, c.DiscogsCacheFile)
- if err != nil {
- log.Fatalln(err)
- }
- r := &Router{
- static: f,
- lib: lib,
- rcol: discogsCache,
- }
- log.Println("Listening on http://0.0.0.0:8080/")
- log.Fatalln(http.ListenAndServe(":8080", r))
+ return sql, latest, run, nil
+}
+
+func start(servers chan (*http.Server)) func(*http.Server, net.Listener, error) error {
+ return func(s *http.Server, l net.Listener, err error) error {
+ if err != nil {
+ return err
+ }
+ servers <- s
+ return s.Serve(l)
+ }
+}
+
+func shutdown(servers chan (*http.Server)) error {
+ sigint := make(chan os.Signal, 1)
+ signal.Notify(sigint, os.Interrupt)
+ <-sigint
+ close(servers)
+ var err error
+ for server := range servers {
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
+ if shutdownerr := server.Shutdown(ctx); shutdownerr != nil {
+ err = shutdownerr
+ }
+ cancel()
+ }
+ return err
+}
+
+func publicServer(port int, handler http.Handler) (*http.Server, net.Listener, error) {
+ server := &http.Server{Handler: handler}
+ ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
+ if err != nil {
+ return nil, nil, err
+ }
+ log.Printf("public server: http://0.0.0.0:%d/", port)
+ return server, ln, nil
+}
+
+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 {
+ return nil, nil, err
+ }
+ handler.ts, err = s.LocalClient()
+ if err != nil {
+ return nil, nil, err
+ }
+ log.Printf("management server: http://%s/", hostname)
+ return &http.Server{Handler: handler}, ln, nil
}
diff --git a/config/config.go b/config/config.go
index b88c747..6a67ef4 100644
--- a/config/config.go
+++ b/config/config.go
@@ -1,6 +1,7 @@
package config
type Config struct {
+ DBType string `default:"sql"`
DBUser string
DBPass string
DBHost string
diff --git a/database/mysql.go b/database/mysql.go
index 93df8fd..cc30ee9 100644
--- a/database/mysql.go
+++ b/database/mysql.go
@@ -153,9 +153,8 @@ func (m *MySQL) GetAllBooks(ctx context.Context) ([]media.Book, error) {
}
allBooksQuery := fmt.Sprintf(`SELECT
- id, title, authors, sortauthor, isbn10, isbn13, format,
- genre, publisher, series, volume, year, signed,
- description, notes, coverurl, childrens
+ id, title, authors, sortauthor, isbn10, isbn13, format, genre, publisher,
+ series, volume, year, signed, description, notes, coverurl, childrens
FROM %s`, m.tableName)
books := []media.Book{}
@@ -169,12 +168,8 @@ func (m *MySQL) GetAllBooks(ctx context.Context) ([]media.Book, error) {
b := media.Book{}
var authors string
err := rows.Scan(
- &b.ID, &b.Title, &authors,
- &b.SortAuthor, &b.ISBN10, &b.ISBN13,
- &b.Format, &b.Genre, &b.Publisher,
- &b.Series, &b.Volume, &b.Year,
- &b.Signed, &b.Description, &b.Notes,
- &b.CoverURL, &b.Childrens)
+ &b.ID, &b.Title, &authors, &b.SortAuthor, &b.ISBN10, &b.ISBN13, &b.Format, &b.Genre, &b.Publisher,
+ &b.Series, &b.Volume, &b.Year, &b.Signed, &b.Description, &b.Notes, &b.CoverURL, &b.Childrens)
if err != nil {
return nil, err
}
@@ -193,25 +188,14 @@ func (m *MySQL) AddBook(ctx context.Context, b *media.Book) error {
res, err := m.connection.ExecContext(ctx, `
INSERT INTO `+m.tableName+`
- (title, authors, sortauthor, isbn10, isbn13, format, genre, publisher, series, volume, year, signed, description, notes, coverurl, childrens)
+ (
+ title, authors, sortauthor, isbn10, isbn13, format, genre, publisher, series,
+ volume, year, signed, description, notes, coverurl, childrens
+ )
VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- b.Title,
- strings.Join(b.Authors, ";"),
- b.SortAuthor,
- b.ISBN10,
- b.ISBN13,
- b.Format,
- b.Genre,
- b.Publisher,
- b.Series,
- b.Volume,
- b.Year,
- b.Signed,
- b.Description,
- b.Notes,
- b.CoverURL,
- b.Childrens,
+ b.Title, strings.Join(b.Authors, ";"), b.SortAuthor, b.ISBN10, b.ISBN13, b.Format, b.Genre, b.Publisher, b.Series,
+ b.Volume, b.Year, b.Signed, b.Description, b.Notes, b.CoverURL, b.Childrens,
)
if err != nil {
return err
@@ -236,41 +220,13 @@ func (m *MySQL) UpdateBook(ctx context.Context, old, new *media.Book) error {
res, err := m.connection.ExecContext(ctx, `
UPDATE `+m.tableName+`
- SET id=?
- title=?
- authors=?
- sortauthor=?
- isbn10=?
- isbn13=?
- format=?
- genre=?
- publisher=?
- series=?
- volume=?
- year=?
- signed=?
- description=?
- notes=?
- coverurl=?
- childrens=?
+ SET
+ id=? title=? authors=? sortauthor=? isbn10=? isbn13=? format=? genre=? publisher=?
+ series=? volume=? year=? signed=? description=? notes=? coverurl=? childrens=?
WHERE id=?`,
- new.Title,
- strings.Join(new.Authors, ";"),
- new.SortAuthor,
- new.ISBN10,
- new.ISBN13,
- new.Format,
- new.Genre,
- new.Publisher,
- new.Series,
- new.Volume,
- new.Year,
- new.Signed,
- new.Description,
- new.Notes,
- new.CoverURL,
- new.Childrens,
- old.ID)
+ new.Title, strings.Join(new.Authors, ";"), new.SortAuthor, new.ISBN10, new.ISBN13, new.Format, new.Genre, new.Publisher,
+ new.Series, new.Volume, new.Year, new.Signed, new.Description, new.Notes, new.CoverURL, new.Childrens, old.ID,
+ )
if err != nil {
return err
}
@@ -284,6 +240,10 @@ func (m *MySQL) UpdateBook(ctx context.Context, old, new *media.Book) error {
return nil
}
+func (m *MySQL) DeleteBook(_ context.Context, b *media.Book) error {
+ return nil
+}
+
func parseMigrationFileName(filename string) (int, string, error) {
sp := strings.SplitN(filename, "-", 2)
i, err := strconv.Atoi(sp[0])
diff --git a/frontend/files/app.js b/frontend/files/app.js
index 78e598a..a023322 100644
--- a/frontend/files/app.js
+++ b/frontend/files/app.js
@@ -3,61 +3,130 @@ var sortState = {
sortOrder: "asc",
};
-function init() {
- fetch("/api/books")
+var admin = false;
+
+var books;
+
+function checkAdminMode() {
+ fetch("/api/mode")
.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)
- );
+ .then((resp) => (admin = resp.Admin))
+ .then(() => {
+ if (admin) {
+ var element = document.getElementById("addBook");
+ element.addEventListener("click", (e) => {
+ e.preventDefault();
+ renderAddBookView();
+ });
+ element.classList.remove("hidden");
+ }
});
}
-function renderTable(books, sortField) {
- if (sortField) {
- if (sortState.sortBy === sortField && sortState.sortOrder === "asc") {
- sortState.sortOrder = "desc";
+function loadBookList() {
+ fetch("/api/books")
+ .then((response) => response.json())
+ .then((list) => {
+ // prepare response
+ list.forEach(apiResponseParsing);
+ books = list;
+ document.getElementById("search").addEventListener("input", rerender);
+ document.getElementById("childrens").addEventListener("change", rerender);
+ rerender();
+ });
+}
+
+function rerender() {
+ var searchValue = document.getElementById("search").value;
+ var childrens = document.getElementById("childrens").checked;
+ renderTable(search(searchValue, childrens));
+}
+
+function init() {
+ checkAdminMode();
+ loadBookList();
+}
+
+function renderAddBookView() {
+ document.getElementById("current").innerHTML = AddBookTemplate();
+ document.getElementById("lookup").addEventListener("click", (e) => {
+ e.preventDefault();
+ if (document.getElementById("isbn-13").value.length === 13) {
+ getPossibleBooks(document.getElementById("isbn-13").value);
} else {
- sortState.sortOrder = "asc";
+ console.log("no isbn");
}
+ });
+ document.getElementById("save").addEventListener("click", (e) => {
+ e.preventDefault();
+ saveBook({
+ title: document.getElementById("title").value,
+ authors: document.getElementById("authors").value.split(";"),
+ sortAuthor: document.getElementById("sortAuthor").value,
+ "isbn-10": document.getElementById("isbn-10").value,
+ "isbn-13": document.getElementById("isbn-13").value,
+ publisher: document.getElementById("publisher").value,
+ format: document.getElementById("format").value,
+ genre: document.getElementById("genre").value,
+ series: document.getElementById("series").value,
+ volume: document.getElementById("volume").value,
+ year: document.getElementById("year").value,
+ coverURL: document.getElementById("coverURL").value,
+ });
+ });
+}
+
+function getPossibleBooks(isbn) {
+ fetch("/api/query", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ "isbn-13": isbn }),
+ })
+ .then((response) => response.json())
+ .then((json) => {
+ Object.keys(json).forEach((key) => {
+ var elem = document.getElementById(key);
+ if (elem !== null) {
+ elem.value = json[key];
+ }
+ });
+ });
+}
+
+function saveBook(book) {
+ fetch("/api/books", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(book),
+ }).then(() => {
+ clearAddBookForm();
+ loadBookList();
+ });
+}
+
+function renderTable(bookList, sortField) {
+ if (sortField) {
+ sortState.sortOrder =
+ sortState.sortBy === sortField && sortState.sortOrder === "asc"
+ ? "desc"
+ : "asc";
sortState.sortBy = sortField;
}
- books.sort((one, two) =>
+ bookList.sort((one, two) =>
(one[sortState.sortBy] + one["sortTitle"]).localeCompare(
two[sortState.sortBy] + two["sortTitle"]
)
);
if (sortState.sortOrder === "desc") {
- books.reverse();
+ bookList.reverse();
}
- books.forEach((e, i) => (e.rowNumber = i)); // re-key
+ bookList.forEach((e, i) => (e.rowNumber = i)); // re-key
// rendering
var bookElement = document.getElementById("books");
- bookElement.innerHTML = TableTemplate(books);
+ bookElement.innerHTML = TableTemplate(bookList);
- var bookCount = document.getElementById("bookCount");
- bookCount.innerHTML = `${books.length} books`;
+ document.getElementById("bookCount").innerHTML = `${bookList.length} books`;
// add listeners for selecting book to view
Array.from(bookElement.querySelectorAll("tbody tr"))
@@ -66,7 +135,7 @@ function renderTable(books, sortField) {
row.addEventListener("click", (e) => {
// add listener to swap current book
document.getElementById("current").innerHTML = BookTemplate(
- books[e.currentTarget.id]
+ bookList[e.currentTarget.id]
);
});
});
@@ -74,7 +143,8 @@ function renderTable(books, sortField) {
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
+ // only add callback when there's a sortBy attribute
+ renderTable(bookList, e.target.dataset.sortBy);
});
}
);
@@ -98,9 +168,9 @@ function apiResponseParsing(book) {
return book;
}
-function search(books, searchBy, includeChildrensBooks) {
+function search(searchBy, includeChildrensBooks) {
searchBy = searchCleaner(searchBy);
- books = books.filter(
+ return books.filter(
({ title, authors, genre, publisher, series, year, childrens }) => {
var inSearch = true;
if (searchBy !== "") {
@@ -119,7 +189,6 @@ function search(books, searchBy, includeChildrensBooks) {
return inSearch;
}
);
- return books;
}
function titleCleaner(title) {
@@ -145,14 +214,20 @@ function ISBNfromEAN(EAN) {
return ISBN + (checkdigit === 10 ? "X" : checkdigit);
}
+function clearAddBookForm() {
+ document
+ .getElementById("newBookForm")
+ .childNodes.forEach((node) =>
+ node.nodeName === "LABEL" ? (node.lastChild.value = "") : null
+ );
+}
+
function BookTemplate({
"isbn-13": isbn13,
"isbn-10": isbn10,
authors,
coverURL,
- description,
format,
- notes,
publisher,
series,
signed,
@@ -173,6 +248,7 @@ function BookTemplate({
}
${signed ? "Signed by the author ✒
" : ""}
${format}
+ ${admin ? `Edit Book` : ""}
`;
}
@@ -211,3 +287,36 @@ function TableTemplate(books) {
return acc.concat(TableRowTemplate(book));
}, "")} `;
}
+
+function AddBookTemplate() {
+ return `