[add] settings (pw & time offset), [fix] PWA issues, [fix] misc styling issues
This commit is contained in:
parent
25ab36e4b5
commit
5a8bdacf4f
@ -40,12 +40,16 @@ In additional to the compatible KOSync API's, we add:
|
||||
- Additional APIs to automatically upload reading statistics
|
||||
- Automatically upload documents to the server (can download in the "Documents" view)
|
||||
- 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 rendered server side.
|
||||
- No JavaScript! All information is generated server side.
|
||||
|
||||
# Server
|
||||
|
||||
Docker Image: `docker pull gitea.va.reichard.io/evan/bookmanager:latest`
|
||||
|
||||
## KOSync API
|
||||
|
||||
The KOSync compatible API endpoint is located at: `http(s)://<SERVER>/api/ko`
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
@ -61,7 +65,7 @@ docker run \
|
||||
gitea.va.reichard.io/evan/bookmanager:latest
|
||||
```
|
||||
|
||||
The service is now accessible at: `http://localhost:8585`
|
||||
The service is now accessible at: `http://localhost:8585`. I recommend registering an account and then disabling registration unless you expect more users.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
@ -14,6 +14,7 @@ import (
|
||||
"reichard.io/bbank/config"
|
||||
"reichard.io/bbank/database"
|
||||
"reichard.io/bbank/graph"
|
||||
"reichard.io/bbank/utils"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
@ -74,11 +75,13 @@ func (api *API) registerWebAppRoutes() {
|
||||
render := multitemplate.NewRenderer()
|
||||
helperFuncs := template.FuncMap{
|
||||
"GetSVGGraphData": graph.GetSVGGraphData,
|
||||
"GetUTCOffsets": utils.GetUTCOffsets,
|
||||
}
|
||||
|
||||
render.AddFromFilesFuncs("login", helperFuncs, "templates/login.html")
|
||||
render.AddFromFilesFuncs("home", helperFuncs, "templates/base.html", "templates/home.html")
|
||||
render.AddFromFilesFuncs("graphs", helperFuncs, "templates/base.html", "templates/graphs.html")
|
||||
render.AddFromFilesFuncs("settings", helperFuncs, "templates/base.html", "templates/settings.html")
|
||||
render.AddFromFilesFuncs("activity", helperFuncs, "templates/base.html", "templates/activity.html")
|
||||
render.AddFromFilesFuncs("documents", helperFuncs, "templates/base.html", "templates/documents.html")
|
||||
render.AddFromFilesFuncs("document", helperFuncs, "templates/base.html", "templates/document.html")
|
||||
@ -93,6 +96,8 @@ func (api *API) registerWebAppRoutes() {
|
||||
api.Router.POST("/register", api.authFormRegister)
|
||||
|
||||
api.Router.GET("/", api.authWebAppMiddleware, api.createAppResourcesRoute("home"))
|
||||
api.Router.GET("/settings", api.authWebAppMiddleware, api.createAppResourcesRoute("settings"))
|
||||
api.Router.POST("/settings", api.authWebAppMiddleware, api.editSettings)
|
||||
api.Router.GET("/activity", api.authWebAppMiddleware, api.createAppResourcesRoute("activity"))
|
||||
api.Router.GET("/documents", api.authWebAppMiddleware, api.createAppResourcesRoute("documents"))
|
||||
api.Router.GET("/documents/:document", api.authWebAppMiddleware, api.createAppResourcesRoute("document"))
|
||||
|
@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
@ -9,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
argon2 "github.com/alexedwards/argon2id"
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
@ -40,6 +42,12 @@ type requestDocumentIdentify struct {
|
||||
ISBN *string `form:"isbn"`
|
||||
}
|
||||
|
||||
type requestSettingsEdit struct {
|
||||
Password *string `form:"password"`
|
||||
NewPassword *string `form:"new_password"`
|
||||
TimeOffset *string `form:"time_offset"`
|
||||
}
|
||||
|
||||
func baseResourceRoute(template string, args ...map[string]any) func(c *gin.Context) {
|
||||
variables := gin.H{"RouteName": template}
|
||||
if len(args) > 0 {
|
||||
@ -167,6 +175,27 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
|
||||
"DatabaseInfo": database_info,
|
||||
"GraphData": read_graph_data,
|
||||
}
|
||||
} else if routeName == "settings" {
|
||||
user, err := api.DB.Queries.GetUser(api.DB.Ctx, rUser.(string))
|
||||
if err != nil {
|
||||
log.Error("[createAppResourcesRoute] GetUser DB Error:", err)
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
||||
return
|
||||
}
|
||||
|
||||
devices, err := api.DB.Queries.GetDevices(api.DB.Ctx, rUser.(string))
|
||||
if err != nil {
|
||||
log.Error("[createAppResourcesRoute] GetDevices DB Error:", err)
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
||||
return
|
||||
}
|
||||
|
||||
templateVars["Data"] = gin.H{
|
||||
"Settings": gin.H{
|
||||
"TimeOffset": *user.TimeOffset,
|
||||
},
|
||||
"Devices": devices,
|
||||
}
|
||||
} else if routeName == "login" {
|
||||
templateVars["RegistrationEnabled"] = api.Config.RegistrationEnabled
|
||||
}
|
||||
@ -471,6 +500,88 @@ func (api *API) identifyDocument(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "document", templateVars)
|
||||
}
|
||||
|
||||
func (api *API) editSettings(c *gin.Context) {
|
||||
rUser, _ := c.Get("AuthorizedUser")
|
||||
|
||||
var rUserSettings requestSettingsEdit
|
||||
if err := c.ShouldBind(&rUserSettings); err != nil {
|
||||
log.Error("[editSettings] Invalid Form Bind")
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate Something Exists
|
||||
if rUserSettings.Password == nil && rUserSettings.NewPassword == nil && rUserSettings.TimeOffset == nil {
|
||||
log.Error("[editSettings] Missing Form Values")
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
||||
return
|
||||
}
|
||||
|
||||
templateVars := gin.H{
|
||||
"User": rUser,
|
||||
}
|
||||
newUserSettings := database.UpdateUserParams{
|
||||
UserID: rUser.(string),
|
||||
}
|
||||
|
||||
// Set New Password
|
||||
if rUserSettings.Password != nil && rUserSettings.NewPassword != nil {
|
||||
password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.Password)))
|
||||
authorized := api.authorizeCredentials(rUser.(string), password)
|
||||
if authorized == true {
|
||||
password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.NewPassword)))
|
||||
hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams)
|
||||
if err != nil {
|
||||
templateVars["PasswordErrorMessage"] = "Unknown Error"
|
||||
} else {
|
||||
templateVars["PasswordMessage"] = "Password Updated"
|
||||
newUserSettings.Password = &hashedPassword
|
||||
}
|
||||
} else {
|
||||
templateVars["PasswordErrorMessage"] = "Invalid Password"
|
||||
}
|
||||
}
|
||||
|
||||
// Set Time Offset
|
||||
if rUserSettings.TimeOffset != nil {
|
||||
templateVars["TimeOffsetMessage"] = "Time Offset Updated"
|
||||
newUserSettings.TimeOffset = rUserSettings.TimeOffset
|
||||
}
|
||||
|
||||
// Update User
|
||||
_, err := api.DB.Queries.UpdateUser(api.DB.Ctx, newUserSettings)
|
||||
if err != nil {
|
||||
log.Error("[editSettings] UpdateUser DB Error:", err)
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get User
|
||||
user, err := api.DB.Queries.GetUser(api.DB.Ctx, rUser.(string))
|
||||
if err != nil {
|
||||
log.Error("[editSettings] GetUser DB Error:", err)
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get Devices
|
||||
devices, err := api.DB.Queries.GetDevices(api.DB.Ctx, rUser.(string))
|
||||
if err != nil {
|
||||
log.Error("[editSettings] GetDevices DB Error:", err)
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
|
||||
return
|
||||
}
|
||||
|
||||
templateVars["Data"] = gin.H{
|
||||
"Settings": gin.H{
|
||||
"TimeOffset": *user.TimeOffset,
|
||||
},
|
||||
"Devices": devices,
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "settings", templateVars)
|
||||
}
|
||||
|
||||
func bindQueryParams(c *gin.Context) queryParams {
|
||||
var qParams queryParams
|
||||
c.BindQuery(&qParams)
|
||||
|
@ -24,7 +24,7 @@ func (api *API) authorizeCredentials(username string, password string) (authoriz
|
||||
return false
|
||||
}
|
||||
|
||||
if match, err := argon2.ComparePasswordAndHash(password, user.Pass); err != nil || match != true {
|
||||
if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || match != true {
|
||||
return false
|
||||
}
|
||||
|
||||
@ -94,7 +94,6 @@ func (api *API) authFormLogin(c *gin.Context) {
|
||||
|
||||
// MD5 - KOSync Compatiblity
|
||||
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
|
||||
|
||||
if authorized := api.authorizeCredentials(username, password); authorized != true {
|
||||
c.HTML(http.StatusUnauthorized, "login", gin.H{
|
||||
"RegistrationEnabled": api.Config.RegistrationEnabled,
|
||||
@ -140,7 +139,7 @@ func (api *API) authFormRegister(c *gin.Context) {
|
||||
|
||||
rows, err := api.DB.Queries.CreateUser(api.DB.Ctx, database.CreateUserParams{
|
||||
ID: username,
|
||||
Pass: hashedPassword,
|
||||
Pass: &hashedPassword,
|
||||
})
|
||||
|
||||
// SQL Error
|
||||
|
@ -107,7 +107,7 @@ func (api *API) createUser(c *gin.Context) {
|
||||
|
||||
rows, err := api.DB.Queries.CreateUser(api.DB.Ctx, database.CreateUserParams{
|
||||
ID: rUser.Username,
|
||||
Pass: hashedPassword,
|
||||
Pass: &hashedPassword,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("[createUser] CreateUser DB Error:", err)
|
||||
|
@ -2,6 +2,5 @@
|
||||
"short_name": "Book Manager",
|
||||
"name": "Book Manager",
|
||||
"theme_color": "#1F2937",
|
||||
"background_color": "#1F2937",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
@ -1,15 +1,21 @@
|
||||
# Book Manager - SyncNinja KOReader Plugin
|
||||
|
||||
This is BookManagers KOReader Plugin called `syncninja.koplugin`.
|
||||
This is BookManagers KOReader Plugin called `syncninja.koplugin`. Features include:
|
||||
|
||||
# Installation
|
||||
- Syncing read activity
|
||||
- Uploading documents
|
||||
- Configurable sync settings
|
||||
|
||||
## Installation
|
||||
|
||||
Copy the `syncninja.koplugin` directory to the `plugins` directory for your KOReader installation. Restart KOReader and SyncNinja will be accessible via the Tools menu.
|
||||
|
||||
# Configuration
|
||||
## Configuration
|
||||
|
||||
You must configure the BookManager server and credentials in SyncNinja. Afterwhich you'll have the ability to configure the sync cadence as well as whether you'd like the plugin to sync your activity, document metadata, and/or documents themselves.
|
||||
|
||||
# KOSync Compatibility
|
||||
## KOSync Compatibility
|
||||
|
||||
BookManager implements API's compatible with the KOSync plugin. This means that you can utilize this server for KOSync (and it's recommended!). SyncNinja provides an easy way to merge configurations between both KOSync and itself in the menu.
|
||||
|
||||
The KOSync compatible API endpoint is located at: `http(s)://<SERVER>/api/ko`. You can either use the previous mentioned merge feature to automatically configure KOSync once SyncNinja is configured, or you can manually set KOSync's server to the above.
|
||||
|
@ -26,7 +26,7 @@ type Config struct {
|
||||
|
||||
func Load() *Config {
|
||||
return &Config{
|
||||
Version: "0.0.1",
|
||||
Version: "0.0.2",
|
||||
DBType: trimLowerString(getEnv("DATABASE_TYPE", "SQLite")),
|
||||
DBName: trimLowerString(getEnv("DATABASE_NAME", "book_manager")),
|
||||
DBPassword: getEnv("DATABASE_PASSWORD", ""),
|
||||
|
@ -21,11 +21,11 @@ type Activity struct {
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Sync bool `json:"sync"`
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Sync bool `json:"sync"`
|
||||
}
|
||||
|
||||
type Document struct {
|
||||
@ -90,8 +90,8 @@ type RescaledActivity struct {
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Pass string `json:"-"`
|
||||
Pass *string `json:"-"`
|
||||
Admin bool `json:"-"`
|
||||
TimeOffset string `json:"time_offset"`
|
||||
TimeOffset *string `json:"time_offset"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
@ -21,6 +21,14 @@ ON CONFLICT DO NOTHING;
|
||||
SELECT * FROM users
|
||||
WHERE id = $user_id LIMIT 1;
|
||||
|
||||
-- name: UpdateUser :one
|
||||
UPDATE users
|
||||
SET
|
||||
pass = COALESCE($password, pass),
|
||||
time_offset = COALESCE($time_offset, time_offset)
|
||||
WHERE id = $user_id
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpsertDocument :one
|
||||
INSERT INTO documents (
|
||||
id,
|
||||
@ -287,11 +295,15 @@ LIMIT $limit
|
||||
OFFSET $offset;
|
||||
|
||||
-- name: GetDevices :many
|
||||
SELECT * FROM devices
|
||||
WHERE user_id = $user_id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $limit
|
||||
OFFSET $offset;
|
||||
SELECT
|
||||
devices.device_name,
|
||||
CAST(DATETIME(devices.created_at, users.time_offset) AS TEXT) AS created_at,
|
||||
CAST(DATETIME(MAX(activity.created_at), users.time_offset) AS TEXT) AS last_sync
|
||||
FROM activity
|
||||
JOIN devices ON devices.id = activity.device_id
|
||||
JOIN users ON users.id = $user_id
|
||||
WHERE devices.user_id = $user_id
|
||||
GROUP BY activity.device_id;
|
||||
|
||||
-- name: GetDocumentReadStats :one
|
||||
SELECT
|
||||
|
@ -121,8 +121,8 @@ ON CONFLICT DO NOTHING
|
||||
`
|
||||
|
||||
type CreateUserParams struct {
|
||||
ID string `json:"id"`
|
||||
Pass string `json:"-"`
|
||||
ID string `json:"id"`
|
||||
Pass *string `json:"-"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (int64, error) {
|
||||
@ -376,35 +376,33 @@ func (q *Queries) GetDevice(ctx context.Context, deviceID string) (Device, error
|
||||
}
|
||||
|
||||
const getDevices = `-- name: GetDevices :many
|
||||
SELECT id, user_id, device_name, created_at, sync FROM devices
|
||||
WHERE user_id = ?1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?3
|
||||
OFFSET ?2
|
||||
SELECT
|
||||
devices.device_name,
|
||||
CAST(DATETIME(devices.created_at, users.time_offset) AS TEXT) AS created_at,
|
||||
CAST(DATETIME(MAX(activity.created_at), users.time_offset) AS TEXT) AS last_sync
|
||||
FROM activity
|
||||
JOIN devices ON devices.id = activity.device_id
|
||||
JOIN users ON users.id = ?1
|
||||
WHERE devices.user_id = ?1
|
||||
GROUP BY activity.device_id
|
||||
`
|
||||
|
||||
type GetDevicesParams struct {
|
||||
UserID string `json:"user_id"`
|
||||
Offset int64 `json:"offset"`
|
||||
Limit int64 `json:"limit"`
|
||||
type GetDevicesRow struct {
|
||||
DeviceName string `json:"device_name"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastSync string `json:"last_sync"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetDevices(ctx context.Context, arg GetDevicesParams) ([]Device, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getDevices, arg.UserID, arg.Offset, arg.Limit)
|
||||
func (q *Queries) GetDevices(ctx context.Context, userID string) ([]GetDevicesRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getDevices, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Device
|
||||
var items []GetDevicesRow
|
||||
for rows.Next() {
|
||||
var i Device
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.DeviceName,
|
||||
&i.CreatedAt,
|
||||
&i.Sync,
|
||||
); err != nil {
|
||||
var i GetDevicesRow
|
||||
if err := rows.Scan(&i.DeviceName, &i.CreatedAt, &i.LastSync); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
@ -1272,6 +1270,34 @@ func (q *Queries) UpdateProgress(ctx context.Context, arg UpdateProgressParams)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateUser = `-- name: UpdateUser :one
|
||||
UPDATE users
|
||||
SET
|
||||
pass = COALESCE(?1, pass),
|
||||
time_offset = COALESCE(?2, time_offset)
|
||||
WHERE id = ?3
|
||||
RETURNING id, pass, admin, time_offset, created_at
|
||||
`
|
||||
|
||||
type UpdateUserParams struct {
|
||||
Password *string `json:"-"`
|
||||
TimeOffset *string `json:"time_offset"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (User, error) {
|
||||
row := q.db.QueryRowContext(ctx, updateUser, arg.Password, arg.TimeOffset, arg.UserID)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Pass,
|
||||
&i.Admin,
|
||||
&i.TimeOffset,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const upsertDevice = `-- name: UpsertDevice :one
|
||||
INSERT INTO devices (id, user_id, device_name)
|
||||
VALUES (?, ?, ?)
|
||||
|
30
sqlc.yaml
30
sqlc.yaml
@ -9,7 +9,7 @@ sql:
|
||||
out: "database"
|
||||
emit_json_tags: true
|
||||
overrides:
|
||||
# Type pointers needed for JSON
|
||||
# Documents
|
||||
- column: "documents.md5"
|
||||
go_type:
|
||||
type: "string"
|
||||
@ -63,6 +63,7 @@ sql:
|
||||
type: "string"
|
||||
pointer: true
|
||||
|
||||
# Metadata
|
||||
- column: "metadata.title"
|
||||
go_type:
|
||||
type: "string"
|
||||
@ -92,6 +93,33 @@ sql:
|
||||
type: "string"
|
||||
pointer: true
|
||||
|
||||
# Devices
|
||||
- column: "devices.id"
|
||||
go_type:
|
||||
type: "string"
|
||||
- column: "devices.user_id"
|
||||
go_type:
|
||||
type: "string"
|
||||
- column: "devices.device_name"
|
||||
go_type:
|
||||
type: "string"
|
||||
- column: "devices.sync"
|
||||
go_type:
|
||||
type: "bool"
|
||||
- column: "devices.created_at"
|
||||
go_type:
|
||||
type: "string"
|
||||
|
||||
# Devices
|
||||
- column: "users.pass"
|
||||
go_type:
|
||||
type: "string"
|
||||
pointer: true
|
||||
- column: "users.time_offset"
|
||||
go_type:
|
||||
type: "string"
|
||||
pointer: true
|
||||
|
||||
# Do not generate JSON
|
||||
- column: "documents.synced"
|
||||
go_struct_tag: 'json:"-"'
|
||||
|
@ -2,22 +2,24 @@
|
||||
<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 name="viewport"
|
||||
content="width=device-width, initial-scale=0.90, user-scalable=no">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<title>Book Manager - {{block "title" .}}{{end}}</title>
|
||||
</head>
|
||||
<body>
|
||||
<body class="bg-gray-100 dark:bg-gray-800">
|
||||
<main
|
||||
class="relative h-screen overflow-hidden bg-gray-100 dark:bg-gray-800"
|
||||
class="relative h-screen overflow-hidden"
|
||||
>
|
||||
<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">
|
||||
<input type="checkbox" class="absolute lg:hidden z-50 -top-2 w-7 h-7 flex cursor-pointer opacity-0 w-12 h-12" />
|
||||
<span class="lg:hidden w-7 h-0.5 z-40 mt-0.5 bg-white"></span>
|
||||
<span class="lg:hidden w-7 h-0.5 z-40 mt-1 bg-white"></span>
|
||||
<span class="lg:hidden w-7 h-0.5 z-40 mt-1 bg-white"></span>
|
||||
<span class="lg:hidden bg-black w-7 h-0.5 z-40 mt-0.5 dark:bg-white"></span>
|
||||
<span class="lg:hidden bg-black w-7 h-0.5 z-40 mt-1 dark:bg-white"></span>
|
||||
<span class="lg:hidden bg-black w-7 h-0.5 z-40 mt-1 dark:bg-white"></span>
|
||||
|
||||
<div id="menu" class="fixed -mt-6 -ml-6 lg:-mt-8 h-full w-56 lg:w-48 bg-white dark:bg-gray-700 shadow-lg">
|
||||
<div class="h-16 flex justify-end lg:justify-around">
|
||||
@ -102,7 +104,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-xl font-bold dark:text-white px-6">{{block "header" .}}{{end}}</h1>
|
||||
<h1 class="text-xl font-bold dark:text-white px-6 lg:ml-44">{{block "header" .}}{{end}}</h1>
|
||||
<div
|
||||
class="relative flex items-center justify-end w-full p-4 space-x-4"
|
||||
>
|
||||
@ -134,6 +136,15 @@
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="options-menu"
|
||||
>
|
||||
<a
|
||||
href="/settings"
|
||||
class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600"
|
||||
role="menuitem"
|
||||
>
|
||||
<span class="flex flex-col">
|
||||
<span>Settings</span>
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href="/logout"
|
||||
class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600"
|
||||
@ -175,7 +186,6 @@
|
||||
|
||||
<!-- Custom Animation CSS -->
|
||||
<style>
|
||||
|
||||
/* ----------------------------- */
|
||||
/* ------- User Dropdown ------- */
|
||||
/* ----------------------------- */
|
||||
@ -210,7 +220,6 @@
|
||||
#mobile-nav-button input:checked ~ span {
|
||||
opacity: 1;
|
||||
transform: rotate(45deg) translate(2px, -2px);
|
||||
background: #FFFFFF;
|
||||
}
|
||||
|
||||
#mobile-nav-button input:checked ~ span:nth-last-child(3) {
|
||||
|
@ -2,9 +2,20 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="manifest" href="./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 name="viewport" content="width=device-width" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<title>Book Manager - {{if .Register}}Register{{else}}Login{{end}}</title>
|
||||
</head>
|
||||
<body class="bg-gray-100 dark:bg-gray-800 dark:text-white">
|
||||
<div class="flex flex-wrap w-full">
|
||||
|
217
templates/settings.html
Normal file
217
templates/settings.html
Normal file
@ -0,0 +1,217 @@
|
||||
{{template "base.html" .}} {{define "title"}}Settings{{end}} {{define "header"}}
|
||||
<a href="./settings">Settings</a>
|
||||
{{end}} {{define "content"}}
|
||||
<div class="h-full w-full relative">
|
||||
<div class="w-full flex flex-col md:flex-row gap-4">
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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 class="flex flex-col gap-4 grow">
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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
|
||||
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="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
<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>
|
||||
</span>
|
||||
<select
|
||||
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"
|
||||
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>
|
||||
<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">
|
||||
{{ range $device := .Data.Devices }}
|
||||
<tr>
|
||||
<td class="p-3 pl-0">
|
||||
<p>{{ $device.DeviceName }}</p>
|
||||
</td>
|
||||
<td class="p-3">
|
||||
<p>{{ $device.LastSync }}</p>
|
||||
</td>
|
||||
<td class="p-3">
|
||||
<p>{{ $device.CreatedAt }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
@ -1,49 +1,51 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
// "encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
func CalculatePartialMD5(filePath string) string {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
defer file.Close()
|
||||
|
||||
var step int64 = 1024
|
||||
var size int64 = 1024
|
||||
var buf bytes.Buffer
|
||||
|
||||
for i := -1; i <= 10; i++ {
|
||||
byteStep := make([]byte, size)
|
||||
|
||||
var newShift int64 = int64(i * 2)
|
||||
var newOffset int64
|
||||
if i == -1 {
|
||||
newOffset = 0
|
||||
} else {
|
||||
newOffset = step << newShift
|
||||
}
|
||||
|
||||
_, err := file.ReadAt(byteStep, newOffset)
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
buf.Write(byteStep)
|
||||
}
|
||||
|
||||
allBytes := buf.Bytes()
|
||||
return fmt.Sprintf("%x", md5.Sum(allBytes))
|
||||
type UTCOffset struct {
|
||||
Name string
|
||||
Value string
|
||||
}
|
||||
|
||||
func main() {
|
||||
fileHash := CalculatePartialMD5("test.epub")
|
||||
fmt.Println("MD5: ", fileHash)
|
||||
var UTC_OFFSETS = []UTCOffset{
|
||||
{Value: "-12 hours", Name: "UTC−12:00"},
|
||||
{Value: "-11 hours", Name: "UTC−11:00"},
|
||||
{Value: "-10 hours", Name: "UTC−10:00"},
|
||||
{Value: "-9.5 hours", Name: "UTC−09:30"},
|
||||
{Value: "-9 hours", Name: "UTC−09:00"},
|
||||
{Value: "-8 hours", Name: "UTC−08:00"},
|
||||
{Value: "-7 hours", Name: "UTC−07:00"},
|
||||
{Value: "-6 hours", Name: "UTC−06:00"},
|
||||
{Value: "-5 hours", Name: "UTC−05:00"},
|
||||
{Value: "-4 hours", Name: "UTC−04:00"},
|
||||
{Value: "-3.5 hours", Name: "UTC−03:30"},
|
||||
{Value: "-3 hours", Name: "UTC−03:00"},
|
||||
{Value: "-2 hours", Name: "UTC−02:00"},
|
||||
{Value: "-1 hours", Name: "UTC−01:00"},
|
||||
{Value: "0 hours", Name: "UTC±00:00"},
|
||||
{Value: "+1 hours", Name: "UTC+01:00"},
|
||||
{Value: "+2 hours", Name: "UTC+02:00"},
|
||||
{Value: "+3 hours", Name: "UTC+03:00"},
|
||||
{Value: "+3.5 hours", Name: "UTC+03:30"},
|
||||
{Value: "+4 hours", Name: "UTC+04:00"},
|
||||
{Value: "+4.5 hours", Name: "UTC+04:30"},
|
||||
{Value: "+5 hours", Name: "UTC+05:00"},
|
||||
{Value: "+5.5 hours", Name: "UTC+05:30"},
|
||||
{Value: "+5.75 hours", Name: "UTC+05:45"},
|
||||
{Value: "+6 hours", Name: "UTC+06:00"},
|
||||
{Value: "+6.5 hours", Name: "UTC+06:30"},
|
||||
{Value: "+7 hours", Name: "UTC+07:00"},
|
||||
{Value: "+8 hours", Name: "UTC+08:00"},
|
||||
{Value: "+8.75 hours", Name: "UTC+08:45"},
|
||||
{Value: "+9 hours", Name: "UTC+09:00"},
|
||||
{Value: "+9.5 hours", Name: "UTC+09:30"},
|
||||
{Value: "+10 hours", Name: "UTC+10:00"},
|
||||
{Value: "+10.5 hours", Name: "UTC+10:30"},
|
||||
{Value: "+11 hours", Name: "UTC+11:00"},
|
||||
{Value: "+12 hours", Name: "UTC+12:00"},
|
||||
{Value: "+12.75 hours", Name: "UTC+12:45"},
|
||||
{Value: "+13 hours", Name: "UTC+13:00"},
|
||||
{Value: "+14 hours", Name: "UTC+14:00"},
|
||||
}
|
||||
|
||||
func GetUTCOffsets() []UTCOffset {
|
||||
return UTC_OFFSETS
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user