unify frontends
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
David 2024-01-01 20:38:42 -05:00
parent c2ac67aa07
commit 382d3bab61
8 changed files with 48 additions and 598 deletions

View File

@ -14,18 +14,14 @@ type Router struct {
static fs.FS static fs.FS
lib Library lib Library
rcol RecordCollection rcol RecordCollection
}
type AdminRouter struct {
static fs.FS
lib Library
ts *tailscale.LocalClient ts *tailscale.LocalClient
isAdmin bool
} }
type handler map[string]func() type path map[string]func()
func (h handler) Handle(w http.ResponseWriter, req *http.Request) { func (h path) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if f, ok := h[req.Method]; ok { if f, ok := h[r.Method]; ok {
f() f()
return return
} }
@ -50,31 +46,33 @@ func writeJSON(w http.ResponseWriter, b any, status int) {
func (router *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (router *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path { switch r.URL.Path {
case "/api/records": case "/api/mode":
handler{ path{
http.MethodGet: func() { getRecords(router.rcol, w, r) }, http.MethodGet: func() {
}.Handle(w, r) writeJSON(w, struct{ Admin bool }{Admin: router.isAdmin}, http.StatusOK)
case "/api/books": },
handler{ }.ServeHTTP(w, r)
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/whoami": case "/api/whoami":
handler{ if !router.isAdmin {
http.NotFoundHandler().ServeHTTP(w, r)
return
}
path{
http.MethodGet: func() { getWhoAmI(router.ts, w, r) }, 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": case "/api/books":
handler{ p := path{
http.MethodGet: func() { getBooks(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) }, if router.isAdmin {
}.Handle(w, r) p[http.MethodPost] = func() { addBook(router.lib, w, r) }
p[http.MethodDelete] = func() { deleteBook(router.lib, w, r) }
}
p.ServeHTTP(w, r)
default: default:
static(router.static).ServeHTTP(w, r) static(router.static).ServeHTTP(w, r)
} }

View File

@ -55,24 +55,26 @@ func main() {
c.DiscogsToken, time.Hour*24, c.DiscogsUser, c.DiscogsPersist, c.DiscogsCacheFile, c.DiscogsToken, time.Hour*24, c.DiscogsUser, c.DiscogsPersist, c.DiscogsCacheFile,
)) ))
frontendRoot := must.Get(frontend.Root()) staticRoot := must.Get(frontend.Root())
adminRoot := must.Get(frontend.AdminRoot())
servers := make(chan (*http.Server), 3) servers := make(chan (*http.Server), 3)
errGroup := errgroup.Group{} errGroup := errgroup.Group{}
errGroup.Go(func() error { errGroup.Go(func() error {
return start(servers)( return start(servers)(
publicServer(8080, &Router{ publicServer(8080, &Router{
static: frontendRoot, static: staticRoot,
lib: lib, lib: lib,
rcol: discogsCache, rcol: discogsCache,
isAdmin: false,
})) }))
}) })
errGroup.Go(func() error { errGroup.Go(func() error {
return start(servers)( return start(servers)(
tailscaleListener("library-admin", &AdminRouter{ tailscaleListener("library-admin", &Router{
static: adminRoot, static: staticRoot,
lib: lib, lib: lib,
rcol: discogsCache,
isAdmin: true,
})) }))
}) })
errGroup.Go(func() error { errGroup.Go(func() error {
@ -143,10 +145,15 @@ func publicServer(port int, handler http.Handler) (*http.Server, net.Listener, e
return server, ln, nil 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{ s := &tsnet.Server{
Dir: ".config/" + hostname, Dir: ".config/" + hostname,
Hostname: 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") ln, err := s.Listen("tcp", ":80")
if err != nil { if err != nil {

View File

@ -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 `<img ${coverURL ? `src="${coverURL}"` : ``}/>
<div class="bookDetails">
<h1>${title}</h1>
<h2>${authors}</h2>
<span>${[isbn10, isbn13].filter((v) => v != "").join(" / ")}</span><br/>
<span>${publisher}, ${year}</span><br/>
${
series
? `<span>${series}${volume ? `, Volume ${volume}` : ""}</span><br/>`
: ""
}
${signed ? "<span>Signed by the author ✒</span><br/>" : ""}
<span>${format}</span>
</div>`;
}
function TableRowTemplate({
"isbn-13": isbn13,
"isbn-10": isbn10,
authors,
publisher,
rowNumber,
signed,
title,
year,
}) {
return `<tr class="tRow" id="${rowNumber}">
<td class="title">
${title} ${
signed ? '<span class="signed" title="Signed by the author" >✒</span>' : ""
}
</td>
<td class="author">${authors}</td>
<td class="publisher">${publisher}</td>
<td class="year">${year}</td>
<td class="isbn">${isbn13 ? isbn13 : isbn10}</td>
</tr>`;
}
function TableTemplate(books) {
return `<table class="bookTable">
<tr>
<th data-sort-by="sortTitle" class="tHeader title">Title</th>
<th data-sort-by="sortAuthor" class="tHeader author">Author</th>
<th data-sort-by="publisher" class="tHeader publisher">Publisher</th>
<th data-sort-by="year" class="tHeader year">Year</th>
<th class="tHeader isbn">ISBN</th>
</tr>${books.reduce((acc, book) => {
return acc.concat(TableRowTemplate(book));
}, "")} </table>`;
}
function AddBookTemplate() {
return `<div class="addBook">add book form goes here</div>`;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

View File

@ -1,47 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Library</title>
<link rel="stylesheet" href="style.css" />
<link rel="icon" href="favicon.ico" type="image/x-icon" />
<link
href="https://fonts.googleapis.com/css?family=Libre+Baskerville:400,700&display=swap"
as="style"
rel="stylesheet preload prefetch"
/>
<script type="text/javascript" src="app.js"></script>
<script type="text/javascript">
window.addEventListener("DOMContentLoaded", init);
</script>
<meta name="description" content="A personal library record." />
</head>
<body>
<div class="wrapper">
<div id="header">
<h1>Library</h1>
<a
target="_blank"
rel="noreferrer"
href="https://git.yetaga.in/alazyreader/library"
>git</a
>
<a href="#">add book</a>
<div id="searchBox">
<label for="childrens" class="bookCount"
>Include Childrens Books?</label
>
<input id="childrens" type="checkbox" name="childrens" />
<span id="bookCount" class="bookCount">_ books</span>
<input
id="search"
type="text"
name="search"
placeholder="Search..."
/>
</div>
</div>
<div id="current">No Book Selected</div>
<div id="books"></div>
</div>
</body>
</html>

View File

@ -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;
}

View File

@ -8,13 +8,6 @@ import (
//go:embed files //go:embed files
var static embed.FS var static embed.FS
//go:embed admin
var admin embed.FS
func Root() (fs.FS, error) { func Root() (fs.FS, error) {
return fs.Sub(static, "files") return fs.Sub(static, "files")
} }
func AdminRoot() (fs.FS, error) {
return fs.Sub(admin, "admin")
}