Merge branch 'main' of ssh://git.linuxhg.com:2222/Bookhoard/bookhoard

This commit is contained in:
2026-05-01 14:31:54 -04:00
50 changed files with 1169 additions and 355 deletions
+1
View File
@@ -507,5 +507,6 @@ type Users struct {
Theme pgtype.Text `db:"theme" json:"theme"`
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
Timezone pgtype.Text `db:"timezone" json:"timezone"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
}
+2
View File
@@ -247,6 +247,7 @@ type Querier interface {
GetSystemConfig(ctx context.Context, key string) (SystemConfig, error)
// System Settings queries
GetSystemSetting(ctx context.Context, settingKey string) (string, error)
GetSystemTimezone(ctx context.Context) (string, error)
// Get universal progress for a book
GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error)
// Get unlinked book by ContentId
@@ -373,6 +374,7 @@ type Querier interface {
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UpdateUserRoleRow, error)
UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error
UpdateUserTimezone(ctx context.Context, arg UpdateUserTimezoneParams) error
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
+30 -1
View File
@@ -5408,6 +5408,17 @@ func (q *Queries) GetSystemSetting(ctx context.Context, settingKey string) (stri
return setting_value, err
}
const GetSystemTimezone = `-- name: GetSystemTimezone :one
SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone'
`
func (q *Queries) GetSystemTimezone(ctx context.Context) (string, error) {
row := q.db.QueryRow(ctx, GetSystemTimezone)
var setting_value string
err := row.Scan(&setting_value)
return setting_value, err
}
const GetUniversalProgress = `-- name: GetUniversalProgress :one
SELECT
rp.id,
@@ -5651,6 +5662,7 @@ SELECT
u.max_devices,
u.created_at,
u.updated_at,
u.timezone,
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
FROM users u
WHERE u.id = $1
@@ -5667,6 +5679,7 @@ type GetUserRow struct {
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
Timezone pgtype.Text `db:"timezone" json:"timezone"`
DeviceCount int64 `db:"device_count" json:"device_count"`
}
@@ -5684,6 +5697,7 @@ func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, erro
&i.MaxDevices,
&i.CreatedAt,
&i.UpdatedAt,
&i.Timezone,
&i.DeviceCount,
)
return i, err
@@ -10256,7 +10270,7 @@ func (q *Queries) UpdateUniversalProgress(ctx context.Context, arg UpdateUnivers
const UpdateUserMaxDevices = `-- name: UpdateUserMaxDevices :one
UPDATE users SET max_devices = $2, updated_at = NOW() WHERE id = $1
RETURNING id, email, username, password_hash, first_name, last_name, role, theme, max_devices, created_at, updated_at
RETURNING id, email, username, password_hash, first_name, last_name, role, theme, max_devices, created_at, timezone, updated_at
`
type UpdateUserMaxDevicesParams struct {
@@ -10278,6 +10292,7 @@ func (q *Queries) UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDev
&i.Theme,
&i.MaxDevices,
&i.CreatedAt,
&i.Timezone,
&i.UpdatedAt,
)
return i, err
@@ -10341,6 +10356,20 @@ func (q *Queries) UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams
return err
}
const UpdateUserTimezone = `-- name: UpdateUserTimezone :exec
UPDATE users SET timezone = $2, updated_at = NOW() WHERE id = $1
`
type UpdateUserTimezoneParams struct {
ID pgtype.UUID `db:"id" json:"id"`
Timezone pgtype.Text `db:"timezone" json:"timezone"`
}
func (q *Queries) UpdateUserTimezone(ctx context.Context, arg UpdateUserTimezoneParams) error {
_, err := q.db.Exec(ctx, UpdateUserTimezone, arg.ID, arg.Timezone)
return err
}
const UpdateUsername = `-- name: UpdateUsername :exec
UPDATE users SET username = $2, updated_at = NOW() WHERE id = $1
`
+7
View File
@@ -27,6 +27,7 @@ SELECT
u.max_devices,
u.created_at,
u.updated_at,
u.timezone,
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
FROM users u
WHERE u.id = $1;
@@ -300,6 +301,12 @@ RETURNING *;
UPDATE users SET role = $2, updated_at = NOW() WHERE id = $1
RETURNING id, email, username, role;
-- name: UpdateUserTimezone :exec
UPDATE users SET timezone = $2, updated_at = NOW() WHERE id = $1;
-- name: GetSystemTimezone :one
SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone';
-- name: CountUserDevices :one
SELECT COUNT(*) FROM devices WHERE user_id = $1;
+8 -8
View File
@@ -75,18 +75,18 @@ func (h *AnalyticsHandler) GetReadingStats(c *echo.Context) error {
endDate := c.QueryParam("end_date")
if startDate == "" {
startDate = time.Now().AddDate(0, -1, 0).Format("2006-01-02")
startDate = time.Now().AddDate(0, -1, 0).Format("01-02-2006")
}
if endDate == "" {
endDate = time.Now().Format("2006-01-02")
endDate = time.Now().Format("01-02-2006")
}
startTime, err := time.Parse("2006-01-02", startDate)
startTime, err := time.Parse("01-02-2006", startDate)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid start_date format")
}
endTime, err := time.Parse("2006-01-02", endDate)
endTime, err := time.Parse("01-02-2006", endDate)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid end_date format")
}
@@ -130,7 +130,7 @@ func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadi
longestSession = int(minutes)
}
dateKey := entry.CreatedAt.Time.Format("2006-01-02")
dateKey := entry.CreatedAt.Time.Format("01-02-2006")
if dailyMap[dateKey] == nil {
dailyMap[dateKey] = &DailyReading{
Date: dateKey,
@@ -141,7 +141,7 @@ func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadi
if entry.PagesRead.Valid {
totalPages += int(entry.PagesRead.Int32)
dateKey := entry.CreatedAt.Time.Format("2006-01-02")
dateKey := entry.CreatedAt.Time.Format("01-02-2006")
if dailyMap[dateKey] != nil {
dailyMap[dateKey].Pages += int(entry.PagesRead.Int32)
}
@@ -222,7 +222,7 @@ func (h *AnalyticsHandler) GetDeviceUsage(c *echo.Context) error {
lastSync := ""
if u.LastSync != nil {
if t, ok := u.LastSync.(time.Time); ok {
lastSync = t.Format("2006-01-02 15:04:05")
lastSync = t.Format("01-02-2006 03:04:05 PM")
}
}
@@ -274,7 +274,7 @@ func (h *AnalyticsHandler) GetPopularBooks(c *echo.Context) error {
lastRead := ""
if book.LastRead != nil {
if t, ok := book.LastRead.(time.Time); ok {
lastRead = t.Format("2006-01-02 15:04:05")
lastRead = t.Format("01-02-2006 03:04:05 PM")
}
}
+18
View File
@@ -87,6 +87,7 @@ type UpdateProfileRequest struct {
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
Theme string `json:"theme,omitempty" validate:"omitempty"`
Timezone string `json:"timezone,omitempty" validate:"omitempty"`
}
type AdminUpdateUserRequest struct {
@@ -95,6 +96,7 @@ type AdminUpdateUserRequest struct {
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
Theme string `json:"theme,omitempty" validate:"omitempty"`
Timezone string `json:"timezone,omitempty" validate:"omitempty"`
Role string `json:"role,omitempty" validate:"omitempty,oneof=user admin"`
}
@@ -564,6 +566,22 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
}
}
// Update timezone
if req.Timezone != "" {
if _, err := time.LoadLocation(req.Timezone); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Invalid timezone",
})
}
err := h.db.UpdateUserTimezone(c.Request().Context(), database.UpdateUserTimezoneParams{
ID: targetUserUUID,
Timezone: pgtype.Text{String: req.Timezone, Valid: true},
})
if err != nil {
return err
}
}
// Update email (if provided)
if req.Email != "" {
existingUser, err := h.db.GetUserByEmail(c.Request().Context(), req.Email)
+1 -1
View File
@@ -306,7 +306,7 @@ func (h *Handler) GetAllProgress(c *echo.Context) error {
lastUpdated := ""
if progress.LastReadAt.Valid {
lastUpdated = progress.LastReadAt.Time.Format("2006-01-02 15:04")
lastUpdated = progress.LastReadAt.Time.Format("01-02-2006 03:04 PM")
}
progressList = append(progressList, ProgressWithMedia{
+94 -1
View File
@@ -388,6 +388,23 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
// Update each config value
for key, value := range req {
if key == "default_timezone" {
if _, err := time.LoadLocation(value); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid timezone",
})
}
err := h.db.UpdateSystemSetting(ctx, database.UpdateSystemSettingParams{
SettingKey: "default_timezone",
SettingValue: value,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "failed to update default timezone",
})
}
continue
}
_, err := h.db.SetSystemConfig(ctx, database.SetSystemConfigParams{
Key: key,
Value: value,
@@ -408,6 +425,12 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to fetch updated configuration</div>`)
}
defaultTimezone := "UTC"
tz, err := h.db.GetSystemTimezone(ctx)
if err == nil && tz != "" {
defaultTimezone = tz
}
// Render success message with updated form
return c.HTML(http.StatusOK, fmt.Sprintf(`
<div class="mb-4 p-4 rounded-lg" style="background-color: var(--bg-secondary); border: 1px solid var(--accent);">
@@ -437,6 +460,44 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
</button>
</div>
</div>
<div class="mt-8 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)">System Defaults</h3>
<div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-primary)">Default Timezone</label>
<select name="default_timezone" id="default_timezone" class="w-full px-4 py-2 rounded-lg border" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
<option value="UTC"%s>UTC (UTC+0)</option>
<option value="Pacific/Honolulu"%s>Hawaii (UTC-10)</option>
<option value="America/Anchorage"%s>Alaska (UTC-9/-8)</option>
<option value="America/Los_Angeles"%s>Pacific (UTC-8/-7)</option>
<option value="America/Denver"%s>Mountain (UTC-7/-6)</option>
<option value="America/Phoenix"%s>Mountain - no DST (UTC-7)</option>
<option value="America/Chicago"%s>Central (UTC-6/-5)</option>
<option value="America/New_York"%s>Eastern (UTC-5/-4)</option>
<option value="America/Sao_Paulo"%s>Brasilia (UTC-3/-2)</option>
<option value="Europe/London"%s>British (UTC+0/+1)</option>
<option value="Europe/Paris"%s>Central European (UTC+1/+2)</option>
<option value="Europe/Helsinki"%s>Eastern European (UTC+2/+3)</option>
<option value="Europe/Moscow"%s>Moscow (UTC+3)</option>
<option value="Asia/Tehran"%s>Iran (UTC+3:30)</option>
<option value="Asia/Dubai"%s>Gulf (UTC+4)</option>
<option value="Asia/Karachi"%s>Pakistan (UTC+5)</option>
<option value="Asia/Kolkata"%s>India (UTC+5:30)</option>
<option value="Asia/Dhaka"%s>Bangladesh (UTC+6)</option>
<option value="Asia/Bangkok"%s>Indochina (UTC+7)</option>
<option value="Asia/Shanghai"%s>China (UTC+8)</option>
<option value="Asia/Tokyo"%s>Japan/Korea (UTC+9)</option>
<option value="Australia/Darwin"%s>Australian Central (UTC+9:30)</option>
<option value="Australia/Sydney"%s>Australian Eastern (UTC+10/+11)</option>
<option value="Pacific/Auckland"%s>New Zealand (UTC+12/+13)</option>
</select>
<p class="text-sm mt-1" style="color: var(--text-secondary)">Default timezone for users who haven't set their own.</p>
</div>
<div class="mt-6 flex justify-end">
<button type="submit" class="btn-primary px-6 py-2 rounded-lg font-medium">
Save Settings
</button>
</div>
</div>
</form>
<div class="mt-8 card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
@@ -447,7 +508,32 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
<p><strong>Device Sync:</strong> %s/api/sync</p>
</div>
</div>
`, baseURL.Value, baseURL.Value, baseURL.Value, baseURL.Value))
`, baseURL.Value,
selectedAttr(defaultTimezone, "UTC"),
selectedAttr(defaultTimezone, "Pacific/Honolulu"),
selectedAttr(defaultTimezone, "America/Anchorage"),
selectedAttr(defaultTimezone, "America/Los_Angeles"),
selectedAttr(defaultTimezone, "America/Denver"),
selectedAttr(defaultTimezone, "America/Phoenix"),
selectedAttr(defaultTimezone, "America/Chicago"),
selectedAttr(defaultTimezone, "America/New_York"),
selectedAttr(defaultTimezone, "America/Sao_Paulo"),
selectedAttr(defaultTimezone, "Europe/London"),
selectedAttr(defaultTimezone, "Europe/Paris"),
selectedAttr(defaultTimezone, "Europe/Helsinki"),
selectedAttr(defaultTimezone, "Europe/Moscow"),
selectedAttr(defaultTimezone, "Asia/Tehran"),
selectedAttr(defaultTimezone, "Asia/Dubai"),
selectedAttr(defaultTimezone, "Asia/Karachi"),
selectedAttr(defaultTimezone, "Asia/Kolkata"),
selectedAttr(defaultTimezone, "Asia/Dhaka"),
selectedAttr(defaultTimezone, "Asia/Bangkok"),
selectedAttr(defaultTimezone, "Asia/Shanghai"),
selectedAttr(defaultTimezone, "Asia/Tokyo"),
selectedAttr(defaultTimezone, "Australia/Darwin"),
selectedAttr(defaultTimezone, "Australia/Sydney"),
selectedAttr(defaultTimezone, "Pacific/Auckland"),
baseURL.Value, baseURL.Value, baseURL.Value))
}
return c.JSON(http.StatusOK, map[string]string{
@@ -477,3 +563,10 @@ func sanitizeAll(s string, old string, new string) string {
}
return result
}
func selectedAttr(current, value string) string {
if current == value {
return " selected"
}
return ""
}
+23
View File
@@ -5,6 +5,7 @@ import (
"errors"
"net/http"
"strconv"
"time"
"github.com/jackc/pgx/v5"
"github.com/labstack/echo/v5"
@@ -31,6 +32,28 @@ type ScanSettingsResponse struct {
Message string `json:"message,omitempty"`
}
type UpdateTimezoneSettingsRequest struct {
DefaultTimezone string `json:"default_timezone" validate:"required"`
}
func (h *SystemSettingsHandler) UpdateTimezoneSettings(c *echo.Context) error {
var req UpdateTimezoneSettingsRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
}
if _, err := time.LoadLocation(req.DefaultTimezone); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid timezone"})
}
err := h.db.UpdateSystemSetting(c.Request().Context(), database.UpdateSystemSettingParams{
SettingKey: "default_timezone",
SettingValue: req.DefaultTimezone,
})
if err != nil {
return err
}
return c.JSON(http.StatusOK, map[string]string{"message": "Timezone updated"})
}
func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error {
var req UpdateScanSettingsRequest
if err := c.Bind(&req); err != nil {
+7 -1
View File
@@ -932,7 +932,13 @@ func registerFrontendRoutes(cfg *Config) {
}
systemConfig := map[string]string{
"base_url": baseURL,
"base_url": baseURL,
"default_timezone": "UTC",
}
defaultTimezone, err := cfg.Queries.GetSystemTimezone(c.Request().Context())
if err == nil && defaultTimezone != "" {
systemConfig["default_timezone"] = defaultTimezone
}
var buf bytes.Buffer
+6
View File
@@ -35,6 +35,11 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
userTheme = userDB.Theme.String
}
userTimezone := "UTC"
if userDB.Timezone.Valid {
userTimezone = userDB.Timezone.String
}
// Extract JWT token for WebSocket authentication
token := ""
if cookie, err := c.Cookie("token"); err == nil {
@@ -48,6 +53,7 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
Role: userRole,
Theme: userTheme,
Token: token,
Timezone: userTimezone,
}, nil
}