feat(audio-player): per-book playback speed + user default (plan §8)
Speed becomes a per-USER-per-BOOK preference that syncs across devices, with a user-level default for books not yet played: - new audio_playback_speed table (mirrors reading_speed's shape; a preference, NOT a measurement and NOT a reading_progress column) + users.default_audio_speed, schema in place - chapters endpoint resolves per-book row → user default → 1.0 and ships playback_speed + default_playback_speed (zero extra round trips; default powers the dropdown marker) - PUT /api/media-items/:id/audio-speed upserts (range-checked; the client SPEEDS list stays the real gate; library visibility gates writes) - the user default rides the profile path like theme/timezone: profile form gains a 'Default Playback Speed' select (server-side SpeedOptions mirrors the web SPEEDS list), GET /profile carries it - web: restorePosition applies the resolved server speed (localStorage audio_speed demotes to a first-paint cache); setSpeed PUTs fire-and-forget with a failure toast; the Speed dropdown gains a '(default)' row marker and a 'Make N× my default' action with a success toast ALSO fixes a pre-existing profile-page bug surfaced by the §8 form verification: the password section's current_password input was required INSIDE the shared form, so HTML5 validation blocked every Save Changes submission (dead since the shell migration). The password flow keeps its own gates (disabled-button checkers + server 400). Integration test TestAudioPlaybackSpeed pins the full contract (unset → 1x, upsert resolution, range rejection, default fallback for row-less books, default updates re-satisfying unset books). Verified live: 9 Playwright checks.
This commit is contained in:
1 parent
3325b64463
commit
3576a4415a
16 files changed
+680
-148
No files matched your search
@@ -0,0 +1,172 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Playback speed preferences (player plan §8): a per-user PER-BOOK
|
||||||
|
// speed plus a user-level default, resolved at the chapters endpoint
|
||||||
|
// (per-book row → users.default_audio_speed → 1.0). All speed writes
|
||||||
|
// ride the fresh per-test regular user — the shared admin account is
|
||||||
|
// never touched (AGENTS doctrine). Fixture books live in test-named
|
||||||
|
// libraries that cascade away with the library delete.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeTestTrackTone renders a short sine mp3 at a GIVEN frequency so
|
||||||
|
// multiple fixture books never collide on the scanner's content dedup.
|
||||||
|
// Falls back to dummy bytes (ingest is ffprobe-optional) like
|
||||||
|
// writeTestTrack.
|
||||||
|
func writeTestTrackTone(t *testing.T, path string, hz int) {
|
||||||
|
t.Helper()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
||||||
|
if ffmpeg, err := exec.LookPath("ffmpeg"); err == nil {
|
||||||
|
cmd := exec.Command(ffmpeg, "-y", "-v", "error", "-f", "lavfi",
|
||||||
|
"-i", fmt.Sprintf("sine=frequency=%d:duration=0.3", hz), "-codec:a", "libmp3lame", "-b:a", "32k", path)
|
||||||
|
if out, err := cmd.CombinedOutput(); err == nil {
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
t.Logf("ffmpeg track render failed (%s), falling back to dummy bytes: %s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(fmt.Sprintf("ID3 dummy audio %d", hz)), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAudioPlaybackSpeed(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
t.Cleanup(func() { _ = setup.Close() })
|
||||||
|
|
||||||
|
admin, _ := moveTestAdminID(t, setup)
|
||||||
|
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Speed %d", time.Now().UnixNano()))
|
||||||
|
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||||
|
|
||||||
|
writeTestTrack(t, filepath.Join(folder, "Speed Book", "01 one.mp3"))
|
||||||
|
// 552 Hz (not the shared helper's 440) so the scanner's sha-256
|
||||||
|
// dedup doesn't skip the second book as a byte-duplicate.
|
||||||
|
writeTestTrackTone(t, filepath.Join(folder, "Speed Book Two", "01 one.mp3"), 552)
|
||||||
|
scanTestFolder(t, setup, admin, folder)
|
||||||
|
|
||||||
|
// Grouping depends on track count (single-track folders ingest as
|
||||||
|
// single-file items whose path IS the file; multi-track folders get
|
||||||
|
// the folder path) — find by the library-relative path CONTAINING
|
||||||
|
// the book folder so the test holds either way.
|
||||||
|
var bookPath, book2Path string
|
||||||
|
for _, item := range listLibraryAudioItems(t, setup, libID) {
|
||||||
|
p := filepath.ToSlash(item.FilePath)
|
||||||
|
t.Logf("ingested item path: %s", p)
|
||||||
|
if strings.Contains(p, "Speed Book Two") && book2Path == "" {
|
||||||
|
book2Path = p
|
||||||
|
} else if strings.Contains(p, "Speed Book/") && bookPath == "" {
|
||||||
|
bookPath = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotEmpty(t, bookPath, "Speed Book item must exist")
|
||||||
|
require.NotEmpty(t, book2Path, "Speed Book Two item must exist")
|
||||||
|
book, found := findAudioItemByPath(t, setup, libID, bookPath)
|
||||||
|
require.True(t, found)
|
||||||
|
bookID := uuid.UUID(book.ID.Bytes)
|
||||||
|
book2, found := findAudioItemByPath(t, setup, libID, book2Path)
|
||||||
|
require.True(t, found)
|
||||||
|
book2ID := uuid.UUID(book2.ID.Bytes)
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
token := setup.RegularToken
|
||||||
|
authed := func(method, url, body string) (*http.Response, func()) {
|
||||||
|
var req *http.Request
|
||||||
|
if body == "" {
|
||||||
|
req, _ = http.NewRequest(method, url, nil)
|
||||||
|
} else {
|
||||||
|
req, _ = http.NewRequest(method, url, strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return resp, func() { _ = resp.Body.Close() }
|
||||||
|
}
|
||||||
|
getSpeeds := func(itemID uuid.UUID) (playback, def float64) {
|
||||||
|
resp, closer := authed("GET", setup.Server.URL+"/api/media-items/"+itemID.String()+"/chapters", "")
|
||||||
|
defer closer()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var data struct {
|
||||||
|
PlaybackSpeed float64 `json:"playback_speed"`
|
||||||
|
DefaultPlaybackSpeed float64 `json:"default_playback_speed"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&data))
|
||||||
|
return data.PlaybackSpeed, data.DefaultPlaybackSpeed
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("unset resolves to 1x", func(t *testing.T) {
|
||||||
|
playback, def := getSpeeds(bookID)
|
||||||
|
require.Equal(t, 1.0, playback)
|
||||||
|
require.Equal(t, 1.0, def)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("put per-book speed upserts and resolves", func(t *testing.T) {
|
||||||
|
resp, closer := authed("PUT", setup.Server.URL+"/api/media-items/"+bookID.String()+"/audio-speed", `{"speed":1.5}`)
|
||||||
|
defer closer()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var saved struct {
|
||||||
|
PlaybackSpeed float64 `json:"playback_speed"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&saved))
|
||||||
|
require.Equal(t, 1.5, saved.PlaybackSpeed)
|
||||||
|
|
||||||
|
playback, def := getSpeeds(bookID)
|
||||||
|
require.Equal(t, 1.5, playback)
|
||||||
|
require.Equal(t, 1.0, def) // user default untouched
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("out of range speed is rejected", func(t *testing.T) {
|
||||||
|
for _, body := range []string{`{"speed":99}`, `{"speed":0}`, `{"speed":0.1}`} {
|
||||||
|
resp, closer := authed("PUT", setup.Server.URL+"/api/media-items/"+bookID.String()+"/audio-speed", body)
|
||||||
|
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
closer()
|
||||||
|
}
|
||||||
|
// The stored speed is untouched by the rejects.
|
||||||
|
playback, _ := getSpeeds(bookID)
|
||||||
|
require.Equal(t, 1.5, playback)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("profile default applies to books without their own speed", func(t *testing.T) {
|
||||||
|
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", `{"default_audio_speed":1.25}`)
|
||||||
|
defer closer()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
// Per-book row wins over the default on book one...
|
||||||
|
playback, def := getSpeeds(bookID)
|
||||||
|
require.Equal(t, 1.5, playback)
|
||||||
|
require.Equal(t, 1.25, def)
|
||||||
|
// ...book two has no row and picks the default up.
|
||||||
|
playback2, _ := getSpeeds(book2ID)
|
||||||
|
require.Equal(t, 1.25, playback2)
|
||||||
|
|
||||||
|
// GET /profile carries it for the profile form.
|
||||||
|
resp2, closer2 := authed("GET", setup.Server.URL+"/api/auth/profile", "")
|
||||||
|
defer closer2()
|
||||||
|
var profile struct {
|
||||||
|
DefaultAudioSpeed float64 `json:"default_audio_speed"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(resp2.Body).Decode(&profile))
|
||||||
|
require.Equal(t, 1.25, profile.DefaultAudioSpeed)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("updating the default re-satisfies unset books", func(t *testing.T) {
|
||||||
|
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", `{"default_audio_speed":0.75}`)
|
||||||
|
defer closer()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
playback2, _ := getSpeeds(book2ID)
|
||||||
|
require.Equal(t, 0.75, playback2)
|
||||||
|
playback1, _ := getSpeeds(bookID)
|
||||||
|
require.Equal(t, 1.5, playback1) // per-book pin survives
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -37,6 +37,11 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- User-level default audiobook playback speed (player plan §8): books
|
||||||
|
-- without a per-book audio_playback_speed row pick this up. REAL
|
||||||
|
-- mirrors the per-book table's column.
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS default_audio_speed REAL NOT NULL DEFAULT 1.0;
|
||||||
|
|
||||||
-- Create system_settings table
|
-- Create system_settings table
|
||||||
CREATE TABLE IF NOT EXISTS system_settings (
|
CREATE TABLE IF NOT EXISTS system_settings (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@@ -1563,6 +1568,24 @@ CREATE TABLE IF NOT EXISTS reading_speed (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_reading_speed_user ON reading_speed(user_id);
|
CREATE INDEX IF NOT EXISTS idx_reading_speed_user ON reading_speed(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_reading_speed_item ON reading_speed(media_item_id);
|
CREATE INDEX IF NOT EXISTS idx_reading_speed_item ON reading_speed(media_item_id);
|
||||||
|
|
||||||
|
-- Per-user PER-BOOK playback speed preference (player plan §8): a
|
||||||
|
-- preference, NOT a measurement (reading_speed holds measured rates),
|
||||||
|
-- and NOT a reading_progress column (a speed tap must not create a
|
||||||
|
-- "currently reading" row). Resolution lives at the chapters endpoint:
|
||||||
|
-- per-book row → users.default_audio_speed → 1.0.
|
||||||
|
CREATE TABLE IF NOT EXISTS audio_playback_speed (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
||||||
|
speed REAL NOT NULL DEFAULT 1.0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(user_id, media_item_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audio_playback_speed_user ON audio_playback_speed(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audio_playback_speed_item ON audio_playback_speed(media_item_id);
|
||||||
|
|
||||||
-- Dictionary cache (for offline use)
|
-- Dictionary cache (for offline use)
|
||||||
CREATE TABLE IF NOT EXISTS dictionary_cache (
|
CREATE TABLE IF NOT EXISTS dictionary_cache (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|||||||
+22
-12
@@ -8,6 +8,15 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type AudioPlaybackSpeed struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||||
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||||
|
Speed float32 `db:"speed" json:"speed"`
|
||||||
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type CollectionItems struct {
|
type CollectionItems struct {
|
||||||
ID pgtype.UUID `db:"id" json:"id"`
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
|
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
|
||||||
@@ -575,16 +584,17 @@ type UserDashboardPreferencesGlobal struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Users struct {
|
type Users struct {
|
||||||
ID pgtype.UUID `db:"id" json:"id"`
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
Email string `db:"email" json:"email"`
|
Email string `db:"email" json:"email"`
|
||||||
Username string `db:"username" json:"username"`
|
Username string `db:"username" json:"username"`
|
||||||
PasswordHash string `db:"password_hash" json:"password_hash"`
|
PasswordHash string `db:"password_hash" json:"password_hash"`
|
||||||
FirstName pgtype.Text `db:"first_name" json:"first_name"`
|
FirstName pgtype.Text `db:"first_name" json:"first_name"`
|
||||||
LastName pgtype.Text `db:"last_name" json:"last_name"`
|
LastName pgtype.Text `db:"last_name" json:"last_name"`
|
||||||
Role string `db:"role" json:"role"`
|
Role string `db:"role" json:"role"`
|
||||||
Theme pgtype.Text `db:"theme" json:"theme"`
|
Theme pgtype.Text `db:"theme" json:"theme"`
|
||||||
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
|
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
|
||||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||||
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
||||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||||
|
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
|
||||||
}
|
}
|
||||||
@@ -202,6 +202,8 @@ type Querier interface {
|
|||||||
GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error)
|
GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error)
|
||||||
GetAllSystemSettingsFull(ctx context.Context) ([]SystemSettings, error)
|
GetAllSystemSettingsFull(ctx context.Context) ([]SystemSettings, error)
|
||||||
GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error)
|
GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error)
|
||||||
|
// Per-book playback speed preference (player plan §8).
|
||||||
|
GetAudioPlaybackSpeed(ctx context.Context, arg GetAudioPlaybackSpeedParams) (float32, error)
|
||||||
// Lifetime session rows for ONE book (the book-detail stats card).
|
// Lifetime session rows for ONE book (the book-detail stats card).
|
||||||
GetBookReadingSessions(ctx context.Context, arg GetBookReadingSessionsParams) ([]GetBookReadingSessionsRow, error)
|
GetBookReadingSessions(ctx context.Context, arg GetBookReadingSessionsParams) ([]GetBookReadingSessionsRow, error)
|
||||||
GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error)
|
GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error)
|
||||||
@@ -696,12 +698,14 @@ type Querier interface {
|
|||||||
UpdateSystemSetting(ctx context.Context, arg UpdateSystemSettingParams) error
|
UpdateSystemSetting(ctx context.Context, arg UpdateSystemSettingParams) error
|
||||||
// Update universal progress
|
// Update universal progress
|
||||||
UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error)
|
UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error)
|
||||||
|
UpdateUserDefaultAudioSpeed(ctx context.Context, arg UpdateUserDefaultAudioSpeedParams) error
|
||||||
UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDevicesParams) (Users, error)
|
UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDevicesParams) (Users, error)
|
||||||
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
|
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
|
||||||
UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UpdateUserRoleRow, error)
|
UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UpdateUserRoleRow, error)
|
||||||
UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error
|
UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error
|
||||||
UpdateUserTimezone(ctx context.Context, arg UpdateUserTimezoneParams) error
|
UpdateUserTimezone(ctx context.Context, arg UpdateUserTimezoneParams) error
|
||||||
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
|
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
|
||||||
|
UpsertAudioPlaybackSpeed(ctx context.Context, arg UpsertAudioPlaybackSpeedParams) (float32, error)
|
||||||
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
|
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
|
||||||
UpsertGlobalDashboardPreferences(ctx context.Context, arg UpsertGlobalDashboardPreferencesParams) (UserDashboardPreferencesGlobal, error)
|
UpsertGlobalDashboardPreferences(ctx context.Context, arg UpsertGlobalDashboardPreferencesParams) (UserDashboardPreferencesGlobal, error)
|
||||||
UpsertMediaReview(ctx context.Context, arg UpsertMediaReviewParams) (MediaReviews, error)
|
UpsertMediaReview(ctx context.Context, arg UpsertMediaReviewParams) (MediaReviews, error)
|
||||||
|
|||||||
@@ -3761,6 +3761,25 @@ func (q *Queries) GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsF
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GetAudioPlaybackSpeed = `-- name: GetAudioPlaybackSpeed :one
|
||||||
|
|
||||||
|
SELECT speed FROM audio_playback_speed
|
||||||
|
WHERE user_id = $1 AND media_item_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetAudioPlaybackSpeedParams struct {
|
||||||
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||||
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-book playback speed preference (player plan §8).
|
||||||
|
func (q *Queries) GetAudioPlaybackSpeed(ctx context.Context, arg GetAudioPlaybackSpeedParams) (float32, error) {
|
||||||
|
row := q.db.QueryRow(ctx, GetAudioPlaybackSpeed, arg.UserID, arg.MediaItemID)
|
||||||
|
var speed float32
|
||||||
|
err := row.Scan(&speed)
|
||||||
|
return speed, err
|
||||||
|
}
|
||||||
|
|
||||||
const GetBookReadingSessions = `-- name: GetBookReadingSessions :many
|
const GetBookReadingSessions = `-- name: GetBookReadingSessions :many
|
||||||
SELECT
|
SELECT
|
||||||
rh.id, rh.device_id, rh.progress_percentage,
|
rh.id, rh.device_id, rh.progress_percentage,
|
||||||
@@ -9922,24 +9941,26 @@ SELECT
|
|||||||
u.created_at,
|
u.created_at,
|
||||||
u.updated_at,
|
u.updated_at,
|
||||||
u.timezone,
|
u.timezone,
|
||||||
|
u.default_audio_speed,
|
||||||
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
|
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
|
||||||
FROM users u
|
FROM users u
|
||||||
WHERE u.id = $1
|
WHERE u.id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetUserRow struct {
|
type GetUserRow struct {
|
||||||
ID pgtype.UUID `db:"id" json:"id"`
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
Email string `db:"email" json:"email"`
|
Email string `db:"email" json:"email"`
|
||||||
Username string `db:"username" json:"username"`
|
Username string `db:"username" json:"username"`
|
||||||
Theme pgtype.Text `db:"theme" json:"theme"`
|
Theme pgtype.Text `db:"theme" json:"theme"`
|
||||||
FirstName pgtype.Text `db:"first_name" json:"first_name"`
|
FirstName pgtype.Text `db:"first_name" json:"first_name"`
|
||||||
LastName pgtype.Text `db:"last_name" json:"last_name"`
|
LastName pgtype.Text `db:"last_name" json:"last_name"`
|
||||||
Role string `db:"role" json:"role"`
|
Role string `db:"role" json:"role"`
|
||||||
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
|
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
|
||||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||||
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
||||||
DeviceCount int64 `db:"device_count" json:"device_count"`
|
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
|
||||||
|
DeviceCount int64 `db:"device_count" json:"device_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error) {
|
func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error) {
|
||||||
@@ -9957,6 +9978,7 @@ func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, erro
|
|||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
|
&i.DefaultAudioSpeed,
|
||||||
&i.DeviceCount,
|
&i.DeviceCount,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
@@ -17200,9 +17222,23 @@ func (q *Queries) UpdateUniversalProgress(ctx context.Context, arg UpdateUnivers
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const UpdateUserDefaultAudioSpeed = `-- name: UpdateUserDefaultAudioSpeed :exec
|
||||||
|
UPDATE users SET default_audio_speed = $2, updated_at = NOW() WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateUserDefaultAudioSpeedParams struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateUserDefaultAudioSpeed(ctx context.Context, arg UpdateUserDefaultAudioSpeedParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, UpdateUserDefaultAudioSpeed, arg.ID, arg.DefaultAudioSpeed)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const UpdateUserMaxDevices = `-- name: UpdateUserMaxDevices :one
|
const UpdateUserMaxDevices = `-- name: UpdateUserMaxDevices :one
|
||||||
UPDATE users SET max_devices = $2, updated_at = NOW() WHERE id = $1
|
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, timezone, updated_at
|
RETURNING id, email, username, password_hash, first_name, last_name, role, theme, max_devices, created_at, timezone, updated_at, default_audio_speed
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateUserMaxDevicesParams struct {
|
type UpdateUserMaxDevicesParams struct {
|
||||||
@@ -17226,6 +17262,7 @@ func (q *Queries) UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDev
|
|||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.DefaultAudioSpeed,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -17316,6 +17353,27 @@ func (q *Queries) UpdateUsername(ctx context.Context, arg UpdateUsernameParams)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const UpsertAudioPlaybackSpeed = `-- name: UpsertAudioPlaybackSpeed :one
|
||||||
|
INSERT INTO audio_playback_speed (user_id, media_item_id, speed)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (user_id, media_item_id)
|
||||||
|
DO UPDATE SET speed = EXCLUDED.speed, updated_at = NOW()
|
||||||
|
RETURNING speed
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertAudioPlaybackSpeedParams struct {
|
||||||
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||||
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||||
|
Speed float32 `db:"speed" json:"speed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpsertAudioPlaybackSpeed(ctx context.Context, arg UpsertAudioPlaybackSpeedParams) (float32, error) {
|
||||||
|
row := q.db.QueryRow(ctx, UpsertAudioPlaybackSpeed, arg.UserID, arg.MediaItemID, arg.Speed)
|
||||||
|
var speed float32
|
||||||
|
err := row.Scan(&speed)
|
||||||
|
return speed, err
|
||||||
|
}
|
||||||
|
|
||||||
const UpsertDashboardPreferences = `-- name: UpsertDashboardPreferences :one
|
const UpsertDashboardPreferences = `-- name: UpsertDashboardPreferences :one
|
||||||
INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_collections, collection_order, items_per_section)
|
INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_collections, collection_order, items_per_section)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ SELECT
|
|||||||
u.created_at,
|
u.created_at,
|
||||||
u.updated_at,
|
u.updated_at,
|
||||||
u.timezone,
|
u.timezone,
|
||||||
|
u.default_audio_speed,
|
||||||
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
|
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
|
||||||
FROM users u
|
FROM users u
|
||||||
WHERE u.id = $1;
|
WHERE u.id = $1;
|
||||||
@@ -511,6 +512,22 @@ RETURNING id, email, username, role;
|
|||||||
-- name: UpdateUserTimezone :exec
|
-- name: UpdateUserTimezone :exec
|
||||||
UPDATE users SET timezone = $2, updated_at = NOW() WHERE id = $1;
|
UPDATE users SET timezone = $2, updated_at = NOW() WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: UpdateUserDefaultAudioSpeed :exec
|
||||||
|
UPDATE users SET default_audio_speed = $2, updated_at = NOW() WHERE id = $1;
|
||||||
|
|
||||||
|
-- Per-book playback speed preference (player plan §8).
|
||||||
|
|
||||||
|
-- name: GetAudioPlaybackSpeed :one
|
||||||
|
SELECT speed FROM audio_playback_speed
|
||||||
|
WHERE user_id = $1 AND media_item_id = $2;
|
||||||
|
|
||||||
|
-- name: UpsertAudioPlaybackSpeed :one
|
||||||
|
INSERT INTO audio_playback_speed (user_id, media_item_id, speed)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (user_id, media_item_id)
|
||||||
|
DO UPDATE SET speed = EXCLUDED.speed, updated_at = NOW()
|
||||||
|
RETURNING speed;
|
||||||
|
|
||||||
-- name: GetSystemTimezone :one
|
-- name: GetSystemTimezone :one
|
||||||
SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone';
|
SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone';
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -120,6 +121,8 @@ type UserProfile struct {
|
|||||||
LastName string `json:"last_name"`
|
LastName string `json:"last_name"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Theme string `json:"theme,omitempty"`
|
Theme string `json:"theme,omitempty"`
|
||||||
|
// User-level default audiobook playback speed (plan §8); 0 omits.
|
||||||
|
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateProfileRequest struct {
|
type UpdateProfileRequest struct {
|
||||||
@@ -129,6 +132,9 @@ type UpdateProfileRequest struct {
|
|||||||
LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
|
LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
|
||||||
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
|
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
|
||||||
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
|
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
|
||||||
|
// Per the SPEEDS list bounds; the profile select always sends it,
|
||||||
|
// JSON clients omit it (0 = untouched).
|
||||||
|
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty" form:"default_audio_speed" validate:"omitempty,min=0.25,max=4"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminUpdateUserRequest struct {
|
type AdminUpdateUserRequest struct {
|
||||||
@@ -139,6 +145,8 @@ type AdminUpdateUserRequest struct {
|
|||||||
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
|
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
|
||||||
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
|
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
|
||||||
Role string `json:"role,omitempty" form:"role" validate:"omitempty,oneof=user admin"`
|
Role string `json:"role,omitempty" form:"role" validate:"omitempty,oneof=user admin"`
|
||||||
|
// Same semantics as UpdateProfileRequest.DefaultAudioSpeed.
|
||||||
|
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty" form:"default_audio_speed" validate:"omitempty,min=0.25,max=4"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register handles POST /api/auth/register
|
// Register handles POST /api/auth/register
|
||||||
@@ -538,13 +546,14 @@ func (h *AuthHandler) GetProfile(c *echo.Context) error {
|
|||||||
lastName = user.LastName.String
|
lastName = user.LastName.String
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusOK, UserProfile{
|
return c.JSON(http.StatusOK, UserProfile{
|
||||||
ID: uuid.UUID(user.ID.Bytes).String(),
|
ID: uuid.UUID(user.ID.Bytes).String(),
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
FirstName: firstName,
|
FirstName: firstName,
|
||||||
LastName: lastName,
|
LastName: lastName,
|
||||||
Role: user.Role,
|
Role: user.Role,
|
||||||
Theme: user.Theme.String,
|
Theme: user.Theme.String,
|
||||||
|
DefaultAudioSpeed: float64(user.DefaultAudioSpeed),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -693,6 +702,22 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update the default audiobook playback speed (plan §8). The form
|
||||||
|
// select always sends it; JSON clients that omit it decode as 0 and
|
||||||
|
// leave the column untouched.
|
||||||
|
if req.DefaultAudioSpeed != 0 {
|
||||||
|
if req.DefaultAudioSpeed < 0.25 || req.DefaultAudioSpeed > 4 || math.IsNaN(req.DefaultAudioSpeed) || math.IsInf(req.DefaultAudioSpeed, 0) {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "default playback speed out of range"})
|
||||||
|
}
|
||||||
|
err := h.db.UpdateUserDefaultAudioSpeed(c.Request().Context(), database.UpdateUserDefaultAudioSpeedParams{
|
||||||
|
ID: targetUserUUID,
|
||||||
|
DefaultAudioSpeed: float32(req.DefaultAudioSpeed),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Browser path (htmx modal + profile page): refresh so every rendered
|
// Browser path (htmx modal + profile page): refresh so every rendered
|
||||||
// value (timezone select, table rows, header) reflects the save and the
|
// value (timezone select, table rows, header) reflects the save and the
|
||||||
// modal closes. A bare JSON body swapped into #profile-result read as
|
// modal closes. A bare JSON body swapped into #profile-result read as
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"math"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -2990,16 +2991,96 @@ func (mh *MediaHandler) GetAudioChapters(c *echo.Context) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Playback speed (plan §8): resolve per-book row → user default →
|
||||||
|
// 1.0 IN the payload so the player applies it with zero extra round
|
||||||
|
// trips; default_playback_speed powers the dropdown's "(default)"
|
||||||
|
// marker. Speed is a preference, so this rides the authenticated
|
||||||
|
// user — anonymous readers just get 1.0.
|
||||||
|
playbackSpeed, defaultSpeed := 1.0, 1.0
|
||||||
|
if userID, ok := c.Get("user_id").(string); ok {
|
||||||
|
if userUUID, err := uuid.Parse(userID); err == nil {
|
||||||
|
userRow, err := mh.db.GetUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
|
||||||
|
if err == nil && userRow.DefaultAudioSpeed > 0 {
|
||||||
|
defaultSpeed = float64(userRow.DefaultAudioSpeed)
|
||||||
|
}
|
||||||
|
bookSpeed, err := mh.db.GetAudioPlaybackSpeed(c.Request().Context(), database.GetAudioPlaybackSpeedParams{
|
||||||
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
})
|
||||||
|
if err == nil && bookSpeed > 0 {
|
||||||
|
playbackSpeed = float64(bookSpeed)
|
||||||
|
} else {
|
||||||
|
playbackSpeed = defaultSpeed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
"media_item_id": item.ID,
|
"media_item_id": item.ID,
|
||||||
"chapters": payload.Chapters,
|
"chapters": payload.Chapters,
|
||||||
"duration_seconds": payload.DurationSeconds,
|
"duration_seconds": payload.DurationSeconds,
|
||||||
"codec_profile": payload.CodecProfile,
|
"codec_profile": payload.CodecProfile,
|
||||||
"layout": payload.Layout,
|
"layout": payload.Layout,
|
||||||
"tracks": tracks,
|
"tracks": tracks,
|
||||||
|
"playback_speed": playbackSpeed,
|
||||||
|
"default_playback_speed": defaultSpeed,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetAudioSpeed handles PUT /api/media-items/:id/audio-speed — the
|
||||||
|
// per-user per-book playback speed upsert (plan §8). The client SPEEDS
|
||||||
|
// list is the real gate; the server sanity-checks the range.
|
||||||
|
func (mh *MediaHandler) SetAudioSpeed(c *echo.Context) error { mediaID := c.Param("id")
|
||||||
|
mediaUUID, err := uuid.Parse(mediaID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||||
|
}
|
||||||
|
userID, ok := c.Get("user_id").(string)
|
||||||
|
if !ok || userID == "" {
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||||
|
}
|
||||||
|
userUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid user"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Speed float64 `json:"speed"`
|
||||||
|
}
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||||
|
}
|
||||||
|
if req.Speed < 0.25 || req.Speed > 4 || math.IsNaN(req.Speed) || math.IsInf(req.Speed, 0) {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "speed out of range"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The book must exist (and be visible): a preference for a hidden
|
||||||
|
// library must not be writable — family-lens rule.
|
||||||
|
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
|
||||||
|
}
|
||||||
|
if item.LibraryID.Valid {
|
||||||
|
visibleSet, _, err := mh.visibleLibrarySet(c)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check library access"})
|
||||||
|
}
|
||||||
|
if !visibleSet[uuid.UUID(item.LibraryID.Bytes).String()] {
|
||||||
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
speed, err := mh.db.UpsertAudioPlaybackSpeed(c.Request().Context(), database.UpsertAudioPlaybackSpeedParams{
|
||||||
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
Speed: float32(req.Speed),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to save playback speed"})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]float64{"playback_speed": float64(speed)})
|
||||||
|
}
|
||||||
|
|
||||||
// GetAudioTrack handles GET /api/media-items/:id/audio/:trackIndex — the
|
// GetAudioTrack handles GET /api/media-items/:id/audio/:trackIndex — the
|
||||||
// per-track byte stream for multi-file audiobooks (folder books serve the
|
// per-track byte stream for multi-file audiobooks (folder books serve the
|
||||||
// real file; zip books extract the entry in place). Library visibility is
|
// real file; zip books extract the entry in place). Library visibility is
|
||||||
|
|||||||
+11
-10
@@ -56,16 +56,17 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
|
|||||||
}
|
}
|
||||||
|
|
||||||
return templates.User{
|
return templates.User{
|
||||||
ID: userID,
|
ID: userID,
|
||||||
Email: userEmail,
|
Email: userEmail,
|
||||||
Username: userUsername,
|
Username: userUsername,
|
||||||
Role: userRole,
|
Role: userRole,
|
||||||
Theme: userTheme,
|
Theme: userTheme,
|
||||||
Token: token,
|
Token: token,
|
||||||
Timezone: userTimezone,
|
Timezone: userTimezone,
|
||||||
FirstName: utils.PgTextString(userDB.FirstName),
|
FirstName: utils.PgTextString(userDB.FirstName),
|
||||||
LastName: utils.PgTextString(userDB.LastName),
|
LastName: utils.PgTextString(userDB.LastName),
|
||||||
CreatedAt: userDB.CreatedAt.Time,
|
CreatedAt: userDB.CreatedAt.Time,
|
||||||
|
DefaultAudioSpeed: float64(userDB.DefaultAudioSpeed),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ func registerMediaRoutes(cfg *Config) {
|
|||||||
// books stream through the existing /uploads Range-serving path;
|
// books stream through the existing /uploads Range-serving path;
|
||||||
// multi-file books additionally expose per-track streaming.
|
// multi-file books additionally expose per-track streaming.
|
||||||
protected.GET("/media-items/:id/chapters", cfg.MediaHandler.GetAudioChapters)
|
protected.GET("/media-items/:id/chapters", cfg.MediaHandler.GetAudioChapters)
|
||||||
|
protected.PUT("/media-items/:id/audio-speed", cfg.MediaHandler.SetAudioSpeed)
|
||||||
protected.GET("/media-items/:id/audio/:trackIndex", cfg.MediaHandler.GetAudioTrack)
|
protected.GET("/media-items/:id/audio/:trackIndex", cfg.MediaHandler.GetAudioTrack)
|
||||||
|
|
||||||
// Media rating routes (all authenticated users)
|
// Media rating routes (all authenticated users)
|
||||||
|
|||||||
@@ -403,9 +403,19 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
|||||||
class="w-full text-left px-3 py-1.5 rounded-md text-sm"
|
class="w-full text-left px-3 py-1.5 rounded-md text-sm"
|
||||||
:class="Number(speed) === rate ? 'font-semibold' : ''"
|
:class="Number(speed) === rate ? 'font-semibold' : ''"
|
||||||
:style="Number(speed) === rate ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'"
|
:style="Number(speed) === rate ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'"
|
||||||
x-text="rate + '×'"
|
x-text="rate + '×' + (isDefaultSpeed(rate) ? ' · default' : '')"
|
||||||
></button>
|
></button>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- §8: pin the current rate as the user default —
|
||||||
|
books without their own speed pick it up -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
x-show="!isDefaultSpeed(speed)"
|
||||||
|
@click="setDefaultSpeed(speed); speedOpen = false"
|
||||||
|
class="w-full text-left px-3 py-1.5 mt-1 rounded-md text-xs border-t"
|
||||||
|
style="border-color: var(--border); color: var(--text-secondary);"
|
||||||
|
x-text="'Make ' + speed + '× my default'"
|
||||||
|
></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span class=\"inline-block min-w-[4.75rem]\" x-text=\"fmtTime(scrubbing ? scrubValue : currentTime)\">0:00</span><div id=\"scrub-area\" class=\"relative flex-1 h-6 flex items-center cursor-pointer\" style=\"touch-action: none;\" @pointerdown=\"scrubPointerDown($event)\" @pointermove=\"scrubPointerMove($event)\" @pointerup=\"scrubPointerUp($event)\" @pointercancel=\"scrubPointerUp($event)\" @pointerleave=\"scrubPointerLeave()\" @wheel.prevent=\"onScrubWheel($event)\"><!-- Base track --><div class=\"absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" style=\"background-color: var(--surface-hover);\"></div><!-- Elapsed fill --><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:0;width:' + scrubFillPct() + '%;background-color:var(--accent);'\"></div><!-- Buffered span: the ACTIVE track's element buffer only --><template x-if=\"bufferSpan()\"><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:' + bufferSpan().left + '%;width:' + bufferSpan().width + '%;background-color:var(--accent-muted);'\"></div></template><!-- Chapter ticks: density-decimated, current chapter tinted --><div id=\"scrub-ticks\" class=\"absolute inset-0 pointer-events-none\"><template x-for=\"idx in tickIndices\" :key=\"'tick' + idx\"><div class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-2 w-0.5 rounded-full\" :class=\"currentChapterIdx === idx ? 'opacity-100' : 'opacity-40'\" :style=\"'left:' + (chapters[idx].start / (chaptersDuration() || 1) * 100) + '%;background-color:' + (currentChapterIdx === idx ? 'var(--accent)' : 'var(--text-secondary)') + ';'\"></div></template></div><!-- §9 magnet: the line a click would land on (hover\n\t\t\t\t\t\t inside the pixel radius locks the tooltip here) --><div x-show=\"hoverSnapped && !scrubbing\" x-cloak class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-4 w-px rounded-full pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--accent);'\"></div><input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"relative w-full\" @input=\"scrubbing = true; scrubValue = Number($event.target.value)\" @change=\"seekTo(scrubValue); scrubbing = false\" aria-label=\"Seek position\"><!-- Hover tooltip: time + chapter under the pointer --><div x-show=\"hoverSeconds !== null && !scrubbing\" x-cloak class=\"absolute bottom-full mb-1 -translate-x-1/2 whitespace-nowrap rounded-md px-2 py-1 text-[11px] shadow-lg pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);'\"><span x-text=\"hoverSeconds !== null ? fmtTime(hoverSeconds) : ''\"></span> <span style=\"color: var(--text-secondary);\" x-text=\"hoverSeconds !== null ? ' — ' + chapterTitleAt(hoverSeconds) : ''\"></span></div><!-- Precision rail (§9): Shift+drag on mouse, or a touch\n\t\t\t\t\t\t drag pulled off the bar — ±5 min or tighter, free\n\t\t\t\t\t\t mapping inside. Edge labels self-document the window\n\t\t\t\t\t\t (floats over the transports, page never shifts) --><div x-show=\"railVisible\" x-cloak class=\"absolute top-full mt-1 inset-x-0 h-12 rounded-lg shadow-lg p-1.5 pointer-events-none\" :style=\"'background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;' + (railLiftY ? ' transform: translateY(' + railLiftY + 'px);' : '')\"><div class=\"relative h-full\"><div class=\"absolute inset-x-0 top-1/2 h-px\" style=\"background-color: var(--border);\"></div><template x-for=\"tick in railTickList\" :key=\"'rtick' + tick.start\"><div class=\"absolute top-1 bottom-1 w-px\" :style=\"'left:' + ((tick.start - railFrom) / Math.max(1, railTo - railFrom) * 100) + '%;background-color:var(--text-secondary);opacity:0.5;'\"></div></template><div class=\"absolute top-0 bottom-0 w-0.5 -translate-x-1/2 rounded-full\" :style=\"'left:' + railMarkerPct() + '%;background-color:var(--accent);'\"></div><div class=\"absolute left-1 top-0.5 text-[10px] tabular-nums whitespace-nowrap\" style=\"color: var(--text-primary);\" x-text=\"fmtTime(scrubValue) + ' — ' + chapterTitleAt(scrubValue)\"></div><div class=\"absolute left-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railFrom)\"></div><div class=\"absolute right-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railTo)\"></div></div></div></div><button typeLine truncated
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span class=\"inline-block min-w-[4.75rem]\" x-text=\"fmtTime(scrubbing ? scrubValue : currentTime)\">0:00</span><div id=\"scrub-area\" class=\"relative flex-1 h-6 flex items-center cursor-pointer\" style=\"touch-action: none;\" @pointerdown=\"scrubPointerDown($event)\" @pointermove=\"scrubPointerMove($event)\" @pointerup=\"scrubPointerUp($event)\" @pointercancel=\"scrubPointerUp($event)\" @pointerleave=\"scrubPointerLeave()\" @wheel.prevent=\"onScrubWheel($event)\"><!-- Base track --><div class=\"absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" style=\"background-color: var(--surface-hover);\"></div><!-- Elapsed fill --><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:0;width:' + scrubFillPct() + '%;background-color:var(--accent);'\"></div><!-- Buffered span: the ACTIVE track's element buffer only --><template x-if=\"bufferSpan()\"><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:' + bufferSpan().left + '%;width:' + bufferSpan().width + '%;background-color:var(--accent-muted);'\"></div></template><!-- Chapter ticks: density-decimated, current chapter tinted --><div id=\"scrub-ticks\" class=\"absolute inset-0 pointer-events-none\"><template x-for=\"idx in tickIndices\" :key=\"'tick' + idx\"><div class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-2 w-0.5 rounded-full\" :class=\"currentChapterIdx === idx ? 'opacity-100' : 'opacity-40'\" :style=\"'left:' + (chapters[idx].start / (chaptersDuration() || 1) * 100) + '%;background-color:' + (currentChapterIdx === idx ? 'var(--accent)' : 'var(--text-secondary)') + ';'\"></div></template></div><!-- §9 magnet: the line a click would land on (hover\n\t\t\t\t\t\t inside the pixel radius locks the tooltip here) --><div x-show=\"hoverSnapped && !scrubbing\" x-cloak class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-4 w-px rounded-full pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--accent);'\"></div><input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"relative w-full\" @input=\"scrubbing = true; scrubValue = Number($event.target.value)\" @change=\"seekTo(scrubValue); scrubbing = false\" aria-label=\"Seek position\"><!-- Hover tooltip: time + chapter under the pointer --><div x-show=\"hoverSeconds !== null && !scrubbing\" x-cloak class=\"absolute bottom-full mb-1 -translate-x-1/2 whitespace-nowrap rounded-md px-2 py-1 text-[11px] shadow-lg pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);'\"><span x-text=\"hoverSeconds !== null ? fmtTime(hoverSeconds) : ''\"></span> <span style=\"color: var(--text-secondary);\" x-text=\"hoverSeconds !== null ? ' — ' + chapterTitleAt(hoverSeconds) : ''\"></span></div><!-- Precision rail (§9): Shift+drag on mouse, or a touch\n\t\t\t\t\t\t drag pulled off the bar — ±5 min or tighter, free\n\t\t\t\t\t\t mapping inside. Edge labels self-document the window\n\t\t\t\t\t\t (floats over the transports, page never shifts) --><div x-show=\"railVisible\" x-cloak class=\"absolute top-full mt-1 inset-x-0 h-12 rounded-lg shadow-lg p-1.5 pointer-events-none\" :style=\"'background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;' + (railLiftY ? ' transform: translateY(' + railLiftY + 'px);' : '')\"><div class=\"relative h-full\"><div class=\"absolute inset-x-0 top-1/2 h-px\" style=\"background-color: var(--border);\"></div><template x-for=\"tick in railTickList\" :key=\"'rtick' + tick.start\"><div class=\"absolute top-1 bottom-1 w-px\" :style=\"'left:' + ((tick.start - railFrom) / Math.max(1, railTo - railFrom) * 100) + '%;background-color:var(--text-secondary);opacity:0.5;'\"></div></template><div class=\"absolute top-0 bottom-0 w-0.5 -translate-x-1/2 rounded-full\" :style=\"'left:' + railMarkerPct() + '%;background-color:var(--accent);'\"></div><div class=\"absolute left-1 top-0.5 text-[10px] tabular-nums whitespace-nowrap\" style=\"color: var(--text-primary);\" x-text=\"fmtTime(scrubValue) + ' — ' + chapterTitleAt(scrubValue)\"></div><div class=\"absolute left-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railFrom)\"></div><div class=\"absolute right-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railTo)\"></div></div></div></div><button typeLine truncated
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
package templates
|
package templates
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// effectiveAudioSpeed renders 0 (unset) as 1× — the schema default.
|
||||||
|
func effectiveAudioSpeed(user User) float64 {
|
||||||
|
if user.DefaultAudioSpeed == 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return user.DefaultAudioSpeed
|
||||||
|
}
|
||||||
|
|
||||||
templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, showRoleField bool, showCancelButton bool) {
|
templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, showRoleField bool, showCancelButton bool) {
|
||||||
<form
|
<form
|
||||||
hx-put={ actionURL }
|
hx-put={ actionURL }
|
||||||
@@ -66,6 +76,17 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
|
|||||||
<option value="material-dark" selected?={ user.Theme == "material-dark" }>Material Dark</option>
|
<option value="material-dark" selected?={ user.Theme == "material-dark" }>Material Dark</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Default Playback Speed</label>
|
||||||
|
<select
|
||||||
|
name="default_audio_speed"
|
||||||
|
class="input"
|
||||||
|
>
|
||||||
|
for _, rate := range SpeedOptions {
|
||||||
|
<option value={ fmt.Sprintf("%g", rate) } selected?={ effectiveAudioSpeed(user) == rate }>{ fmt.Sprintf("%g", rate) }×</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Timezone</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Timezone</label>
|
||||||
<select
|
<select
|
||||||
@@ -125,7 +146,6 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
|
|||||||
type="password"
|
type="password"
|
||||||
name="current_password"
|
name="current_password"
|
||||||
class="input"
|
class="input"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
+153
-97
@@ -8,6 +8,16 @@ package templates
|
|||||||
import "github.com/a-h/templ"
|
import "github.com/a-h/templ"
|
||||||
import templruntime "github.com/a-h/templ/runtime"
|
import templruntime "github.com/a-h/templ/runtime"
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// effectiveAudioSpeed renders 0 (unset) as 1× — the schema default.
|
||||||
|
func effectiveAudioSpeed(user User) float64 {
|
||||||
|
if user.DefaultAudioSpeed == 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return user.DefaultAudioSpeed
|
||||||
|
}
|
||||||
|
|
||||||
func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showRoleField bool, showCancelButton bool) templ.Component {
|
func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showRoleField bool, showCancelButton bool) templ.Component {
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
@@ -36,7 +46,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(actionURL)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(actionURL)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 5, Col: 20}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 15, Col: 20}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -57,7 +67,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Username)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Username)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 21, Col: 27}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 31, Col: 27}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -70,7 +80,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Email)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Email)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 30, Col: 24}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 40, Col: 24}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -83,7 +93,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.FirstName)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.FirstName)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 39, Col: 28}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 49, Col: 28}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -96,7 +106,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.LastName)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.LastName)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 49, Col: 27}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 59, Col: 27}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -172,277 +182,323 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, ">Material Dark</option></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Timezone</label> <select name=\"timezone\" class=\"input\"><option value=\"UTC\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, ">Material Dark</option></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Default Playback Speed</label> <select name=\"default_audio_speed\" class=\"input\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, rate := range SpeedOptions {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<option value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var7 string
|
||||||
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%g", rate))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 86, Col: 46}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if effectiveAudioSpeed(user) == rate {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " selected")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, ">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var8 string
|
||||||
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%g", rate))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 86, Col: 122}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "×</option>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Timezone</label> <select name=\"timezone\" class=\"input\"><option value=\"UTC\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "UTC" {
|
if user.Timezone == "UTC" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " selected")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, ">UTC (UTC+0)</option> <option value=\"Pacific/Honolulu\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if user.Timezone == "Pacific/Honolulu" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " selected")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, ">Hawaii (UTC-10)</option> <option value=\"America/Anchorage\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if user.Timezone == "America/Anchorage" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " selected")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, ">Alaska (UTC-9/-8)</option> <option value=\"America/Los_Angeles\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if user.Timezone == "America/Los_Angeles" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, ">Pacific (UTC-8/-7)</option> <option value=\"America/Denver\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, ">UTC (UTC+0)</option> <option value=\"Pacific/Honolulu\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "America/Denver" {
|
if user.Timezone == "Pacific/Honolulu" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, ">Mountain (UTC-7/-6)</option> <option value=\"America/Phoenix\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, ">Hawaii (UTC-10)</option> <option value=\"America/Anchorage\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "America/Phoenix" {
|
if user.Timezone == "America/Anchorage" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ">Mountain - no DST (UTC-7)</option> <option value=\"America/Chicago\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ">Alaska (UTC-9/-8)</option> <option value=\"America/Los_Angeles\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "America/Chicago" {
|
if user.Timezone == "America/Los_Angeles" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">Central (UTC-6/-5)</option> <option value=\"America/New_York\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">Pacific (UTC-8/-7)</option> <option value=\"America/Denver\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "America/New_York" {
|
if user.Timezone == "America/Denver" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, ">Eastern (UTC-5/-4)</option> <option value=\"America/Sao_Paulo\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, ">Mountain (UTC-7/-6)</option> <option value=\"America/Phoenix\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "America/Sao_Paulo" {
|
if user.Timezone == "America/Phoenix" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, ">Brasilia (UTC-3/-2)</option> <option value=\"Europe/London\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, ">Mountain - no DST (UTC-7)</option> <option value=\"America/Chicago\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Europe/London" {
|
if user.Timezone == "America/Chicago" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">British (UTC+0/+1)</option> <option value=\"Europe/Paris\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">Central (UTC-6/-5)</option> <option value=\"America/New_York\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Europe/Paris" {
|
if user.Timezone == "America/New_York" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, ">Central European (UTC+1/+2)</option> <option value=\"Europe/Helsinki\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, ">Eastern (UTC-5/-4)</option> <option value=\"America/Sao_Paulo\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Europe/Helsinki" {
|
if user.Timezone == "America/Sao_Paulo" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, ">Eastern European (UTC+2/+3)</option> <option value=\"Europe/Moscow\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, ">Brasilia (UTC-3/-2)</option> <option value=\"Europe/London\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Europe/Moscow" {
|
if user.Timezone == "Europe/London" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, ">Moscow (UTC+3)</option> <option value=\"Asia/Tehran\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, ">British (UTC+0/+1)</option> <option value=\"Europe/Paris\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Asia/Tehran" {
|
if user.Timezone == "Europe/Paris" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, ">Iran (UTC+3:30)</option> <option value=\"Asia/Dubai\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, ">Central European (UTC+1/+2)</option> <option value=\"Europe/Helsinki\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Asia/Dubai" {
|
if user.Timezone == "Europe/Helsinki" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, ">Gulf (UTC+4)</option> <option value=\"Asia/Karachi\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, ">Eastern European (UTC+2/+3)</option> <option value=\"Europe/Moscow\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Asia/Karachi" {
|
if user.Timezone == "Europe/Moscow" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, ">Pakistan (UTC+5)</option> <option value=\"Asia/Kolkata\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, ">Moscow (UTC+3)</option> <option value=\"Asia/Tehran\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Asia/Kolkata" {
|
if user.Timezone == "Asia/Tehran" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ">India (UTC+5:30)</option> <option value=\"Asia/Dhaka\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ">Iran (UTC+3:30)</option> <option value=\"Asia/Dubai\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Asia/Dhaka" {
|
if user.Timezone == "Asia/Dubai" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, ">Bangladesh (UTC+6)</option> <option value=\"Asia/Bangkok\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, ">Gulf (UTC+4)</option> <option value=\"Asia/Karachi\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Asia/Bangkok" {
|
if user.Timezone == "Asia/Karachi" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, ">Indochina (UTC+7)</option> <option value=\"Asia/Shanghai\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, ">Pakistan (UTC+5)</option> <option value=\"Asia/Kolkata\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Asia/Shanghai" {
|
if user.Timezone == "Asia/Kolkata" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, ">China (UTC+8)</option> <option value=\"Asia/Tokyo\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, ">India (UTC+5:30)</option> <option value=\"Asia/Dhaka\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Asia/Tokyo" {
|
if user.Timezone == "Asia/Dhaka" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, ">Japan/Korea (UTC+9)</option> <option value=\"Australia/Darwin\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, ">Bangladesh (UTC+6)</option> <option value=\"Asia/Bangkok\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Australia/Darwin" {
|
if user.Timezone == "Asia/Bangkok" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, ">Australian Central (UTC+9:30)</option> <option value=\"Australia/Sydney\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, ">Indochina (UTC+7)</option> <option value=\"Asia/Shanghai\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Australia/Sydney" {
|
if user.Timezone == "Asia/Shanghai" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, ">Australian Eastern (UTC+10/+11)</option> <option value=\"Pacific/Auckland\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, ">China (UTC+8)</option> <option value=\"Asia/Tokyo\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Pacific/Auckland" {
|
if user.Timezone == "Asia/Tokyo" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, ">New Zealand (UTC+12/+13)</option></select></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, ">Japan/Korea (UTC+9)</option> <option value=\"Australia/Darwin\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if user.Timezone == "Australia/Darwin" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, " selected")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, ">Australian Central (UTC+9:30)</option> <option value=\"Australia/Sydney\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if user.Timezone == "Australia/Sydney" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, " selected")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, ">Australian Eastern (UTC+10/+11)</option> <option value=\"Pacific/Auckland\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if user.Timezone == "Pacific/Auckland" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, " selected")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, ">New Zealand (UTC+12/+13)</option></select></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if showRoleField {
|
if showRoleField {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Role</label> <select name=\"role\" class=\"input\"><option value=\"user\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Role</label> <select name=\"role\" class=\"input\"><option value=\"user\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Role == "user" {
|
if user.Role == "user" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, ">User</option> <option value=\"admin\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, ">User</option> <option value=\"admin\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Role == "admin" {
|
if user.Role == "admin" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, ">Admin</option></select></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, ">Admin</option></select></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</div></div><div x-data=\"profilePasswordForm\"><div class=\"flex items-center gap-2 mb-4\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</div></div><div x-data=\"profilePasswordForm\"><div class=\"flex items-center gap-2 mb-4\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -450,12 +506,12 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Change Password</h3></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Change Password</h3></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if requireCurrentPassword {
|
if requireCurrentPassword {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<div class=\"space-y-4 max-w-md\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Current Password</label> <input type=\"password\" name=\"current_password\" class=\"input\" required></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" id=\"new-password\" name=\"new_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" id=\"confirm-password\" name=\"confirm_password\" class=\"input\"></div><div id=\"password-requirements\" class=\"text-xs\"></div><button type=\"button\" id=\"password-submit-btn\" hx-put=\"/api/auth/password\" hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"btn btn-primary\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<div class=\"space-y-4 max-w-md\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Current Password</label> <input type=\"password\" name=\"current_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" id=\"new-password\" name=\"new_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" id=\"confirm-password\" name=\"confirm_password\" class=\"input\"></div><div id=\"password-requirements\" class=\"text-xs\"></div><button type=\"button\" id=\"password-submit-btn\" hx-put=\"/api/auth/password\" hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"btn btn-primary\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -463,25 +519,25 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "Update Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "Update Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<p class=\"text-sm italic mb-4\" style=\"color: var(--text-secondary);\">As an admin, you can change this user's password without knowing their current password.</p><div class=\"space-y-4 max-w-md\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" id=\"new-password\" name=\"new_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" id=\"confirm-password\" name=\"confirm_password\" class=\"input\"></div><div id=\"password-requirements\" class=\"text-xs\"></div><button type=\"button\" id=\"password-submit-btn\" hx-put=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<p class=\"text-sm italic mb-4\" style=\"color: var(--text-secondary);\">As an admin, you can change this user's password without knowing their current password.</p><div class=\"space-y-4 max-w-md\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" id=\"new-password\" name=\"new_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" id=\"confirm-password\" name=\"confirm_password\" class=\"input\"></div><div id=\"password-requirements\" class=\"text-xs\"></div><button type=\"button\" id=\"password-submit-btn\" hx-put=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/password/" + user.ID)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/password/" + user.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 191, Col: 46}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 211, Col: 46}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\" hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"btn btn-primary\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\" hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"btn btn-primary\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -489,22 +545,22 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "Reset Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "Reset Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</div><div class=\"flex justify-end gap-3 pt-6 border-t\" style=\"border-color: var(--border);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</div><div class=\"flex justify-end gap-3 pt-6 border-t\" style=\"border-color: var(--border);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if showCancelButton {
|
if showCancelButton {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<button type=\"button\" @click=\"closeProfileModal()\" class=\"btn btn-secondary\">Cancel</button> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "<button type=\"button\" @click=\"closeProfileModal()\" class=\"btn btn-secondary\">Cancel</button> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<button type=\"submit\" class=\"btn btn-primary\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<button type=\"submit\" class=\"btn btn-primary\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -512,7 +568,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "Save Changes</button></div><div id=\"profile-result\" class=\"mt-2\"></div></form>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "Save Changes</button></div><div id=\"profile-result\" class=\"mt-2\"></div></form>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,15 @@ type User struct {
|
|||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
Token string
|
Token string
|
||||||
Timezone string
|
Timezone string
|
||||||
|
// User-level default audiobook playback speed (plan §8); 0 means
|
||||||
|
// "unset" and renders as the 1× option.
|
||||||
|
DefaultAudioSpeed float64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SpeedOption is one entry of the default-playback-speed select — the
|
||||||
|
// server-side mirror of the web player's SPEEDS list (plan §5/§8).
|
||||||
|
var SpeedOptions = []float64{0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3}
|
||||||
|
|
||||||
type PageData struct {
|
type PageData struct {
|
||||||
User User
|
User User
|
||||||
CurrentPage string
|
CurrentPage string
|
||||||
|
|||||||
@@ -196,6 +196,9 @@ Alpine.data("audioReader", () => ({
|
|||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
playing: false,
|
playing: false,
|
||||||
speed: normalizeSpeed(localStorage.getItem("audio_speed")),
|
speed: normalizeSpeed(localStorage.getItem("audio_speed")),
|
||||||
|
// User-level default speed (plan §8): powers the dropdown's
|
||||||
|
// "(default)" marker; hydrated from the chapters payload.
|
||||||
|
defaultSpeed: 1,
|
||||||
// Exposed for the templ dropdown's x-for — templ cannot import the
|
// Exposed for the templ dropdown's x-for — templ cannot import the
|
||||||
// const directly; this property is the binding.
|
// const directly; this property is the binding.
|
||||||
speeds: SPEEDS,
|
speeds: SPEEDS,
|
||||||
@@ -501,6 +504,17 @@ Alpine.data("audioReader", () => ({
|
|||||||
const data = await chaptersResp.json();
|
const data = await chaptersResp.json();
|
||||||
this.chapters = (data.chapters || []) as AudioChapter[];
|
this.chapters = (data.chapters || []) as AudioChapter[];
|
||||||
this.bookDuration = data.duration_seconds || this.chapterEndEstimate();
|
this.bookDuration = data.duration_seconds || this.chapterEndEstimate();
|
||||||
|
// §8: the server owns speed — per-book row, else the user's
|
||||||
|
// default. localStorage stays only as a first-paint cache; the
|
||||||
|
// payload value wins over it BEFORE the rate is (re)applied to
|
||||||
|
// the element below.
|
||||||
|
const serverSpeed = Number(data.playback_speed);
|
||||||
|
if (SPEEDS.includes(serverSpeed)) {
|
||||||
|
this.speed = serverSpeed;
|
||||||
|
localStorage.setItem("audio_speed", String(serverSpeed));
|
||||||
|
}
|
||||||
|
const serverDefault = Number(data.default_playback_speed);
|
||||||
|
if (SPEEDS.includes(serverDefault)) this.defaultSpeed = serverDefault;
|
||||||
this.buildTracks(data);
|
this.buildTracks(data);
|
||||||
this.recomputeTicks();
|
this.recomputeTicks();
|
||||||
const audio = this.audio();
|
const audio = this.audio();
|
||||||
@@ -624,6 +638,39 @@ Alpine.data("audioReader", () => ({
|
|||||||
localStorage.setItem("audio_speed", String(rate));
|
localStorage.setItem("audio_speed", String(rate));
|
||||||
const audio = this.audio();
|
const audio = this.audio();
|
||||||
if (audio) audio.playbackRate = rate;
|
if (audio) audio.playbackRate = rate;
|
||||||
|
// §8: the per-book speed lives server-side (localStorage is only a
|
||||||
|
// first-paint cache). Fire-and-forget; a failure toasts — the rate
|
||||||
|
// still applies to this session.
|
||||||
|
fetch(`/api/media-items/${this.mediaItemId}/audio-speed`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { ...getAuthHeaders(), "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ speed: rate }),
|
||||||
|
})
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error(String(r.status));
|
||||||
|
})
|
||||||
|
.catch(() => showToast("Speed saved for this session only — server rejected it", "error"));
|
||||||
|
},
|
||||||
|
|
||||||
|
// setDefaultSpeed pins a user-level default (plan §8): books without
|
||||||
|
// their own per-book speed pick it up. Rides the profile endpoint,
|
||||||
|
// same as the profile-page select.
|
||||||
|
setDefaultSpeed(rate: number) {
|
||||||
|
fetch("/api/auth/profile", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { ...getAuthHeaders(), "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ default_audio_speed: rate }),
|
||||||
|
})
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error(String(r.status));
|
||||||
|
this.defaultSpeed = rate;
|
||||||
|
showToast(`Default playback speed set to ${rate}×`, "success");
|
||||||
|
})
|
||||||
|
.catch(() => showToast("Default speed not saved", "error"));
|
||||||
|
},
|
||||||
|
|
||||||
|
isDefaultSpeed(rate: number): boolean {
|
||||||
|
return Number(this.defaultSpeed) === rate;
|
||||||
},
|
},
|
||||||
|
|
||||||
setSkip(seconds: number) {
|
setSkip(seconds: number) {
|
||||||
|
|||||||
Reference in new issue
Block a user