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 `
+
+ ${[ + { name: "Title", id: "title", type: "text" }, + { name: "Authors", id: "authors", type: "text" }, + { name: "SortAuthor", id: "sortAuthor", type: "text" }, + { name: "ISBN10", id: "isbn-10", type: "text" }, + { name: "ISBN13", id: "isbn-13", type: "text" }, + { name: "Publisher", id: "publisher", type: "text" }, + { name: "Format", id: "format", type: "text" }, + { name: "Genre", id: "genre", type: "text" }, + { name: "Series", id: "series", type: "text" }, + { name: "Volume", id: "volume", type: "text" }, + { name: "Year", id: "year", type: "text" }, + { name: "CoverURL", id: "coverURL", type: "text" }, + { name: "Signed", id: "signed", type: "checkbox" }, + { name: "Childrens", id: "childrens", type: "checkbox" }, + ].reduce((acc, field) => { + return acc.concat( + `
` + ); + }, "")} + + +
+
`; +} diff --git a/frontend/files/index.html b/frontend/files/index.html index 5997b38..d92ea4e 100644 --- a/frontend/files/index.html +++ b/frontend/files/index.html @@ -31,6 +31,7 @@ href="https://git.yetaga.in/alazyreader/library" >git +