management server listener #17
@ -11,21 +11,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Router struct {
|
type Router struct {
|
||||||
static fs.FS
|
static fs.FS
|
||||||
lib Library
|
lib Library
|
||||||
rcol RecordCollection
|
rcol RecordCollection
|
||||||
|
ts *tailscale.LocalClient
|
||||||
|
isAdmin bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminRouter struct {
|
type path map[string]func()
|
||||||
static fs.FS
|
|
||||||
lib Library
|
|
||||||
ts *tailscale.LocalClient
|
|
||||||
}
|
|
||||||
|
|
||||||
type handler map[string]func()
|
func (h path) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if f, ok := h[r.Method]; ok {
|
||||||
func (h handler) Handle(w http.ResponseWriter, req *http.Request) {
|
|
||||||
if f, ok := h[req.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)
|
||||||
}
|
}
|
||||||
|
@ -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 {
|
||||||
|
@ -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
|
//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")
|
|
||||||
}
|
|
||||||
|
Loading…
Reference in New Issue
Block a user