management server listener #17
@ -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)
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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.
Binary file not shown.
@ -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>
|
@ -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;
|
||||
}
|
@ -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")
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user