feat: create admin dashboard with user preferences and library settings
- Add admin dashboard at /admin route consolidating all user settings - Move user preferences (username, email, password, theme) from separate page - Add ebook library preferences section with folder management - Add scan settings (frequency and auto-scan toggle) with database persistence - Create database migration for scan_frequency_minutes and auto_scan_enabled columns - Add API endpoints for scan settings management: - PUT /api/library/scan-settings - Update scan preferences - GET /api/library/scan-settings - Get current scan settings - Update dashboard navigation to link to admin dashboard - Remove old preferences.html template (functionality moved to admin) - Create Bruno API testing files for library endpoints - Add real-time folder loading and management in admin interface - Implement scan settings persistence and retrieval from database
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
-- Add scan settings to users table
|
||||
ALTER TABLE users ADD COLUMN scan_frequency_minutes INTEGER DEFAULT 60;
|
||||
ALTER TABLE users ADD COLUMN auto_scan_enabled BOOLEAN DEFAULT true;
|
||||
@@ -0,0 +1,36 @@
|
||||
meta {
|
||||
name: Get Scan Settings
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/api/library/scan-settings
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
|
||||
docs {
|
||||
## Get Scan Settings
|
||||
|
||||
Retrieves the user's current ebook scanning settings.
|
||||
|
||||
**Method:** GET
|
||||
|
||||
**Endpoint:** /api/library/scan-settings
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Response:**
|
||||
- `scan_frequency_minutes` (integer): Minutes between automatic scans
|
||||
- `auto_scan_enabled` (boolean): Whether automatic scanning is enabled
|
||||
|
||||
**Status Codes:**
|
||||
- 200: Success
|
||||
- 401: Unauthorized
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
meta {
|
||||
name: Update Scan Settings
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
put {
|
||||
url: {{base_url}}/api/library/scan-settings
|
||||
body: json
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
body {
|
||||
{
|
||||
"scan_frequency_minutes": 60,
|
||||
"auto_scan_enabled": true
|
||||
}
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
|
||||
docs {
|
||||
## Update Scan Settings
|
||||
|
||||
Updates the user's ebook scanning settings.
|
||||
|
||||
**Method:** PUT
|
||||
|
||||
**Endpoint:** /api/library/scan-settings
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Request Body:**
|
||||
- `scan_frequency_minutes` (integer, required): Minutes between automatic scans (15-1440)
|
||||
- `auto_scan_enabled` (boolean, required): Whether automatic scanning is enabled
|
||||
|
||||
**Response:**
|
||||
- `message` (string): Success message
|
||||
|
||||
**Status Codes:**
|
||||
- 200: Success
|
||||
- 400: Invalid settings
|
||||
- 401: Unauthorized
|
||||
}
|
||||
@@ -62,4 +62,6 @@ type Users struct {
|
||||
Theme pgtype.Text `db:"theme" json:"theme"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
ScanFrequencyMinutes pgtype.Int4 `db:"scan_frequency_minutes" json:"scan_frequency_minutes"`
|
||||
AutoScanEnabled pgtype.Bool `db:"auto_scan_enabled" json:"auto_scan_enabled"`
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ type Querier interface {
|
||||
GetEbookRating(ctx context.Context, arg GetEbookRatingParams) (EbookRatings, error)
|
||||
GetEbookRatings(ctx context.Context, ebookID pgtype.UUID) ([]GetEbookRatingsRow, error)
|
||||
GetReadingProgress(ctx context.Context, arg GetReadingProgressParams) (ReadingProgress, error)
|
||||
GetScanSettings(ctx context.Context, id pgtype.UUID) (GetScanSettingsRow, error)
|
||||
GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error)
|
||||
GetUserByEmail(ctx context.Context, email string) (Users, error)
|
||||
GetUserByEmailOrUsername(ctx context.Context, email string) (Users, error)
|
||||
@@ -38,6 +39,7 @@ type Querier interface {
|
||||
UpdateEmail(ctx context.Context, arg UpdateEmailParams) error
|
||||
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error
|
||||
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
|
||||
UpdateScanSettings(ctx context.Context, arg UpdateScanSettingsParams) error
|
||||
UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error
|
||||
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ func (q *Queries) CreateEbookRating(ctx context.Context, arg CreateEbookRatingPa
|
||||
const CreateUser = `-- name: CreateUser :one
|
||||
INSERT INTO users (email, username, password_hash, theme)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, email, username, password_hash, theme, created_at, updated_at
|
||||
RETURNING id, email, username, password_hash, theme, created_at, updated_at, scan_frequency_minutes, auto_scan_enabled
|
||||
`
|
||||
|
||||
type CreateUserParams struct {
|
||||
@@ -157,6 +157,8 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (Users,
|
||||
&i.Theme,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ScanFrequencyMinutes,
|
||||
&i.AutoScanEnabled,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -373,6 +375,22 @@ func (q *Queries) GetReadingProgress(ctx context.Context, arg GetReadingProgress
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetScanSettings = `-- name: GetScanSettings :one
|
||||
SELECT scan_frequency_minutes, auto_scan_enabled FROM users WHERE id = $1
|
||||
`
|
||||
|
||||
type GetScanSettingsRow struct {
|
||||
ScanFrequencyMinutes pgtype.Int4 `db:"scan_frequency_minutes" json:"scan_frequency_minutes"`
|
||||
AutoScanEnabled pgtype.Bool `db:"auto_scan_enabled" json:"auto_scan_enabled"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetScanSettings(ctx context.Context, id pgtype.UUID) (GetScanSettingsRow, error) {
|
||||
row := q.db.QueryRow(ctx, GetScanSettings, id)
|
||||
var i GetScanSettingsRow
|
||||
err := row.Scan(&i.ScanFrequencyMinutes, &i.AutoScanEnabled)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetUser = `-- name: GetUser :one
|
||||
SELECT id, email, username, theme, created_at, updated_at FROM users WHERE id = $1
|
||||
`
|
||||
@@ -401,7 +419,7 @@ func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, erro
|
||||
}
|
||||
|
||||
const GetUserByEmail = `-- name: GetUserByEmail :one
|
||||
SELECT id, email, username, password_hash, theme, created_at, updated_at FROM users WHERE email = $1
|
||||
SELECT id, email, username, password_hash, theme, created_at, updated_at, scan_frequency_minutes, auto_scan_enabled FROM users WHERE email = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (Users, error) {
|
||||
@@ -415,12 +433,14 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (Users, erro
|
||||
&i.Theme,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ScanFrequencyMinutes,
|
||||
&i.AutoScanEnabled,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetUserByEmailOrUsername = `-- name: GetUserByEmailOrUsername :one
|
||||
SELECT id, email, username, password_hash, theme, created_at, updated_at FROM users WHERE email = $1 OR username = $1
|
||||
SELECT id, email, username, password_hash, theme, created_at, updated_at, scan_frequency_minutes, auto_scan_enabled FROM users WHERE email = $1 OR username = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByEmailOrUsername(ctx context.Context, email string) (Users, error) {
|
||||
@@ -434,12 +454,14 @@ func (q *Queries) GetUserByEmailOrUsername(ctx context.Context, email string) (U
|
||||
&i.Theme,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ScanFrequencyMinutes,
|
||||
&i.AutoScanEnabled,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetUserByUsername = `-- name: GetUserByUsername :one
|
||||
SELECT id, email, username, password_hash, theme, created_at, updated_at FROM users WHERE username = $1
|
||||
SELECT id, email, username, password_hash, theme, created_at, updated_at, scan_frequency_minutes, auto_scan_enabled FROM users WHERE username = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (Users, error) {
|
||||
@@ -453,6 +475,8 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (Users
|
||||
&i.Theme,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ScanFrequencyMinutes,
|
||||
&i.AutoScanEnabled,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -753,6 +777,21 @@ func (q *Queries) UpdateReadingProgress(ctx context.Context, arg UpdateReadingPr
|
||||
return i, err
|
||||
}
|
||||
|
||||
const UpdateScanSettings = `-- name: UpdateScanSettings :exec
|
||||
UPDATE users SET scan_frequency_minutes = $2, auto_scan_enabled = $3, updated_at = NOW() WHERE id = $1
|
||||
`
|
||||
|
||||
type UpdateScanSettingsParams struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
ScanFrequencyMinutes pgtype.Int4 `db:"scan_frequency_minutes" json:"scan_frequency_minutes"`
|
||||
AutoScanEnabled pgtype.Bool `db:"auto_scan_enabled" json:"auto_scan_enabled"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateScanSettings(ctx context.Context, arg UpdateScanSettingsParams) error {
|
||||
_, err := q.db.Exec(ctx, UpdateScanSettings, arg.ID, arg.ScanFrequencyMinutes, arg.AutoScanEnabled)
|
||||
return err
|
||||
}
|
||||
|
||||
const UpdateUserTheme = `-- name: UpdateUserTheme :exec
|
||||
UPDATE users SET theme = $2, updated_at = NOW() WHERE id = $1
|
||||
`
|
||||
|
||||
@@ -84,6 +84,12 @@ UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1;
|
||||
-- name: DeleteUser :exec
|
||||
DELETE FROM users WHERE id = $1;
|
||||
|
||||
-- name: UpdateScanSettings :exec
|
||||
UPDATE users SET scan_frequency_minutes = $2, auto_scan_enabled = $3, updated_at = NOW() WHERE id = $1;
|
||||
|
||||
-- name: GetScanSettings :one
|
||||
SELECT scan_frequency_minutes, auto_scan_enabled FROM users WHERE id = $1;
|
||||
|
||||
-- name: CreateEbookRating :one
|
||||
INSERT INTO ebook_ratings (ebook_id, user_id, rating)
|
||||
VALUES ($1, $2, $3)
|
||||
|
||||
@@ -588,6 +588,62 @@ func (h *AuthHandler) DeleteAccount(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "account deleted successfully"})
|
||||
}
|
||||
|
||||
type UpdateScanSettingsRequest struct {
|
||||
ScanFrequencyMinutes int32 `json:"scan_frequency_minutes" validate:"required,min=15,max=1440"`
|
||||
AutoScanEnabled bool `json:"auto_scan_enabled"`
|
||||
}
|
||||
|
||||
// UpdateScanSettings handles PUT /api/library/scan-settings
|
||||
func (h *AuthHandler) UpdateScanSettings(c echo.Context) error {
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||||
}
|
||||
|
||||
var req UpdateScanSettingsRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
err = h.db.UpdateScanSettings(c.Request().Context(), database.UpdateScanSettingsParams{
|
||||
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
ScanFrequencyMinutes: pgtype.Int4{Int32: req.ScanFrequencyMinutes, Valid: true},
|
||||
AutoScanEnabled: pgtype.Bool{Bool: req.AutoScanEnabled, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "scan settings updated successfully"})
|
||||
}
|
||||
|
||||
// GetScanSettings handles GET /api/library/scan-settings
|
||||
func (h *AuthHandler) GetScanSettings(c echo.Context) error {
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||||
}
|
||||
|
||||
settings, err := h.db.GetScanSettings(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
|
||||
if err != nil {
|
||||
// If no settings found, return defaults
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"scan_frequency_minutes": 60,
|
||||
"auto_scan_enabled": true,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"scan_frequency_minutes": settings.ScanFrequencyMinutes.Int32,
|
||||
"auto_scan_enabled": settings.AutoScanEnabled.Bool,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) generateJWT(userID string) (string, error) {
|
||||
claims := jwtgo.MapClaims{
|
||||
"user_id": userID,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Add scan settings to users table
|
||||
ALTER TABLE users ADD COLUMN scan_frequency_minutes INTEGER DEFAULT 60;
|
||||
ALTER TABLE users ADD COLUMN auto_scan_enabled BOOLEAN DEFAULT true;
|
||||
@@ -0,0 +1,345 @@
|
||||
{{template "base.html" .}}
|
||||
|
||||
{{define "title"}}Admin Dashboard - Bookmann{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<!-- Navigation Header -->
|
||||
<nav class="border-b" style="border-color: var(--border); background-color: var(--bg-secondary)">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<div class="flex items-center">
|
||||
<h1 class="text-xl font-bold" style="color: var(--text-primary)">📚 Bookmann</h1>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="/" class="px-3 py-2 text-sm hover:opacity-80" style="color: var(--text-secondary)">Dashboard</a>
|
||||
<span style="color: var(--text-secondary)">Welcome, {{.User.Username}}!</span>
|
||||
<button onclick="logout()" class="btn-primary px-4 py-2 rounded text-sm">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Header Section -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="/" class="btn-secondary px-4 py-2 rounded-lg font-medium">
|
||||
← Back to Library
|
||||
</a>
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold" style="color: var(--text-primary)">Admin Dashboard</h2>
|
||||
<p style="color: var(--text-secondary)">Manage your account settings and library preferences</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin Sections -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<!-- User Preferences -->
|
||||
<div class="space-y-6">
|
||||
<h3 class="text-2xl font-semibold" style="color: var(--text-primary)">User Preferences</h3>
|
||||
|
||||
<!-- Update Username -->
|
||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<h4 class="text-lg font-medium mb-4" style="color: var(--text-primary)">Username</h4>
|
||||
<form hx-put="/api/user/username" hx-target="#username-result" hx-swap="innerHTML" hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}' class="flex space-x-3">
|
||||
<input type="text" name="username" value="{{.User.Username}}" class="flex-1 px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
|
||||
<button type="submit" class="btn-primary px-4 py-2 rounded">Update</button>
|
||||
</form>
|
||||
<div id="username-result" class="mt-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- Update Email -->
|
||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<h4 class="text-lg font-medium mb-4" style="color: var(--text-primary)">Email Address</h4>
|
||||
<form hx-put="/api/user/email" hx-target="#email-result" hx-swap="innerHTML" hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}' class="flex space-x-3">
|
||||
<input type="email" name="email" value="{{.User.Email}}" class="flex-1 px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
|
||||
<button type="submit" class="btn-primary px-4 py-2 rounded">Update</button>
|
||||
</form>
|
||||
<div id="email-result" class="mt-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- Update Password -->
|
||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<h4 class="text-lg font-medium mb-4" style="color: var(--text-primary)">Change Password</h4>
|
||||
<form hx-put="/api/user/password" hx-target="#password-result" hx-swap="innerHTML" hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}' class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Current Password</label>
|
||||
<input type="password" name="current_password" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">New Password</label>
|
||||
<input type="password" name="new_password" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required minlength="6">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Confirm New Password</label>
|
||||
<input type="password" name="confirm_password" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required minlength="6">
|
||||
</div>
|
||||
<button type="submit" class="btn-primary px-4 py-2 rounded">Update Password</button>
|
||||
</form>
|
||||
<div id="password-result" class="mt-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- Theme Settings -->
|
||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<h4 class="text-lg font-medium mb-4" style="color: var(--text-primary)">Theme</h4>
|
||||
<form hx-put="/api/auth/theme" hx-target="#theme-result" hx-swap="innerHTML" hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}' class="flex space-x-3">
|
||||
<select name="theme" id="theme-select" class="flex-1 px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">
|
||||
<option value="tokyo-night">Tokyo Night</option>
|
||||
<option value="dracula">Dracula</option>
|
||||
<option value="nord">Nord</option>
|
||||
<option value="solarized-dark">Solarized Dark</option>
|
||||
<option value="monokai">Monokai</option>
|
||||
<option value="one-dark-pro">One Dark Pro</option>
|
||||
<option value="material-dark">Material Dark</option>
|
||||
<option value="catppuccin-mocha">Catppuccin Mocha</option>
|
||||
<option value="catppuccin-macchiato">Catppuccin Macchiato</option>
|
||||
<option value="catppuccin-frappe">Catppuccin Frappé</option>
|
||||
<option value="catppuccin-latte">Catppuccin Latte</option>
|
||||
</select>
|
||||
<button type="submit" class="btn-primary px-4 py-2 rounded">Update Theme</button>
|
||||
</form>
|
||||
<div id="theme-result" class="mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Library Preferences -->
|
||||
<div class="space-y-6">
|
||||
<h3 class="text-2xl font-semibold" style="color: var(--text-primary)">Library Preferences</h3>
|
||||
|
||||
<!-- Ebook Folders Management -->
|
||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<h4 class="text-lg font-medium mb-4" style="color: var(--text-primary)">Ebook Folders</h4>
|
||||
<p style="color: var(--text-secondary)" class="mb-4 text-sm">Manage the folders where Bookmann scans for ebooks</p>
|
||||
|
||||
<!-- Add New Folder -->
|
||||
<div class="mb-4">
|
||||
<form hx-post="/api/auth/ebook-folders" hx-target="#folders-result" hx-swap="innerHTML" hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}' class="flex space-x-2">
|
||||
<input type="text" name="folder_path" placeholder="/path/to/ebooks" class="flex-1 px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
|
||||
<button type="submit" class="btn-primary px-3 py-2 rounded text-sm">Add Folder</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Current Folders List -->
|
||||
<div id="folders-list" class="space-y-2">
|
||||
<!-- Folders will be loaded here -->
|
||||
</div>
|
||||
|
||||
<div id="folders-result" class="mt-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- Scan Settings -->
|
||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<h4 class="text-lg font-medium mb-4" style="color: var(--text-primary)">Scan Settings</h4>
|
||||
|
||||
<!-- Auto Scan Toggle -->
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center space-x-3">
|
||||
<input type="checkbox" id="auto-scan-toggle" class="rounded" onchange="toggleAutoScan()">
|
||||
<span style="color: var(--text-primary)">Enable automatic scanning</span>
|
||||
</label>
|
||||
<p style="color: var(--text-secondary)" class="text-sm mt-1">Automatically scan folders for new ebooks</p>
|
||||
</div>
|
||||
|
||||
<!-- Scan Frequency -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Scan Frequency</label>
|
||||
<select id="scan-frequency" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" onchange="updateScanSettings()">
|
||||
<option value="15">Every 15 minutes</option>
|
||||
<option value="30">Every 30 minutes</option>
|
||||
<option value="60">Every hour</option>
|
||||
<option value="240">Every 4 hours</option>
|
||||
<option value="720">Every 12 hours</option>
|
||||
<option value="1440">Daily</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Manual Scan Button -->
|
||||
<div>
|
||||
<button onclick="manualScan()" class="btn-secondary px-4 py-2 rounded">Scan Now</button>
|
||||
<p style="color: var(--text-secondary)" class="text-sm mt-1">Manually scan all configured folders</p>
|
||||
</div>
|
||||
|
||||
<div id="scan-result" class="mt-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- Danger Zone -->
|
||||
<div class="card p-6 rounded-lg border border-red-500" style="background-color: var(--bg-secondary);">
|
||||
<h3 class="text-xl font-semibold mb-4 text-red-500">Danger Zone</h3>
|
||||
<div class="border-t border-red-500 pt-4">
|
||||
<h4 class="text-lg font-medium mb-2" style="color: var(--text-primary)">Delete Account</h4>
|
||||
<p style="color: var(--text-secondary)" class="mb-4 text-sm">
|
||||
Once you delete your account, there is no going back. Please be certain.
|
||||
</p>
|
||||
<button onclick="deleteAccount()" class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded font-medium">
|
||||
Delete Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Load user data on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadTheme();
|
||||
loadUserTheme();
|
||||
loadFolders();
|
||||
loadScanSettings();
|
||||
});
|
||||
|
||||
// Theme management
|
||||
function loadUserTheme() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
fetch('/api/auth/profile', {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
}).then(res => res.json()).then(data => {
|
||||
if (data.theme) {
|
||||
document.getElementById('theme-select').value = data.theme;
|
||||
applyTheme(data.theme);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Folder management
|
||||
function loadFolders() {
|
||||
fetch('/api/auth/ebook-folders', {
|
||||
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }
|
||||
}).then(res => res.json()).then(data => {
|
||||
renderFolders(data);
|
||||
}).catch(err => console.error('Error loading folders:', err));
|
||||
}
|
||||
|
||||
function renderFolders(folders) {
|
||||
const container = document.getElementById('folders-list');
|
||||
container.innerHTML = '';
|
||||
|
||||
if (folders.length === 0) {
|
||||
container.innerHTML = '<p style="color: var(--text-secondary)" class="text-sm italic">No folders configured</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
folders.forEach(folder => {
|
||||
const folderDiv = document.createElement('div');
|
||||
folderDiv.className = 'flex items-center justify-between p-2 border rounded' style="border-color: var(--border); background-color: var(--bg-primary)";
|
||||
folderDiv.innerHTML = `
|
||||
<span style="color: var(--text-primary)" class="text-sm font-mono">${folder.folder_path}</span>
|
||||
<button onclick="removeFolder('${folder.folder_path}')" class="text-red-500 hover:text-red-700 text-sm">
|
||||
Remove
|
||||
</button>
|
||||
`;
|
||||
container.appendChild(folderDiv);
|
||||
});
|
||||
}
|
||||
|
||||
function removeFolder(folderPath) {
|
||||
if (confirm(`Remove folder: ${folderPath}?`)) {
|
||||
fetch(`/api/auth/ebook-folders/${encodeURIComponent(folderPath)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }
|
||||
}).then(() => {
|
||||
loadFolders();
|
||||
}).catch(err => console.error('Error removing folder:', err));
|
||||
}
|
||||
}
|
||||
|
||||
// Scan settings (placeholder for now)
|
||||
function loadScanSettings() {
|
||||
fetch('/api/library/scan-settings', {
|
||||
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }
|
||||
}).then(res => res.json()).then(data => {
|
||||
document.getElementById('auto-scan-toggle').checked = data.auto_scan_enabled;
|
||||
document.getElementById('scan-frequency').value = data.scan_frequency_minutes;
|
||||
}).catch(err => {
|
||||
console.error('Error loading scan settings:', err);
|
||||
// Fall back to defaults
|
||||
document.getElementById('auto-scan-toggle').checked = true;
|
||||
document.getElementById('scan-frequency').value = '60';
|
||||
});
|
||||
}
|
||||
|
||||
function toggleAutoScan() {
|
||||
// TODO: Implement auto scan toggle
|
||||
console.log('Auto scan toggled');
|
||||
}
|
||||
|
||||
function updateScanSettings() {
|
||||
const scanFrequency = parseInt(document.getElementById('scan-frequency').value);
|
||||
const autoScanEnabled = document.getElementById('auto-scan-toggle').checked;
|
||||
|
||||
fetch('/api/library/scan-settings', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('token')
|
||||
},
|
||||
body: JSON.stringify({
|
||||
scan_frequency_minutes: scanFrequency,
|
||||
auto_scan_enabled: autoScanEnabled
|
||||
})
|
||||
}).then(res => res.json()).then(data => {
|
||||
// Show success message briefly
|
||||
console.log('Scan settings updated:', data.message);
|
||||
}).catch(err => {
|
||||
console.error('Error updating scan settings:', err);
|
||||
});
|
||||
}
|
||||
|
||||
function manualScan() {
|
||||
// Get folder paths from the current folders list
|
||||
const folderElements = document.querySelectorAll('#folders-list .font-mono');
|
||||
const folderPaths = Array.from(folderElements).map(el => el.textContent.trim());
|
||||
|
||||
fetch('/api/scanner/scan', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('token')
|
||||
},
|
||||
body: JSON.stringify({
|
||||
folder_paths: folderPaths
|
||||
})
|
||||
}).then(res => res.json()).then(data => {
|
||||
document.getElementById('scan-result').innerHTML = `<div style="color: var(--accent)">${data.message || 'Scan completed'}</div>`;
|
||||
}).catch(err => {
|
||||
document.getElementById('scan-result').innerHTML = `<div style="color: #ef4444">Scan failed</div>`;
|
||||
console.error('Scan error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Account deletion
|
||||
function deleteAccount() {
|
||||
if (confirm('Are you absolutely sure you want to delete your account? This action cannot be undone and will permanently delete all your ebooks and data.')) {
|
||||
fetch('/api/user/account', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('token')
|
||||
}
|
||||
}).then(response => {
|
||||
if (response.ok) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
alert('Error deleting account. Please try again.');
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error('Error deleting account:', error);
|
||||
alert('Error deleting account. Please try again.');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Logout
|
||||
function logout() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/';
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -12,7 +12,7 @@
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="/" class="px-3 py-2 text-sm hover:opacity-80" style="color: var(--text-secondary)">Dashboard</a>
|
||||
<a href="/preferences" class="px-3 py-2 text-sm hover:opacity-80" style="color: var(--text-secondary)">Preferences</a>
|
||||
<a href="/admin" class="px-3 py-2 text-sm hover:opacity-80" style="color: var(--text-secondary)">Admin</a>
|
||||
<span style="color: var(--text-secondary)">Welcome, {{.User.Username}}!</span>
|
||||
<button onclick="logout()" class="btn-primary px-4 py-2 rounded text-sm">
|
||||
Logout
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
{{template "base.html" .}}
|
||||
|
||||
{{define "title"}}Preferences - Bookmann{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<!-- Navigation Header -->
|
||||
<nav class="border-b" style="border-color: var(--border); background-color: var(--bg-secondary)">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<div class="flex items-center">
|
||||
<h1 class="text-xl font-bold" style="color: var(--text-primary)">📚 Bookmann</h1>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="/" class="px-3 py-2 text-sm hover:opacity-80" style="color: var(--text-secondary)">Dashboard</a>
|
||||
<span style="color: var(--text-secondary)">Welcome, {{.User.Username}}!</span>
|
||||
<button onclick="logout()" class="btn-primary px-4 py-2 rounded text-sm">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Header Section -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center space-x-4">
|
||||
<button onclick="window.history.back()" class="btn-secondary px-4 py-2 rounded-lg font-medium">
|
||||
← Back to Dashboard
|
||||
</button>
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold" style="color: var(--text-primary)">Account Preferences</h2>
|
||||
<p style="color: var(--text-secondary)">Manage your account settings and preferences</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preferences Sections -->
|
||||
<div class="space-y-8">
|
||||
<!-- Account Information -->
|
||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">Account Information</h3>
|
||||
|
||||
<!-- Update Username -->
|
||||
<div class="mb-6">
|
||||
<h4 class="text-lg font-medium mb-3" style="color: var(--text-primary)">Username</h4>
|
||||
<form hx-put="/api/user/username" hx-target="#username-result" hx-swap="innerHTML" hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}' class="flex space-x-3">
|
||||
<input type="text" name="username" value="{{.User.Username}}" class="flex-1 px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
|
||||
<button type="submit" class="btn-primary px-6 py-2 rounded">Update Username</button>
|
||||
</form>
|
||||
<div id="username-result" class="mt-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- Update Email -->
|
||||
<div class="mb-6">
|
||||
<h4 class="text-lg font-medium mb-3" style="color: var(--text-primary)">Email Address</h4>
|
||||
<form hx-put="/api/user/email" hx-target="#email-result" hx-swap="innerHTML" hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}' class="flex space-x-3">
|
||||
<input type="email" name="email" value="{{.User.Email}}" class="flex-1 px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
|
||||
<button type="submit" class="btn-primary px-6 py-2 rounded">Update Email</button>
|
||||
</form>
|
||||
<div id="email-result" class="mt-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- Update Password -->
|
||||
<div>
|
||||
<h4 class="text-lg font-medium mb-3" style="color: var(--text-primary)">Change Password</h4>
|
||||
<form hx-put="/api/user/password" hx-target="#password-result" hx-swap="innerHTML" hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}' class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Current Password</label>
|
||||
<input type="password" name="current_password" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">New Password</label>
|
||||
<input type="password" name="new_password" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required minlength="6">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Confirm New Password</label>
|
||||
<input type="password" name="confirm_password" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required minlength="6">
|
||||
</div>
|
||||
<button type="submit" class="btn-primary px-6 py-2 rounded">Update Password</button>
|
||||
</form>
|
||||
<div id="password-result" class="mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Appearance Settings -->
|
||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">Appearance</h3>
|
||||
|
||||
<div>
|
||||
<h4 class="text-lg font-medium mb-3" style="color: var(--text-primary)">Theme</h4>
|
||||
<form hx-put="/api/auth/theme" hx-target="#theme-result" hx-swap="innerHTML" hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}' class="flex space-x-3">
|
||||
<select name="theme" id="theme-select" class="flex-1 px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">
|
||||
<option value="tokyo-night">Tokyo Night</option>
|
||||
<option value="dracula">Dracula</option>
|
||||
<option value="nord">Nord</option>
|
||||
<option value="solarized-dark">Solarized Dark</option>
|
||||
<option value="monokai">Monokai</option>
|
||||
<option value="one-dark-pro">One Dark Pro</option>
|
||||
<option value="material-dark">Material Dark</option>
|
||||
<option value="catppuccin-mocha">Catppuccin Mocha</option>
|
||||
<option value="catppuccin-macchiato">Catppuccin Macchiato</option>
|
||||
<option value="catppuccin-frappe">Catppuccin Frappé</option>
|
||||
<option value="catppuccin-latte">Catppuccin Latte</option>
|
||||
</select>
|
||||
<button type="submit" class="btn-primary px-6 py-2 rounded">Update Theme</button>
|
||||
</form>
|
||||
<div id="theme-result" class="mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Danger Zone -->
|
||||
<div class="card p-6 rounded-lg border border-red-500" style="background-color: var(--bg-secondary);">
|
||||
<h3 class="text-xl font-semibold mb-6 text-red-500">Danger Zone</h3>
|
||||
<div class="border-t border-red-500 pt-6">
|
||||
<h4 class="text-lg font-medium mb-3" style="color: var(--text-primary)">Delete Account</h4>
|
||||
<p style="color: var(--text-secondary)" class="mb-4">
|
||||
Once you delete your account, there is no going back. Please be certain.
|
||||
</p>
|
||||
<button onclick="deleteAccount()" class="bg-red-600 hover:bg-red-700 text-white px-6 py-2 rounded font-medium">
|
||||
Delete Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function logout() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
function deleteAccount() {
|
||||
if (confirm('Are you absolutely sure you want to delete your account? This action cannot be undone and will permanently delete all your ebooks and data.')) {
|
||||
fetch('/api/user/account', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('token)',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
alert('Error deleting account. Please try again.');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error deleting account:', error);
|
||||
alert('Error deleting account. Please try again.');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function loadUserTheme() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
fetch('/api/auth/profile', {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
}).then(res => res.json()).then(data => {
|
||||
if (data.theme) {
|
||||
document.getElementById('theme-select').value = data.theme;
|
||||
applyTheme(data.theme);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Load theme on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadTheme();
|
||||
loadUserTheme();
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user