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:
2026-01-23 09:38:40 -05:00
parent 2dba1d6eb9
commit 11621462f2
12 changed files with 551 additions and 192 deletions
@@ -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;
+36
View File
@@ -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
}
+47
View File
@@ -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
}
+9 -7
View File
@@ -55,11 +55,13 @@ type UserEbookFolders struct {
}
type Users struct {
ID pgtype.UUID `db:"id" json:"id"`
Email string `db:"email" json:"email"`
Username string `db:"username" json:"username"`
PasswordHash string `db:"password_hash" json:"password_hash"`
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"`
ID pgtype.UUID `db:"id" json:"id"`
Email string `db:"email" json:"email"`
Username string `db:"username" json:"username"`
PasswordHash string `db:"password_hash" json:"password_hash"`
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"`
}
+2
View File
@@ -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
}
+43 -4
View File
@@ -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
`
+6
View File
@@ -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)
+56
View File
@@ -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,
+3
View File
@@ -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;
+345
View File
@@ -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}}
+1 -1
View File
@@ -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
-180
View File
@@ -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}}