[add] basic epub reader, [fix] empty device synced bug
This commit is contained in:
parent
edca763396
commit
bfda15802b
@ -41,7 +41,8 @@ In additional to the compatible KOSync API's, we add:
|
|||||||
- Additional APIs to automatically upload reading statistics
|
- Additional APIs to automatically upload reading statistics
|
||||||
- Upload documents to the server (can download in the "Documents" view or via OPDS)
|
- Upload documents to the server (can download in the "Documents" view or via OPDS)
|
||||||
- Book metadata scraping (Thanks [OpenLibrary](https://openlibrary.org/) & [Google Books API](https://developers.google.com/books/docs/v1/getting_started))
|
- Book metadata scraping (Thanks [OpenLibrary](https://openlibrary.org/) & [Google Books API](https://developers.google.com/books/docs/v1/getting_started))
|
||||||
- No JavaScript! All information is generated server side with go templates
|
- No JavaScript for the main app! All information is generated server side with go templates.
|
||||||
|
- JavaScript is used for the ePub reader. Goals to make it service worker to enable a complete offline PWA reading experience.
|
||||||
|
|
||||||
# Server
|
# Server
|
||||||
|
|
||||||
|
@ -53,7 +53,7 @@ func NewApi(db *database.DBManager, c *config.Config) *API {
|
|||||||
// Configure Cookie Session Store
|
// Configure Cookie Session Store
|
||||||
store := cookie.NewStore(newToken)
|
store := cookie.NewStore(newToken)
|
||||||
store.Options(sessions.Options{
|
store.Options(sessions.Options{
|
||||||
MaxAge: 60 * 60 * 24 * 7,
|
MaxAge: 60 * 60 * 24 * 7,
|
||||||
Secure: true,
|
Secure: true,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: http.SameSiteStrictMode,
|
||||||
@ -81,6 +81,7 @@ func (api *API) registerWebAppRoutes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render.AddFromFilesFuncs("login", helperFuncs, "templates/login.html")
|
render.AddFromFilesFuncs("login", helperFuncs, "templates/login.html")
|
||||||
|
render.AddFromFilesFuncs("reader", helperFuncs, "templates/reader-base.html", "templates/reader.html")
|
||||||
render.AddFromFilesFuncs("home", helperFuncs, "templates/base.html", "templates/home.html")
|
render.AddFromFilesFuncs("home", helperFuncs, "templates/base.html", "templates/home.html")
|
||||||
render.AddFromFilesFuncs("search", helperFuncs, "templates/base.html", "templates/search.html")
|
render.AddFromFilesFuncs("search", helperFuncs, "templates/base.html", "templates/search.html")
|
||||||
render.AddFromFilesFuncs("settings", helperFuncs, "templates/base.html", "templates/settings.html")
|
render.AddFromFilesFuncs("settings", helperFuncs, "templates/base.html", "templates/settings.html")
|
||||||
@ -103,6 +104,7 @@ func (api *API) registerWebAppRoutes() {
|
|||||||
api.Router.GET("/activity", api.authWebAppMiddleware, api.createAppResourcesRoute("activity"))
|
api.Router.GET("/activity", api.authWebAppMiddleware, api.createAppResourcesRoute("activity"))
|
||||||
api.Router.GET("/documents", api.authWebAppMiddleware, api.createAppResourcesRoute("documents"))
|
api.Router.GET("/documents", api.authWebAppMiddleware, api.createAppResourcesRoute("documents"))
|
||||||
api.Router.GET("/documents/:document", api.authWebAppMiddleware, api.createAppResourcesRoute("document"))
|
api.Router.GET("/documents/:document", api.authWebAppMiddleware, api.createAppResourcesRoute("document"))
|
||||||
|
api.Router.GET("/documents/:document/reader", api.authWebAppMiddleware, api.documentReader)
|
||||||
api.Router.GET("/documents/:document/file", api.authWebAppMiddleware, api.downloadDocumentFile)
|
api.Router.GET("/documents/:document/file", api.authWebAppMiddleware, api.downloadDocumentFile)
|
||||||
api.Router.GET("/documents/:document/cover", api.authWebAppMiddleware, api.getDocumentCover)
|
api.Router.GET("/documents/:document/cover", api.authWebAppMiddleware, api.getDocumentCover)
|
||||||
api.Router.POST("/documents/:document/edit", api.authWebAppMiddleware, api.editDocument)
|
api.Router.POST("/documents/:document/edit", api.authWebAppMiddleware, api.editDocument)
|
||||||
|
@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -308,6 +309,45 @@ func (api *API) getDocumentCover(c *gin.Context) {
|
|||||||
c.File(coverFilePath)
|
c.File(coverFilePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (api *API) documentReader(c *gin.Context) {
|
||||||
|
rUser, _ := c.Get("AuthorizedUser")
|
||||||
|
|
||||||
|
var rDoc requestDocumentID
|
||||||
|
if err := c.ShouldBindUri(&rDoc); err != nil {
|
||||||
|
log.Error("[documentReader] Invalid URI Bind")
|
||||||
|
c.AbortWithStatus(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
progress, err := api.DB.Queries.GetProgress(api.DB.Ctx, database.GetProgressParams{
|
||||||
|
DocumentID: rDoc.DocumentID,
|
||||||
|
UserID: rUser.(string),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
log.Error("[documentReader] UpsertDocument DB Error:", err)
|
||||||
|
c.AbortWithStatus(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
document, err := api.DB.Queries.GetDocumentWithStats(api.DB.Ctx, database.GetDocumentWithStatsParams{
|
||||||
|
UserID: rUser.(string),
|
||||||
|
DocumentID: rDoc.DocumentID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Error("[documentReader] GetDocumentWithStats DB Error:", err)
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "reader", gin.H{
|
||||||
|
"SearchEnabled": api.Config.SearchEnabled,
|
||||||
|
"Progress": progress.Progress,
|
||||||
|
"Data": document,
|
||||||
|
"RelBase": "../../",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (api *API) editDocument(c *gin.Context) {
|
func (api *API) editDocument(c *gin.Context) {
|
||||||
var rDocID requestDocumentID
|
var rDocID requestDocumentID
|
||||||
if err := c.ShouldBindUri(&rDocID); err != nil {
|
if err := c.ShouldBindUri(&rDocID); err != nil {
|
||||||
|
@ -142,6 +142,7 @@ func (api *API) setProgress(c *gin.Context) {
|
|||||||
ID: rPosition.DeviceID,
|
ID: rPosition.DeviceID,
|
||||||
UserID: rUser.(string),
|
UserID: rUser.(string),
|
||||||
DeviceName: rPosition.Device,
|
DeviceName: rPosition.Device,
|
||||||
|
LastSynced: time.Now().UTC().Format(time.RFC3339),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Error("[setProgress] UpsertDevice DB Error:", err)
|
log.Error("[setProgress] UpsertDevice DB Error:", err)
|
||||||
}
|
}
|
||||||
@ -188,7 +189,11 @@ func (api *API) getProgress(c *gin.Context) {
|
|||||||
UserID: rUser.(string),
|
UserID: rUser.(string),
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err == sql.ErrNoRows {
|
||||||
|
// Not Found
|
||||||
|
c.JSON(http.StatusOK, gin.H{})
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
log.Error("[getProgress] GetProgress DB Error:", err)
|
log.Error("[getProgress] GetProgress DB Error:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
|
||||||
return
|
return
|
||||||
@ -248,6 +253,7 @@ func (api *API) addActivities(c *gin.Context) {
|
|||||||
ID: rActivity.DeviceID,
|
ID: rActivity.DeviceID,
|
||||||
UserID: rUser.(string),
|
UserID: rUser.(string),
|
||||||
DeviceName: rActivity.Device,
|
DeviceName: rActivity.Device,
|
||||||
|
LastSynced: time.Now().UTC().Format(time.RFC3339),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Error("[addActivities] UpsertDevice DB Error:", err)
|
log.Error("[addActivities] UpsertDevice DB Error:", err)
|
||||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Device"})
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Device"})
|
||||||
|
1
assets/reader/epub.min.js
vendored
Normal file
1
assets/reader/epub.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
655
assets/reader/index.js
Normal file
655
assets/reader/index.js
Normal file
@ -0,0 +1,655 @@
|
|||||||
|
const THEMES = ["light", "tan", "blue", "gray", "black"];
|
||||||
|
const THEME_FILE = "/assets/reader/readerThemes.css";
|
||||||
|
|
||||||
|
class EBookReader {
|
||||||
|
bookState = {
|
||||||
|
currentWord: 0,
|
||||||
|
pages: 0,
|
||||||
|
percentage: 0,
|
||||||
|
progress: "",
|
||||||
|
readEvents: [],
|
||||||
|
words: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(file, bookState) {
|
||||||
|
// Set Variables
|
||||||
|
Object.assign(this.bookState, bookState);
|
||||||
|
|
||||||
|
// Load EPUB
|
||||||
|
this.book = ePub(file, { openAs: "epub" });
|
||||||
|
window.book = this.book;
|
||||||
|
|
||||||
|
// Render
|
||||||
|
this.rendition = this.book.renderTo("viewer", {
|
||||||
|
manager: "default",
|
||||||
|
flow: "paginated",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup Reader
|
||||||
|
this.book.ready.then(this.setupReader.bind(this));
|
||||||
|
|
||||||
|
// Load Settings
|
||||||
|
this.loadSettings();
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
this.initThemes();
|
||||||
|
this.initRenditionListeners();
|
||||||
|
this.initDocumentListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load position and generate locations
|
||||||
|
**/
|
||||||
|
async setupReader() {
|
||||||
|
// Load Position
|
||||||
|
let currentCFI = await this.fromPosition(this.bookState.progress);
|
||||||
|
await this.rendition.display(currentCFI);
|
||||||
|
|
||||||
|
// Start Timer
|
||||||
|
this.bookState.pageStart = Date.now();
|
||||||
|
|
||||||
|
// Get Stats
|
||||||
|
let getStats = function () {
|
||||||
|
let stats = this.getBookStats();
|
||||||
|
this.updateBookStats(stats);
|
||||||
|
}.bind(this);
|
||||||
|
|
||||||
|
// Register Content Hook
|
||||||
|
this.rendition.hooks.content.register(getStats);
|
||||||
|
getStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register all themes with reader
|
||||||
|
**/
|
||||||
|
initThemes() {
|
||||||
|
// Register Themes
|
||||||
|
THEMES.forEach((theme) =>
|
||||||
|
this.rendition.themes.register(theme, THEME_FILE)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.rendition.themes.select(this.readerSettings.theme || "tan");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendition hooks
|
||||||
|
**/
|
||||||
|
initRenditionListeners() {
|
||||||
|
/**
|
||||||
|
* Initiate the debounce when the given function returns true.
|
||||||
|
* Don't run it again until the timeout lapses.
|
||||||
|
**/
|
||||||
|
function debounceFunc(fn, d) {
|
||||||
|
let timer;
|
||||||
|
let bouncing = false;
|
||||||
|
return function () {
|
||||||
|
let context = this;
|
||||||
|
let args = arguments;
|
||||||
|
|
||||||
|
if (bouncing) return;
|
||||||
|
if (!fn.apply(context, args)) return;
|
||||||
|
|
||||||
|
bouncing = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
bouncing = false;
|
||||||
|
}, d);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Elements
|
||||||
|
let topBar = document.querySelector("#top-bar");
|
||||||
|
let bottomBar = document.querySelector("#bottom-bar");
|
||||||
|
|
||||||
|
let nextPage = this.nextPage.bind(this);
|
||||||
|
let prevPage = this.prevPage.bind(this);
|
||||||
|
let saveSettings = this.saveSettings.bind(this);
|
||||||
|
|
||||||
|
// Font Scaling
|
||||||
|
let readerSettings = this.readerSettings;
|
||||||
|
this.rendition.hooks.render.register(function (doc, data) {
|
||||||
|
let renderDoc = doc.document;
|
||||||
|
|
||||||
|
// Initial Font Size
|
||||||
|
renderDoc.documentElement.style.setProperty(
|
||||||
|
"--editor-font-size",
|
||||||
|
(readerSettings.fontSize || 1) + "em"
|
||||||
|
);
|
||||||
|
this.themes.default({
|
||||||
|
"*": { "font-size": "var(--editor-font-size) !important" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------ //
|
||||||
|
// ---------------- Resize Helpers ---------------- //
|
||||||
|
// ------------------------------------------------ //
|
||||||
|
let isScaling = false;
|
||||||
|
let lastScale = 1;
|
||||||
|
let lastLocation = undefined;
|
||||||
|
let debounceID = undefined;
|
||||||
|
let debounceGesture = () => {
|
||||||
|
this.display(lastLocation.start.cfi);
|
||||||
|
lastLocation = undefined;
|
||||||
|
isScaling = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gesture Listener
|
||||||
|
renderDoc.addEventListener(
|
||||||
|
"gesturechange",
|
||||||
|
async function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
isScaling = true;
|
||||||
|
clearTimeout(debounceID);
|
||||||
|
|
||||||
|
if (!lastLocation) {
|
||||||
|
lastLocation = await this.currentLocation();
|
||||||
|
} else {
|
||||||
|
// Damped Scale
|
||||||
|
readerSettings.fontSize =
|
||||||
|
(readerSettings.fontSize || 1) + (e.scale - lastScale) / 5;
|
||||||
|
lastScale = e.scale;
|
||||||
|
saveSettings();
|
||||||
|
|
||||||
|
// Update Font Size
|
||||||
|
renderDoc.documentElement.style.setProperty(
|
||||||
|
"--editor-font-size",
|
||||||
|
(readerSettings.fontSize || 1) + "em"
|
||||||
|
);
|
||||||
|
|
||||||
|
debounceID = setTimeout(debounceGesture, 200);
|
||||||
|
}
|
||||||
|
}.bind(this),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
// ------------------------------------------------ //
|
||||||
|
// --------------- Swipe Pagination --------------- //
|
||||||
|
// ------------------------------------------------ //
|
||||||
|
let touchStartX,
|
||||||
|
touchStartY,
|
||||||
|
touchEndX,
|
||||||
|
touchEndY = undefined;
|
||||||
|
|
||||||
|
renderDoc.addEventListener(
|
||||||
|
"touchstart",
|
||||||
|
function (event) {
|
||||||
|
touchStartX = event.changedTouches[0].screenX;
|
||||||
|
touchStartY = event.changedTouches[0].screenY;
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
renderDoc.addEventListener(
|
||||||
|
"touchend",
|
||||||
|
function (event) {
|
||||||
|
touchEndX = event.changedTouches[0].screenX;
|
||||||
|
touchEndY = event.changedTouches[0].screenY;
|
||||||
|
if (!isScaling) handleGesture(event);
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleGesture(event) {
|
||||||
|
let drasticity = 75;
|
||||||
|
|
||||||
|
// Swipe Down
|
||||||
|
if (touchEndY - drasticity > touchStartY) {
|
||||||
|
return handleSwipeDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swipe Up
|
||||||
|
if (touchEndY + drasticity < touchStartY) {
|
||||||
|
// Prioritize Down & Up Swipes
|
||||||
|
return handleSwipeUp();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swipe Left
|
||||||
|
if (touchEndX + drasticity < touchStartX) {
|
||||||
|
nextPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swipe Right
|
||||||
|
if (touchEndX - drasticity > touchStartX) {
|
||||||
|
prevPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------ //
|
||||||
|
// --------------- Bottom & Top Bar --------------- //
|
||||||
|
// ------------------------------------------------ //
|
||||||
|
let emSize = parseFloat(getComputedStyle(renderDoc.body).fontSize);
|
||||||
|
renderDoc.addEventListener("click", function (event) {
|
||||||
|
let barPixels = emSize * 5;
|
||||||
|
|
||||||
|
let top = barPixels;
|
||||||
|
let bottom = window.innerHeight - top;
|
||||||
|
|
||||||
|
let left = barPixels / 2;
|
||||||
|
let right = window.innerWidth - left;
|
||||||
|
|
||||||
|
if (event.clientY < top) handleSwipeDown();
|
||||||
|
else if (event.clientY > bottom) handleSwipeUp();
|
||||||
|
else if (event.screenX < left) prevPage();
|
||||||
|
else if (event.screenX > right) nextPage();
|
||||||
|
else {
|
||||||
|
bottomBar.classList.remove("bottom-0");
|
||||||
|
topBar.classList.remove("top-0");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
renderDoc.addEventListener(
|
||||||
|
"wheel",
|
||||||
|
debounceFunc((event) => {
|
||||||
|
if (event.deltaY > 25) {
|
||||||
|
handleSwipeUp();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (event.deltaY < -25) {
|
||||||
|
handleSwipeDown();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}, 400)
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSwipeDown() {
|
||||||
|
if (bottomBar.classList.contains("bottom-0"))
|
||||||
|
bottomBar.classList.remove("bottom-0");
|
||||||
|
else topBar.classList.add("top-0");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSwipeUp() {
|
||||||
|
if (topBar.classList.contains("top-0"))
|
||||||
|
topBar.classList.remove("top-0");
|
||||||
|
else bottomBar.classList.add("bottom-0");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------ //
|
||||||
|
// -------------- Keyboard Shortcuts -------------- //
|
||||||
|
// ------------------------------------------------ //
|
||||||
|
renderDoc.addEventListener(
|
||||||
|
"keyup",
|
||||||
|
function (e) {
|
||||||
|
// Left Key (Previous Page)
|
||||||
|
if ((e.keyCode || e.which) == 37) {
|
||||||
|
prevPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right Key (Next Page)
|
||||||
|
if ((e.keyCode || e.which) == 39) {
|
||||||
|
nextPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// "t" Key (Theme Cycle)
|
||||||
|
if ((e.keyCode || e.which) == 84) {
|
||||||
|
let currentThemeIdx = THEMES.indexOf(readerSettings.theme);
|
||||||
|
if (THEMES.length == currentThemeIdx + 1)
|
||||||
|
readerSettings.theme = THEMES[0];
|
||||||
|
else readerSettings.theme = THEMES[currentThemeIdx + 1];
|
||||||
|
this.themes.select(readerSettings.theme);
|
||||||
|
this.setSettings();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Document listeners
|
||||||
|
**/
|
||||||
|
initDocumentListeners() {
|
||||||
|
// Elements
|
||||||
|
let topBar = document.querySelector("#top-bar");
|
||||||
|
|
||||||
|
let nextPage = this.nextPage.bind(this);
|
||||||
|
let prevPage = this.prevPage.bind(this);
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
"keyup",
|
||||||
|
function (e) {
|
||||||
|
// Left Key (Previous Page)
|
||||||
|
if ((e.keyCode || e.which) == 37) {
|
||||||
|
prevPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right Key (Next Page)
|
||||||
|
if ((e.keyCode || e.which) == 39) {
|
||||||
|
nextPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// "t" Key (Theme Cycle)
|
||||||
|
if ((e.keyCode || e.which) == 84) {
|
||||||
|
let currentThemeIdx = THEMES.indexOf(this.readerSettings.theme);
|
||||||
|
if (THEMES.length == currentThemeIdx + 1)
|
||||||
|
this.readerSettings.theme = THEMES[0];
|
||||||
|
else this.readerSettings.theme = THEMES[currentThemeIdx + 1];
|
||||||
|
this.rendition.themes.select(readerSettings.theme);
|
||||||
|
this.setSettings();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
document.querySelectorAll(".theme").forEach(
|
||||||
|
function (item) {
|
||||||
|
item.addEventListener(
|
||||||
|
"click",
|
||||||
|
function (event) {
|
||||||
|
this.readerSettings.theme = event.target.innerText;
|
||||||
|
this.rendition.themes.select(this.readerSettings.theme);
|
||||||
|
this.saveSettings();
|
||||||
|
}.bind(this)
|
||||||
|
);
|
||||||
|
}.bind(this)
|
||||||
|
);
|
||||||
|
|
||||||
|
document.querySelector(".close-top-bar").addEventListener("click", () => {
|
||||||
|
topBar.classList.remove("top-0");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progresses to the next page & monitors reading activity
|
||||||
|
**/
|
||||||
|
async nextPage() {
|
||||||
|
// Get Elapsed Time
|
||||||
|
let elapsedTime = Date.now() - this.bookState.pageStart;
|
||||||
|
|
||||||
|
// Update Current Word
|
||||||
|
let pageWords = await this.getVisibleWordCount();
|
||||||
|
let startingWord = this.bookState.currentWord;
|
||||||
|
this.bookState.currentWord += pageWords;
|
||||||
|
|
||||||
|
// Add Read Event
|
||||||
|
this.bookState.readEvents.push({ startingWord, pageWords, elapsedTime });
|
||||||
|
|
||||||
|
// Render Next Page
|
||||||
|
await this.rendition.next();
|
||||||
|
|
||||||
|
// Reset Read Timer
|
||||||
|
this.bookState.pageStart = Date.now();
|
||||||
|
|
||||||
|
// Update Stats
|
||||||
|
let stats = this.getBookStats();
|
||||||
|
this.updateBookStats(stats);
|
||||||
|
|
||||||
|
// Test Position
|
||||||
|
console.log(await this.toPosition());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progresses to the previous page & monitors reading activity
|
||||||
|
**/
|
||||||
|
async prevPage() {
|
||||||
|
// Render Previous Page
|
||||||
|
await this.rendition.prev();
|
||||||
|
|
||||||
|
// Update Current Word
|
||||||
|
let pageWords = await this.getVisibleWordCount();
|
||||||
|
this.bookState.currentWord -= pageWords;
|
||||||
|
|
||||||
|
// Reset Read Timer
|
||||||
|
this.bookState.pageStart = Date.now();
|
||||||
|
|
||||||
|
// Update Stats
|
||||||
|
let stats = this.getBookStats();
|
||||||
|
this.updateBookStats(stats);
|
||||||
|
|
||||||
|
// Test Position
|
||||||
|
console.log(await this.toPosition());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive chapter current page and total pages
|
||||||
|
**/
|
||||||
|
sectionProgress() {
|
||||||
|
let visibleItems = this.rendition.manager.visible();
|
||||||
|
if (visibleItems.length == 0) return console.log("No Items");
|
||||||
|
let visibleSection = visibleItems[0];
|
||||||
|
let visibleIndex = visibleSection.index;
|
||||||
|
let pagesPerBlock = visibleSection.layout.divisor;
|
||||||
|
let totalBlocks = visibleSection.width() / visibleSection.layout.width;
|
||||||
|
let sectionPages = totalBlocks;
|
||||||
|
|
||||||
|
let leftOffset = this.rendition.views().container.scrollLeft;
|
||||||
|
let sectionCurrentPage =
|
||||||
|
Math.round(leftOffset / visibleSection.layout.width) + 1;
|
||||||
|
|
||||||
|
return { sectionPages, sectionCurrentPage };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get chapter pages, name and progress percentage
|
||||||
|
**/
|
||||||
|
getBookStats() {
|
||||||
|
let currentProgress = this.sectionProgress();
|
||||||
|
if (!currentProgress) return;
|
||||||
|
let { sectionPages, sectionCurrentPage } = currentProgress;
|
||||||
|
|
||||||
|
let currentLocation = this.rendition.currentLocation();
|
||||||
|
|
||||||
|
let currentTOC = this.book.navigation.toc.find(
|
||||||
|
(item) => item.href == currentLocation.start.href
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sectionPage: sectionCurrentPage,
|
||||||
|
sectionTotalPages: sectionPages,
|
||||||
|
chapterName: currentTOC ? currentTOC.label.trim() : "N/A",
|
||||||
|
percentage:
|
||||||
|
Math.round(
|
||||||
|
(this.bookState.currentWord / this.bookState.words) * 10000
|
||||||
|
) / 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update elements with stats
|
||||||
|
**/
|
||||||
|
updateBookStats(data) {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
let chapterStatus = document.querySelector("#chapter-status");
|
||||||
|
let progressStatus = document.querySelector("#progress-status");
|
||||||
|
let chapterName = document.querySelector("#chapter-name-status");
|
||||||
|
let progressBar = document.querySelector("#progress-bar-status");
|
||||||
|
|
||||||
|
chapterStatus.innerText = `${data.sectionPage} / ${data.sectionTotalPages}`;
|
||||||
|
progressStatus.innerText = `${data.percentage}%`;
|
||||||
|
progressBar.style.width = data.percentage + "%";
|
||||||
|
chapterName.innerText = `${data.chapterName}`;
|
||||||
|
|
||||||
|
// Do Update
|
||||||
|
// console.log(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get XPath from current location
|
||||||
|
**/
|
||||||
|
async toPosition() {
|
||||||
|
// Get DocFragment (current book spline index)
|
||||||
|
let currentPos = await this.rendition.currentLocation();
|
||||||
|
let docFragmentIndex = currentPos.start.index + 1;
|
||||||
|
|
||||||
|
// Base Position
|
||||||
|
let newPos = "/body/DocFragment[" + docFragmentIndex + "]/body";
|
||||||
|
|
||||||
|
// Get first visible node
|
||||||
|
let contents = this.rendition.getContents()[0];
|
||||||
|
let currentNode = contents.range(currentPos.start.cfi).startContainer
|
||||||
|
.parentNode;
|
||||||
|
|
||||||
|
// Walk upwards and build position until body
|
||||||
|
let childPos = "";
|
||||||
|
while (currentNode.nodeName != "BODY") {
|
||||||
|
let relativeIndex =
|
||||||
|
Array.from(currentNode.parentNode.children)
|
||||||
|
.filter((item) => item.nodeName == currentNode.nodeName)
|
||||||
|
.indexOf(currentNode) + 1;
|
||||||
|
|
||||||
|
// E.g: /div[10]
|
||||||
|
let itemPos =
|
||||||
|
"/" + currentNode.nodeName.toLowerCase() + "[" + relativeIndex + "]";
|
||||||
|
|
||||||
|
// Prepend childPos & Update currentNode refernce
|
||||||
|
childPos = itemPos + childPos;
|
||||||
|
currentNode = currentNode.parentNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return derived position
|
||||||
|
return newPos + childPos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get CFI from XPath
|
||||||
|
**/
|
||||||
|
async fromPosition(position) {
|
||||||
|
// Position Reference - Example: /body/DocFragment[15]/body/div[10]/text().184
|
||||||
|
//
|
||||||
|
// - /body/DocFragment[15] = 15th item in book spline
|
||||||
|
// - [...]/body/div[10] = 10th child div under body (direct descendents only)
|
||||||
|
// - [...]/text().184 = text node of parent, character offset @ 184 chars?
|
||||||
|
|
||||||
|
// No Position
|
||||||
|
if (!position || position == "") return;
|
||||||
|
|
||||||
|
// Match Document Fragment Index
|
||||||
|
let fragMatch = position.match(/^\/body\/DocFragment\[(\d+)\]/);
|
||||||
|
if (!fragMatch) {
|
||||||
|
console.warn("No Position Match");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match Item Index
|
||||||
|
let indexMatch = position.match(/\.(\d+)$/);
|
||||||
|
let itemIndex = indexMatch ? parseInt(indexMatch[1]) : 0;
|
||||||
|
|
||||||
|
// Get Spine Item
|
||||||
|
let spinePosition = parseInt(fragMatch[1]) - 1;
|
||||||
|
let docItem = this.book.spine.get(spinePosition);
|
||||||
|
|
||||||
|
// Required for docItem.document Access
|
||||||
|
await docItem.load(this.book.load.bind(this.book));
|
||||||
|
|
||||||
|
// Derive XPath & Namespace
|
||||||
|
let namespaceURI = docItem.document.documentElement.namespaceURI;
|
||||||
|
let remainingXPath = position
|
||||||
|
// Replace with new base
|
||||||
|
.replace(fragMatch[0], "/html")
|
||||||
|
// Replace `.0` Ending Indexes
|
||||||
|
.replace(/\.(\d+)$/, "")
|
||||||
|
// Remove potential trailing `text()`
|
||||||
|
.replace(/\/text\(\)$/, "");
|
||||||
|
|
||||||
|
// Validate Namespace
|
||||||
|
if (namespaceURI) remainingXPath = remainingXPath.replaceAll("/", "/ns:");
|
||||||
|
|
||||||
|
// Perform XPath
|
||||||
|
let docSearch = docItem.document.evaluate(
|
||||||
|
remainingXPath,
|
||||||
|
docItem.document,
|
||||||
|
function (prefix) {
|
||||||
|
if (prefix === "ns") {
|
||||||
|
return namespaceURI;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get Element & CFI
|
||||||
|
let matchedItem = docSearch.iterateNext();
|
||||||
|
let matchedCFI = docItem.cfiFromElement(matchedItem);
|
||||||
|
return matchedCFI;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get visible word count - used for reading stats
|
||||||
|
**/
|
||||||
|
async getVisibleWordCount() {
|
||||||
|
// Force Expand & Resize (Race Condition Issue)
|
||||||
|
this.rendition.manager.visible().forEach((item) => item.expand());
|
||||||
|
|
||||||
|
// Get Start & End CFI
|
||||||
|
let currentLocation = await this.rendition.currentLocation();
|
||||||
|
const [startCFI, endCFI] = [
|
||||||
|
currentLocation.start.cfi,
|
||||||
|
currentLocation.end.cfi,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Derive Range & Get Text
|
||||||
|
let cfiRange = this.getCFIRange(startCFI, endCFI);
|
||||||
|
let textRange = await this.book.getRange(cfiRange);
|
||||||
|
let visibleText = textRange.toString();
|
||||||
|
|
||||||
|
// Split on Whitespace
|
||||||
|
return visibleText.trim().split(/\s+/).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given two CFI's, return range
|
||||||
|
**/
|
||||||
|
getCFIRange(a, b) {
|
||||||
|
const CFI = new ePub.CFI();
|
||||||
|
const start = CFI.parse(a),
|
||||||
|
end = CFI.parse(b);
|
||||||
|
const cfi = {
|
||||||
|
range: true,
|
||||||
|
base: start.base,
|
||||||
|
path: {
|
||||||
|
steps: [],
|
||||||
|
terminal: null,
|
||||||
|
},
|
||||||
|
start: start.path,
|
||||||
|
end: end.path,
|
||||||
|
};
|
||||||
|
const len = cfi.start.steps.length;
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
if (CFI.equalStep(cfi.start.steps[i], cfi.end.steps[i])) {
|
||||||
|
if (i == len - 1) {
|
||||||
|
// Last step is equal, check terminals
|
||||||
|
if (cfi.start.terminal === cfi.end.terminal) {
|
||||||
|
// CFI's are equal
|
||||||
|
cfi.path.steps.push(cfi.start.steps[i]);
|
||||||
|
// Not a range
|
||||||
|
cfi.range = false;
|
||||||
|
}
|
||||||
|
} else cfi.path.steps.push(cfi.start.steps[i]);
|
||||||
|
} else break;
|
||||||
|
}
|
||||||
|
cfi.start.steps = cfi.start.steps.slice(cfi.path.steps.length);
|
||||||
|
cfi.end.steps = cfi.end.steps.slice(cfi.path.steps.length);
|
||||||
|
|
||||||
|
return (
|
||||||
|
"epubcfi(" +
|
||||||
|
CFI.segmentString(cfi.base) +
|
||||||
|
"!" +
|
||||||
|
CFI.segmentString(cfi.path) +
|
||||||
|
"," +
|
||||||
|
CFI.segmentString(cfi.start) +
|
||||||
|
"," +
|
||||||
|
CFI.segmentString(cfi.end) +
|
||||||
|
")"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save settings to localStorage
|
||||||
|
**/
|
||||||
|
saveSettings(obj) {
|
||||||
|
if (!this.readerSettings) this.loadSettings();
|
||||||
|
let newSettings = Object.assign(this.readerSettings, obj);
|
||||||
|
localStorage.setItem("readerSettings", JSON.stringify(newSettings));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load reader settings from localStorage
|
||||||
|
**/
|
||||||
|
loadSettings() {
|
||||||
|
this.readerSettings = JSON.parse(
|
||||||
|
localStorage.getItem("readerSettings") || "{}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
15
assets/reader/jszip.min.js
vendored
Normal file
15
assets/reader/jszip.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
24
assets/reader/readerThemes.css
Normal file
24
assets/reader/readerThemes.css
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
.light {
|
||||||
|
background-color: #fff;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tan {
|
||||||
|
background: #d2b48c;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blue {
|
||||||
|
background: #1f2937;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gray {
|
||||||
|
background: #232323;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.black {
|
||||||
|
background: #000;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
{{template "base.html" .}} {{define "title"}}Activity{{end}} {{define "header"}}
|
{{template "base.html" .}} {{define "title"}}Activity{{end}} {{define "header"}}
|
||||||
<a href="./activity">Activity</a>
|
<a href="./activity">Activity</a>
|
||||||
{{end}} {{define "content"}}
|
{{end}} {{define "content"}}
|
||||||
<div class="px-4 -mx-4 overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<div class="inline-block min-w-full overflow-hidden rounded shadow">
|
<div class="inline-block min-w-full overflow-hidden rounded shadow">
|
||||||
<table class="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm md:text-sm">
|
<table class="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm md:text-sm">
|
||||||
<thead class="text-gray-800 dark:text-gray-400">
|
<thead class="text-gray-800 dark:text-gray-400">
|
||||||
|
@ -12,7 +12,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-100 dark:bg-gray-800">
|
<body class="bg-gray-100 dark:bg-gray-800">
|
||||||
<main
|
<main
|
||||||
class="relative h-screen overflow-hidden"
|
class="relative h-[100dvh] overflow-hidden"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between w-full h-16">
|
<div class="flex items-center justify-between w-full h-16">
|
||||||
<div id="mobile-nav-button" class="flex flex-col z-40 relative ml-6">
|
<div id="mobile-nav-button" class="flex flex-col z-40 relative ml-6">
|
||||||
@ -180,7 +180,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="h-screen px-4 pb-24 overflow-auto md:px-6 lg:ml-48">
|
<div class="h-[100dvh] px-4 pb-20 overflow-auto md:px-6 lg:ml-48">
|
||||||
{{block "content" .}}{{end}}
|
{{block "content" .}}{{end}}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
@ -14,6 +14,14 @@
|
|||||||
<label class="z-10 cursor-pointer" for="edit-cover-button">
|
<label class="z-10 cursor-pointer" for="edit-cover-button">
|
||||||
<img class="rounded object-fill w-full" src="{{ .RelBase }}./documents/{{.Data.ID}}/cover"></img>
|
<img class="rounded object-fill w-full" src="{{ .RelBase }}./documents/{{.Data.ID}}/cover"></img>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{{ if .Data.Filepath }}
|
||||||
|
<a
|
||||||
|
href="./{{ .Data.ID }}/reader"
|
||||||
|
class="z-10 text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded text-sm text-center py-1 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
||||||
|
>Read</a>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
<div class="flex flex-wrap-reverse justify-between z-20 gap-2 relative">
|
<div class="flex flex-wrap-reverse justify-between z-20 gap-2 relative">
|
||||||
<div class="min-w-[50%] md:mr-2">
|
<div class="min-w-[50%] md:mr-2">
|
||||||
<div class="flex gap-1 text-sm">
|
<div class="flex gap-1 text-sm">
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{{range $doc := .Data }}
|
{{range $doc := .Data }}
|
||||||
<div class="w-full relative">
|
<div class="w-full relative">
|
||||||
<div class="flex gap-4 w-full h-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded">
|
<div class="flex gap-4 w-full h-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded">
|
||||||
|
@ -1,244 +1,256 @@
|
|||||||
{{template "base.html" .}} {{define "title"}}Home{{end}} {{define "header"}}
|
{{template "base.html" .}} {{define "title"}}Home{{end}} {{define "header"}}
|
||||||
<a href="./">Home</a>
|
<a href="./">Home</a>
|
||||||
{{end}} {{define "content"}}
|
{{end}} {{define "content"}}
|
||||||
<div class="w-full">
|
<div class="flex flex-col gap-4">
|
||||||
<div
|
|
||||||
class="relative w-full px-4 py-4 bg-white shadow-lg dark:bg-gray-700 rounded"
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
class="absolute top-3 text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500"
|
|
||||||
>
|
|
||||||
Daily Read Totals
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{{ $data := (GetSVGGraphData .Data.GraphData 800 70 )}}
|
|
||||||
<svg
|
|
||||||
viewBox="26 0 755 {{ $data.Height }}"
|
|
||||||
preserveAspectRatio="none"
|
|
||||||
width="100%"
|
|
||||||
height="4em"
|
|
||||||
>
|
|
||||||
<!-- Bezier Line Graph -->
|
|
||||||
<path
|
|
||||||
fill="#316BBE"
|
|
||||||
fill-opacity="0.5"
|
|
||||||
stroke="none"
|
|
||||||
d="{{ $data.BezierPath }} {{ $data.BezierFill }}"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<path fill="none" stroke="#316BBE" d="{{ $data.BezierPath }}" />
|
|
||||||
|
|
||||||
{{ range $index, $item := $data.LinePoints }}
|
|
||||||
<line
|
|
||||||
class="hover-trigger"
|
|
||||||
stroke="black"
|
|
||||||
stroke-opacity="0.0"
|
|
||||||
stroke-width="{{ $data.Offset }}"
|
|
||||||
x1="{{ $item.X }}"
|
|
||||||
x2="{{ $item.X }}"
|
|
||||||
y1="0"
|
|
||||||
y2="{{ $data.Height }}"
|
|
||||||
></line>
|
|
||||||
<g class="hover-item">
|
|
||||||
<line
|
|
||||||
class="text-black dark:text-white"
|
|
||||||
stroke-opacity="0.2"
|
|
||||||
x1="{{ $item.X }}"
|
|
||||||
x2="{{ $item.X }}"
|
|
||||||
y1="30"
|
|
||||||
y2="{{ $data.Height }}"
|
|
||||||
></line>
|
|
||||||
<text
|
|
||||||
class="text-black dark:text-white"
|
|
||||||
alignment-baseline="middle"
|
|
||||||
transform="translate({{ $item.X }}, 5) translate(-30, 8)"
|
|
||||||
font-size="10"
|
|
||||||
>
|
|
||||||
{{ (index $.Data.GraphData $index).Date }}
|
|
||||||
</text>
|
|
||||||
<text
|
|
||||||
class="text-black dark:text-white"
|
|
||||||
alignment-baseline="middle"
|
|
||||||
transform="translate({{ $item.X }}, 25) translate(-30, -2)"
|
|
||||||
font-size="10"
|
|
||||||
>
|
|
||||||
{{ (index $.Data.GraphData $index).MinutesRead }} minutes
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
{{ end }}
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* Interactive Hover */
|
|
||||||
.hover-item {
|
|
||||||
visibility: hidden;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
.hover-trigger:hover + .hover-item,
|
|
||||||
.hover-item:hover {
|
|
||||||
visibility: visible;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* SVG Component Styling */
|
|
||||||
svg text.text-black {
|
|
||||||
fill: black;
|
|
||||||
}
|
|
||||||
svg line.text-black {
|
|
||||||
stroke: black;
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
svg text.dark\:text-white {
|
|
||||||
fill: white;
|
|
||||||
}
|
|
||||||
svg line.dark\:text-white {
|
|
||||||
stroke: white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4 my-4 md:grid-cols-4">
|
|
||||||
<a href="./documents" class="w-full">
|
|
||||||
<div
|
|
||||||
class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col justify-around dark:text-white w-full text-sm">
|
|
||||||
<p class="text-2xl font-bold text-black dark:text-white">
|
|
||||||
{{ .Data.DatabaseInfo.DocumentsSize }}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-gray-400">Documents</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<a href="./activity" class="w-full">
|
|
||||||
<div
|
|
||||||
class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col justify-around dark:text-white w-full text-sm">
|
|
||||||
<p class="text-2xl font-bold text-black dark:text-white">
|
|
||||||
{{ .Data.DatabaseInfo.ActivitySize }}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-gray-400">Activity Records</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div
|
<div
|
||||||
class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded"
|
class="relative w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded"
|
||||||
>
|
|
||||||
<div class="flex flex-col justify-around dark:text-white w-full text-sm">
|
|
||||||
<p class="text-2xl font-bold text-black dark:text-white">
|
|
||||||
{{ .Data.DatabaseInfo.ProgressSize }}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-gray-400">Progress Records</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="w-full">
|
|
||||||
<div
|
|
||||||
class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col justify-around dark:text-white w-full text-sm">
|
|
||||||
<p class="text-2xl font-bold text-black dark:text-white">
|
|
||||||
{{ .Data.DatabaseInfo.DevicesSize }}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-gray-400">Devices</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 my-4 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{{ range $item := .Data.Streaks }}
|
|
||||||
<div class="w-full">
|
|
||||||
<div
|
|
||||||
class="relative w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700 rounded"
|
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
class="text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500"
|
class="absolute top-3 text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500"
|
||||||
>
|
>
|
||||||
{{ if eq $item.Window "WEEK" }} Weekly Read Streak {{ else }} Daily Read
|
Daily Read Totals
|
||||||
Streak {{ end }}
|
|
||||||
</p>
|
</p>
|
||||||
<div class="flex items-end my-6 space-x-2">
|
|
||||||
<p class="text-5xl font-bold text-black dark:text-white">
|
{{ $data := (GetSVGGraphData .Data.GraphData 800 70 )}}
|
||||||
{{ $item.CurrentStreak }}
|
<svg
|
||||||
</p>
|
viewBox="26 0 755 {{ $data.Height }}"
|
||||||
</div>
|
preserveAspectRatio="none"
|
||||||
<div class="dark:text-white">
|
width="100%"
|
||||||
|
height="4em"
|
||||||
|
>
|
||||||
|
<!-- Bezier Line Graph -->
|
||||||
|
<path
|
||||||
|
fill="#316BBE"
|
||||||
|
fill-opacity="0.5"
|
||||||
|
stroke="none"
|
||||||
|
d="{{ $data.BezierPath }} {{ $data.BezierFill }}"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<path fill="none" stroke="#316BBE" d="{{ $data.BezierPath }}" />
|
||||||
|
|
||||||
|
{{ range $index, $item := $data.LinePoints }}
|
||||||
|
<line
|
||||||
|
class="hover-trigger"
|
||||||
|
stroke="black"
|
||||||
|
stroke-opacity="0.0"
|
||||||
|
stroke-width="{{ $data.Offset }}"
|
||||||
|
x1="{{ $item.X }}"
|
||||||
|
x2="{{ $item.X }}"
|
||||||
|
y1="0"
|
||||||
|
y2="{{ $data.Height }}"
|
||||||
|
></line>
|
||||||
|
<g class="hover-item">
|
||||||
|
<line
|
||||||
|
class="text-black dark:text-white"
|
||||||
|
stroke-opacity="0.2"
|
||||||
|
x1="{{ $item.X }}"
|
||||||
|
x2="{{ $item.X }}"
|
||||||
|
y1="30"
|
||||||
|
y2="{{ $data.Height }}"
|
||||||
|
></line>
|
||||||
|
<text
|
||||||
|
class="text-black dark:text-white"
|
||||||
|
alignment-baseline="middle"
|
||||||
|
transform="translate({{ $item.X }}, 5) translate(-30, 8)"
|
||||||
|
font-size="10"
|
||||||
|
>
|
||||||
|
{{ (index $.Data.GraphData $index).Date }}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
class="text-black dark:text-white"
|
||||||
|
alignment-baseline="middle"
|
||||||
|
transform="translate({{ $item.X }}, 25) translate(-30, -2)"
|
||||||
|
font-size="10"
|
||||||
|
>
|
||||||
|
{{ (index $.Data.GraphData $index).MinutesRead }} minutes
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
{{ end }}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Interactive Hover */
|
||||||
|
.hover-item {
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.hover-trigger:hover + .hover-item,
|
||||||
|
.hover-item:hover {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SVG Component Styling */
|
||||||
|
svg text.text-black {
|
||||||
|
fill: black;
|
||||||
|
}
|
||||||
|
svg line.text-black {
|
||||||
|
stroke: black;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
svg text.dark\:text-white {
|
||||||
|
fill: white;
|
||||||
|
}
|
||||||
|
svg line.dark\:text-white {
|
||||||
|
stroke: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
|
<a href="./documents" class="w-full">
|
||||||
|
<div
|
||||||
|
class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between pb-2 mb-2 text-sm border-b border-gray-200"
|
class="flex flex-col justify-around dark:text-white w-full text-sm"
|
||||||
>
|
>
|
||||||
<div>
|
<p class="text-2xl font-bold text-black dark:text-white">
|
||||||
<p>
|
{{ .Data.DatabaseInfo.DocumentsSize }}
|
||||||
{{ if eq $item.Window "WEEK" }} Current Weekly Streak {{ else }}
|
</p>
|
||||||
Current Daily Streak {{ end }}
|
<p class="text-sm text-gray-400">Documents</p>
|
||||||
</p>
|
|
||||||
<div class="flex items-end text-sm text-gray-400">
|
|
||||||
{{ $item.CurrentStreakStartDate }} ➞ {{ $item.CurrentStreakEndDate
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-end font-bold">{{ $item.CurrentStreak }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between pb-2 mb-2 text-sm">
|
</div>
|
||||||
<div>
|
</a>
|
||||||
<p>
|
<a href="./activity" class="w-full">
|
||||||
{{ if eq $item.Window "WEEK" }} Best Weekly Streak {{ else }} Best
|
<div
|
||||||
Daily Streak {{ end }}
|
class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded"
|
||||||
</p>
|
>
|
||||||
<div class="flex items-end text-sm text-gray-400">
|
<div
|
||||||
{{ $item.MaxStreakStartDate }} ➞ {{ $item.MaxStreakEndDate }}
|
class="flex flex-col justify-around dark:text-white w-full text-sm"
|
||||||
</div>
|
>
|
||||||
</div>
|
<p class="text-2xl font-bold text-black dark:text-white">
|
||||||
<div class="flex items-end font-bold">{{ $item.MaxStreak }}</div>
|
{{ .Data.DatabaseInfo.ActivitySize }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-400">Activity Records</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<div class="w-full">
|
||||||
|
<div
|
||||||
|
class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex flex-col justify-around dark:text-white w-full text-sm"
|
||||||
|
>
|
||||||
|
<p class="text-2xl font-bold text-black dark:text-white">
|
||||||
|
{{ .Data.DatabaseInfo.ProgressSize }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-400">Progress Records</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full">
|
||||||
|
<div
|
||||||
|
class="flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex flex-col justify-around dark:text-white w-full text-sm"
|
||||||
|
>
|
||||||
|
<p class="text-2xl font-bold text-black dark:text-white">
|
||||||
|
{{ .Data.DatabaseInfo.DevicesSize }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-400">Devices</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
<div class="w-full">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<div
|
{{ range $item := .Data.Streaks }}
|
||||||
class="flex flex-col justify-between h-full w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700 rounded"
|
<div class="w-full">
|
||||||
>
|
<div
|
||||||
<div>
|
class="relative w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700 rounded"
|
||||||
|
>
|
||||||
<p
|
<p
|
||||||
class="text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500"
|
class="text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500"
|
||||||
>
|
>
|
||||||
WPM Leaderboard
|
{{ if eq $item.Window "WEEK" }} Weekly Read Streak {{ else }} Daily
|
||||||
|
Read Streak {{ end }}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex items-end my-6 space-x-2">
|
<div class="flex items-end my-6 space-x-2">
|
||||||
{{ $length := len .Data.WPMLeaderboard }} {{ if eq $length 0 }}
|
|
||||||
<p class="text-5xl font-bold text-black dark:text-white">N/A</p>
|
|
||||||
{{ else }}
|
|
||||||
<p class="text-5xl font-bold text-black dark:text-white">
|
<p class="text-5xl font-bold text-black dark:text-white">
|
||||||
{{ (index .Data.WPMLeaderboard 0).UserID }}
|
{{ $item.CurrentStreak }}
|
||||||
</p>
|
</p>
|
||||||
{{ end }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="dark:text-white">
|
||||||
<div class="dark:text-white">
|
|
||||||
{{ range $index, $item := .Data.WPMLeaderboard }} {{ if lt $index 3 }}
|
|
||||||
{{ if eq $index 0 }}
|
|
||||||
<div class="flex items-center justify-between pt-2 pb-2 text-sm">
|
|
||||||
{{ else }}
|
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between pt-2 pb-2 text-sm border-t border-gray-200"
|
class="flex items-center justify-between pb-2 mb-2 text-sm border-b border-gray-200"
|
||||||
>
|
>
|
||||||
{{ end }}
|
|
||||||
<div>
|
<div>
|
||||||
<p>{{ $item.UserID }}</p>
|
<p>
|
||||||
|
{{ if eq $item.Window "WEEK" }} Current Weekly Streak {{ else }}
|
||||||
|
Current Daily Streak {{ end }}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-end text-sm text-gray-400">
|
||||||
|
{{ $item.CurrentStreakStartDate }} ➞ {{
|
||||||
|
$item.CurrentStreakEndDate }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end font-bold">
|
||||||
|
{{ $item.CurrentStreak }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-end font-bold">{{ $item.Wpm }} WPM</div>
|
|
||||||
</div>
|
</div>
|
||||||
{{ end }} {{ end }}
|
<div class="flex items-center justify-between pb-2 mb-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
{{ if eq $item.Window "WEEK" }} Best Weekly Streak {{ else }}
|
||||||
|
Best Daily Streak {{ end }}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-end text-sm text-gray-400">
|
||||||
|
{{ $item.MaxStreakStartDate }} ➞ {{ $item.MaxStreakEndDate }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end font-bold">{{ $item.MaxStreak }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{{ end }}
|
||||||
|
|
||||||
{{end}}
|
<div class="w-full">
|
||||||
|
<div
|
||||||
|
class="flex flex-col justify-between h-full w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700 rounded"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
class="text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500"
|
||||||
|
>
|
||||||
|
WPM Leaderboard
|
||||||
|
</p>
|
||||||
|
<div class="flex items-end my-6 space-x-2">
|
||||||
|
{{ $length := len .Data.WPMLeaderboard }} {{ if eq $length 0 }}
|
||||||
|
<p class="text-5xl font-bold text-black dark:text-white">N/A</p>
|
||||||
|
{{ else }}
|
||||||
|
<p class="text-5xl font-bold text-black dark:text-white">
|
||||||
|
{{ (index .Data.WPMLeaderboard 0).UserID }}
|
||||||
|
</p>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dark:text-white">
|
||||||
|
{{ range $index, $item := .Data.WPMLeaderboard }} {{ if lt $index 3 }}
|
||||||
|
{{ if eq $index 0 }}
|
||||||
|
<div class="flex items-center justify-between pt-2 pb-2 text-sm">
|
||||||
|
{{ else }}
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between pt-2 pb-2 text-sm border-t border-gray-200"
|
||||||
|
>
|
||||||
|
{{ end }}
|
||||||
|
<div>
|
||||||
|
<p>{{ $item.UserID }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end font-bold">{{ $item.Wpm }} WPM</div>
|
||||||
|
</div>
|
||||||
|
{{ end }} {{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
181
templates/reader-base.html
Normal file
181
templates/reader-base.html
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<link rel="manifest" href="{{ .RelBase }}./manifest.json" />
|
||||||
|
<meta
|
||||||
|
name="theme-color"
|
||||||
|
content="#F3F4F6"
|
||||||
|
media="(prefers-color-scheme: light)"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="theme-color"
|
||||||
|
content="#1F2937"
|
||||||
|
media="(prefers-color-scheme: dark)"
|
||||||
|
/>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta
|
||||||
|
id="viewport"
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||||
|
/>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<title>Book Manager - {{block "title" .}}{{end}}</title>
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
overscroll-behavior-y: none;
|
||||||
|
margin: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For Webkit-based browsers (Chrome, Safari and Opera) */
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For IE, Edge and Firefox */
|
||||||
|
.no-scrollbar {
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100 dark:bg-gray-800">
|
||||||
|
<main class="relative h-[100dvh] overflow-hidden">
|
||||||
|
<div
|
||||||
|
id="top-bar"
|
||||||
|
class="-top-32 transition-all duration-200 absolute z-10 bg-gray-100 dark:bg-gray-800 flex items-center justify-around w-full h-32 px-2"
|
||||||
|
>
|
||||||
|
<div class="text-gray-500 absolute top-6 left-4 flex flex-col gap-4">
|
||||||
|
<a href="../{{ .Data.ID }}">
|
||||||
|
<svg
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
class="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
d="M20.5355 3.46447C19.0711 2 16.714 2 12 2C7.28595 2 4.92893 2 3.46447 3.46447C2 4.92893 2 7.28595 2 12C2 16.714 2 19.0711 3.46447 20.5355C4.92893 22 7.28595 22 12 22C16.714 22 19.0711 22 20.5355 20.5355C22 19.0711 22 16.714 22 12C22 7.28595 22 4.92893 20.5355 3.46447ZM14.0303 8.46967C14.3232 8.76256 14.3232 9.23744 14.0303 9.53033L11.5607 12L14.0303 14.4697C14.3232 14.7626 14.3232 15.2374 14.0303 15.5303C13.7374 15.8232 13.2626 15.8232 12.9697 15.5303L9.96967 12.5303C9.82902 12.3897 9.75 12.1989 9.75 12C9.75 11.8011 9.82902 11.6103 9.96967 11.4697L12.9697 8.46967C13.2626 8.17678 13.7374 8.17678 14.0303 8.46967Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
class="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100 close-top-bar"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
d="M12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2C16.714 2 19.0711 2 20.5355 3.46447C22 4.92893 22 7.28595 22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22ZM8.96965 8.96967C9.26254 8.67678 9.73742 8.67678 10.0303 8.96967L12 10.9394L13.9696 8.96969C14.2625 8.6768 14.7374 8.6768 15.0303 8.96969C15.3232 9.26258 15.3232 9.73746 15.0303 10.0303L13.0606 12L15.0303 13.9697C15.3232 14.2625 15.3232 14.7374 15.0303 15.0303C14.7374 15.3232 14.2625 15.3232 13.9696 15.0303L12 13.0607L10.0303 15.0303C9.73744 15.3232 9.26256 15.3232 8.96967 15.0303C8.67678 14.7374 8.67678 14.2626 8.96967 13.9697L10.9393 12L8.96965 10.0303C8.67676 9.73744 8.67676 9.26256 8.96965 8.96967Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-10 h-full p-4 pl-14 rounded">
|
||||||
|
<div class="h-full my-auto relative">
|
||||||
|
<a href="../{{ .Data.ID }}">
|
||||||
|
<img class="rounded object-cover h-full" src="./cover" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-7 justify-around dark:text-white text-sm">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="inline-flex shrink-0 items-center">
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-400">Title</p>
|
||||||
|
<p
|
||||||
|
class="font-medium whitespace-nowrap text-ellipsis overflow-hidden max-w-[50dvw]"
|
||||||
|
>
|
||||||
|
{{ or .Data.Title "N/A" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex shrink-0 items-center">
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-400">Author</p>
|
||||||
|
<p
|
||||||
|
class="font-medium whitespace-nowrap text-ellipsis overflow-hidden max-w-[50dvw]"
|
||||||
|
>
|
||||||
|
{{ or .Data.Author "N/A" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="bottom-bar"
|
||||||
|
class="-bottom-20 transition-all duration-200 absolute z-10 bg-gray-100 dark:bg-gray-800 items-center flex h-20 w-full overflow-y-scroll snap-x snap-mandatory no-scrollbar"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="items-center flex flex-col w-screen flex-none snap-center px-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap gap-2 justify-around w-full dark:text-white pb-2"
|
||||||
|
>
|
||||||
|
<div class="flex justify-center gap-2 w-full md:w-fit">
|
||||||
|
<p class="text-gray-400 text-xs">Chapter:</p>
|
||||||
|
<p id="chapter-name-status" class="text-xs">N/A</p>
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex gap-2">
|
||||||
|
<p class="text-gray-400 text-xs">Chapter Pages:</p>
|
||||||
|
<p id="chapter-status" class="text-xs">N/A</p>
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex gap-2">
|
||||||
|
<p class="text-gray-400 text-xs">Progress:</p>
|
||||||
|
<p id="progress-status" class="text-xs">N/A</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full h-2 rounded border border-gray-500">
|
||||||
|
<div
|
||||||
|
id="progress-bar-status"
|
||||||
|
class="w-0 bg-green-200 h-full rounded-l"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="items-center flex flex-col w-screen flex-none snap-center">
|
||||||
|
<p class="text-gray-400">Theme</p>
|
||||||
|
<div class="flex justify-around w-full gap-4 p-2">
|
||||||
|
<div
|
||||||
|
class="theme cursor-pointer rounded border-2 border-white bg-[#fff] text-[#000] grow p-1 text-center"
|
||||||
|
>
|
||||||
|
light
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="theme cursor-pointer rounded border-2 border-white bg-[#d2b48c] text-[#333] grow p-1 text-center"
|
||||||
|
>
|
||||||
|
tan
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="theme cursor-pointer rounded border-2 border-white bg-[#1f2937] text-[#fff] grow p-1 text-center"
|
||||||
|
>
|
||||||
|
blue
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="theme cursor-pointer rounded border-2 border-white bg-[#232323] text-[#fff] grow p-1 text-center"
|
||||||
|
>
|
||||||
|
gray
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="theme cursor-pointer rounded border-2 border-white bg-[#000] text-[#ccc] grow p-1 text-center"
|
||||||
|
>
|
||||||
|
black
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="h-[100dvh] px-4 pb-24 overflow-auto md:px-6 lg:ml-48">
|
||||||
|
{{block "content" .}}{{end}}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
18
templates/reader.html
Normal file
18
templates/reader.html
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{{template "base.html" .}} {{define "title"}}Reader{{end}} {{define "header"}}
|
||||||
|
<a href="../">Documents</a>
|
||||||
|
{{end}} {{define "content"}}
|
||||||
|
<script src="../../assets/reader/jszip.min.js"></script>
|
||||||
|
<script src="../../assets/reader/epub.min.js"></script>
|
||||||
|
<script src="../../assets/reader/index.js"></script>
|
||||||
|
<div id="viewer" class="w-full h-[100dvh] absolute top-0 left-0"></div>
|
||||||
|
<div id="hiddden-viewer" class="hidden"></div>
|
||||||
|
<script>
|
||||||
|
let currentReader = new EBookReader("./file", {
|
||||||
|
words: {{ .Data.Words }},
|
||||||
|
pages: {{ .Data.Pages }},
|
||||||
|
progress: "{{ .Progress }}",
|
||||||
|
percentage: {{ .Data.Percentage }},
|
||||||
|
currentWord: {{ .Data.Percentage }} * ({{ .Data.Words }} / 100),
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{ end}}
|
@ -1,220 +1,218 @@
|
|||||||
{{template "base.html" .}} {{define "title"}}Settings{{end}} {{define "header"}}
|
{{template "base.html" .}} {{define "title"}}Settings{{end}} {{define "header"}}
|
||||||
<a href="./settings">Settings</a>
|
<a href="./settings">Settings</a>
|
||||||
{{end}} {{define "content"}}
|
{{end}} {{define "content"}}
|
||||||
<div class="h-full w-full relative">
|
<div class="w-full flex flex-col md:flex-row gap-4">
|
||||||
<div class="w-full flex flex-col md:flex-row gap-4">
|
<div>
|
||||||
<div>
|
<div
|
||||||
<div
|
class="flex flex-col p-4 items-center rounded shadow-lg md:w-60 lg:w-80 bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
|
||||||
class="flex flex-col p-4 items-center rounded shadow-lg md:w-60 lg:w-80 bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
|
>
|
||||||
|
<svg
|
||||||
|
width="60"
|
||||||
|
fill="currentColor"
|
||||||
|
height="60"
|
||||||
|
class="text-gray-800 dark:text-gray-200"
|
||||||
|
viewBox="0 0 1792 1792"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<svg
|
<path
|
||||||
width="60"
|
d="M1523 1339q-22-155-87.5-257.5t-184.5-118.5q-67 74-159.5 115.5t-195.5 41.5-195.5-41.5-159.5-115.5q-119 16-184.5 118.5t-87.5 257.5q106 150 271 237.5t356 87.5 356-87.5 271-237.5zm-243-699q0-159-112.5-271.5t-271.5-112.5-271.5 112.5-112.5 271.5 112.5 271.5 271.5 112.5 271.5-112.5 112.5-271.5zm512 256q0 182-71 347.5t-190.5 286-285.5 191.5-349 71q-182 0-348-71t-286-191-191-286-71-348 71-348 191-286 286-191 348-71 348 71 286 191 191 286 71 348z"
|
||||||
fill="currentColor"
|
/>
|
||||||
height="60"
|
</svg>
|
||||||
class="text-gray-800 dark:text-gray-200"
|
<p class="text-lg">{{ .User }}</p>
|
||||||
viewBox="0 0 1792 1792"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M1523 1339q-22-155-87.5-257.5t-184.5-118.5q-67 74-159.5 115.5t-195.5 41.5-195.5-41.5-159.5-115.5q-119 16-184.5 118.5t-87.5 257.5q106 150 271 237.5t356 87.5 356-87.5 271-237.5zm-243-699q0-159-112.5-271.5t-271.5-112.5-271.5 112.5-112.5 271.5 112.5 271.5 271.5 112.5 271.5-112.5 112.5-271.5zm512 256q0 182-71 347.5t-190.5 286-285.5 191.5-349 71q-182 0-348-71t-286-191-191-286-71-348 71-348 191-286 286-191 348-71 348 71 286 191 191 286 71 348z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<p class="text-lg">{{ .User }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4 grow">
|
<div class="flex flex-col gap-4 grow">
|
||||||
<div
|
<div
|
||||||
class="flex flex-col gap-2 grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
|
class="flex flex-col gap-2 grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
|
||||||
|
>
|
||||||
|
<p class="text-lg font-semibold mb-2">Change Password</p>
|
||||||
|
<form
|
||||||
|
class="flex gap-4 flex-col lg:flex-row"
|
||||||
|
action="./settings"
|
||||||
|
method="POST"
|
||||||
>
|
>
|
||||||
<p class="text-lg font-semibold mb-2">Change Password</p>
|
<div class="flex flex-col grow">
|
||||||
<form
|
<div class="flex relative">
|
||||||
class="flex gap-4 flex-col lg:flex-row"
|
|
||||||
action="./settings"
|
|
||||||
method="POST"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col grow">
|
|
||||||
<div class="flex relative">
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="15"
|
|
||||||
height="15"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 1792 1792"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M1376 768q40 0 68 28t28 68v576q0 40-28 68t-68 28h-960q-40 0-68-28t-28-68v-576q0-40 28-68t68-28h32v-320q0-185 131.5-316.5t316.5-131.5 316.5 131.5 131.5 316.5q0 26-19 45t-45 19h-64q-26 0-45-19t-19-45q0-106-75-181t-181-75-181 75-75 181v320h736z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
class="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
|
||||||
placeholder="Password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col grow">
|
|
||||||
<div class="flex relative">
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="15"
|
|
||||||
height="15"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 1792 1792"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M1376 768q40 0 68 28t28 68v576q0 40-28 68t-68 28h-960q-40 0-68-28t-28-68v-576q0-40 28-68t68-28h32v-320q0-185 131.5-316.5t316.5-131.5 316.5 131.5 131.5 316.5q0 26-19 45t-45 19h-64q-26 0-45-19t-19-45q0-106-75-181t-181-75-181 75-75 181v320h736z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
id="new_password"
|
|
||||||
name="new_password"
|
|
||||||
class="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
|
||||||
placeholder="New Password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"
|
|
||||||
>
|
|
||||||
<span class="w-full">Submit</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{{ if .PasswordErrorMessage }}
|
|
||||||
<span class="text-red-400 text-xs">{{ .PasswordErrorMessage }}</span>
|
|
||||||
{{ else if .PasswordMessage }}
|
|
||||||
<span class="text-green-400 text-xs">{{ .PasswordMessage }}</span>
|
|
||||||
{{ end }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex flex-col grow gap-2 p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
|
|
||||||
>
|
|
||||||
<p class="text-lg font-semibold mb-2">Change Time Offset</p>
|
|
||||||
<form
|
|
||||||
class="flex gap-4 flex-col lg:flex-row"
|
|
||||||
action="./settings"
|
|
||||||
method="POST"
|
|
||||||
>
|
|
||||||
<div class="flex relative grow">
|
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
|
class="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
width="16"
|
width="15"
|
||||||
height="16"
|
height="15"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 1792 1792"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z"
|
d="M1376 768q40 0 68 28t28 68v576q0 40-28 68t-68 28h-960q-40 0-68-28t-28-68v-576q0-40 28-68t68-28h32v-320q0-185 131.5-316.5t316.5-131.5 316.5 131.5 131.5 316.5q0 26-19 45t-45 19h-64q-26 0-45-19t-19-45q0-106-75-181t-181-75-181 75-75 181v320h736z"
|
||||||
/>
|
></path>
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
d="M12 7.25C12.4142 7.25 12.75 7.58579 12.75 8V11.6893L15.0303 13.9697C15.3232 14.2626 15.3232 14.7374 15.0303 15.0303C14.7374 15.3232 14.2626 15.3232 13.9697 15.0303L11.4697 12.5303C11.329 12.3897 11.25 12.1989 11.25 12V8C11.25 7.58579 11.5858 7.25 12 7.25Z"
|
|
||||||
fill="white"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<select
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
class="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
class="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
||||||
id="time_offset"
|
placeholder="Password"
|
||||||
name="time_offset"
|
/>
|
||||||
>
|
|
||||||
{{ range $item := GetUTCOffsets }}
|
|
||||||
<option
|
|
||||||
{{
|
|
||||||
if
|
|
||||||
(eq
|
|
||||||
$item.Value
|
|
||||||
$.Data.Settings.TimeOffset)
|
|
||||||
}}selected{{
|
|
||||||
end
|
|
||||||
}}
|
|
||||||
value="{{ $item.Value }}"
|
|
||||||
>
|
|
||||||
{{ $item.Name }}
|
|
||||||
</option>
|
|
||||||
{{ end }}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
</div>
|
||||||
type="submit"
|
<div class="flex flex-col grow">
|
||||||
class="px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"
|
<div class="flex relative">
|
||||||
>
|
<span
|
||||||
<span class="w-full">Submit</span>
|
class="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
|
||||||
</button>
|
>
|
||||||
</form>
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 1792 1792"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M1376 768q40 0 68 28t28 68v576q0 40-28 68t-68 28h-960q-40 0-68-28t-28-68v-576q0-40 28-68t68-28h32v-320q0-185 131.5-316.5t316.5-131.5 316.5 131.5 131.5 316.5q0 26-19 45t-45 19h-64q-26 0-45-19t-19-45q0-106-75-181t-181-75-181 75-75 181v320h736z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="new_password"
|
||||||
|
name="new_password"
|
||||||
|
class="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
||||||
|
placeholder="New Password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"
|
||||||
|
>
|
||||||
|
<span class="w-full">Submit</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{{ if .PasswordErrorMessage }}
|
||||||
|
<span class="text-red-400 text-xs">{{ .PasswordErrorMessage }}</span>
|
||||||
|
{{ else if .PasswordMessage }}
|
||||||
|
<span class="text-green-400 text-xs">{{ .PasswordMessage }}</span>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
{{ if .TimeOffsetErrorMessage }}
|
<div
|
||||||
<span class="text-red-400 text-xs">{{ .TimeOffsetErrorMessage }}</span>
|
class="flex flex-col grow gap-2 p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
|
||||||
{{ else if .TimeOffsetMessage }}
|
>
|
||||||
<span class="text-green-400 text-xs">{{ .TimeOffsetMessage }}</span>
|
<p class="text-lg font-semibold mb-2">Change Time Offset</p>
|
||||||
{{ end }}
|
<form
|
||||||
</div>
|
class="flex gap-4 flex-col lg:flex-row"
|
||||||
|
action="./settings"
|
||||||
<div
|
method="POST"
|
||||||
class="flex flex-col grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
|
|
||||||
>
|
>
|
||||||
<p class="text-lg font-semibold">Devices</p>
|
<div class="flex relative grow">
|
||||||
<table class="min-w-full bg-white dark:bg-gray-700 text-sm">
|
<span
|
||||||
<thead class="text-gray-800 dark:text-gray-400">
|
class="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
|
||||||
<tr>
|
>
|
||||||
<th
|
<svg
|
||||||
scope="col"
|
width="16"
|
||||||
class="p-3 pl-0 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
height="16"
|
||||||
>
|
fill="currentColor"
|
||||||
Name
|
viewBox="0 0 24 24"
|
||||||
</th>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<th
|
>
|
||||||
scope="col"
|
<path
|
||||||
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
d="M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z"
|
||||||
>
|
/>
|
||||||
Last Sync
|
<path
|
||||||
</th>
|
fill-rule="evenodd"
|
||||||
<th
|
clip-rule="evenodd"
|
||||||
scope="col"
|
d="M12 7.25C12.4142 7.25 12.75 7.58579 12.75 8V11.6893L15.0303 13.9697C15.3232 14.2626 15.3232 14.7374 15.0303 15.0303C14.7374 15.3232 14.2626 15.3232 13.9697 15.0303L11.4697 12.5303C11.329 12.3897 11.25 12.1989 11.25 12V8C11.25 7.58579 11.5858 7.25 12 7.25Z"
|
||||||
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
fill="white"
|
||||||
>
|
/>
|
||||||
Created
|
</svg>
|
||||||
</th>
|
</span>
|
||||||
</tr>
|
<select
|
||||||
</thead>
|
class="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
||||||
<tbody class="text-black dark:text-white">
|
id="time_offset"
|
||||||
{{ if not .Data.Devices }}
|
name="time_offset"
|
||||||
<tr>
|
>
|
||||||
<td class="text-center p-3" colspan="3">No Results</td>
|
{{ range $item := GetUTCOffsets }}
|
||||||
</tr>
|
<option
|
||||||
{{ end }} {{ range $device := .Data.Devices }}
|
{{
|
||||||
<tr>
|
if
|
||||||
<td class="p-3 pl-0">
|
(eq
|
||||||
<p>{{ $device.DeviceName }}</p>
|
$item.Value
|
||||||
</td>
|
$.Data.Settings.TimeOffset)
|
||||||
<td class="p-3">
|
}}selected{{
|
||||||
<p>{{ $device.LastSynced }}</p>
|
end
|
||||||
</td>
|
}}
|
||||||
<td class="p-3">
|
value="{{ $item.Value }}"
|
||||||
<p>{{ $device.CreatedAt }}</p>
|
>
|
||||||
</td>
|
{{ $item.Name }}
|
||||||
</tr>
|
</option>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</tbody>
|
</select>
|
||||||
</table>
|
</div>
|
||||||
</div>
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"
|
||||||
|
>
|
||||||
|
<span class="w-full">Submit</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{ if .TimeOffsetErrorMessage }}
|
||||||
|
<span class="text-red-400 text-xs">{{ .TimeOffsetErrorMessage }}</span>
|
||||||
|
{{ else if .TimeOffsetMessage }}
|
||||||
|
<span class="text-green-400 text-xs">{{ .TimeOffsetMessage }}</span>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
|
||||||
|
>
|
||||||
|
<p class="text-lg font-semibold">Devices</p>
|
||||||
|
<table class="min-w-full bg-white dark:bg-gray-700 text-sm">
|
||||||
|
<thead class="text-gray-800 dark:text-gray-400">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
class="p-3 pl-0 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
Last Sync
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
Created
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="text-black dark:text-white">
|
||||||
|
{{ if not .Data.Devices }}
|
||||||
|
<tr>
|
||||||
|
<td class="text-center p-3" colspan="3">No Results</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }} {{ range $device := .Data.Devices }}
|
||||||
|
<tr>
|
||||||
|
<td class="p-3 pl-0">
|
||||||
|
<p>{{ $device.DeviceName }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="p-3">
|
||||||
|
<p>{{ $device.LastSynced }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="p-3">
|
||||||
|
<p>{{ $device.CreatedAt }}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
Loading…
Reference in New Issue
Block a user