feat(stats): reading statistics — session capture wired + /stats page + widget + per-book card
The design parked in the handoff (multi-tier stats off reading_history; bookorbit-style in data, not looks) is in. The real prerequisite work was data: reading_history existed but NOTHING wrote to it — the whole stats feature was sitting on an empty table. - CAPTURE (no client changes, no repo-boundary crossings): every successful progress write through SaveProgress (KOReader + app + web + Kobo + offline queue — the one chokepoint) now records one reading_history session row: span = the interval since the PREVIOUS write instant, pages = positive page delta when both sides know the page index, marker rows for first contact. Spans over 2h are kept as zero-second markers (overnight suspend→resume, offline flushes must not inflate totals) — the honest 'sync cadence, not a stopwatch' caveat stays. device_id only when a REAL device row matches (the web/app progress path historically stuffs the USER id into DeviceID — probe instead of trusting it; FK violation would silently drop every capture). Source preserved in device_metadata. - API: /api/stats/summary?range=7|30|90|year (totals, zero-filled daily arrays, streaks, most-active day/weekday, top books, library breakdown — one call), /api/stats/this-week (widget: rolling 7d vs previous + streak), /api/stats/books/:id (lifetime + recent sessions). All bucketed in the user's timezone; finished ≡ any push ≥0.995; visible-library lens enforced at source. Legacy /api/analytics/* kept untouched. Range windows anchor end-of-day to the START of today (blasting the clock time into tomorrow resolved 'today' a calendar day late — found by the streak test). - /stats page (sidebar Stats between Progress and Devices, previously /analytics was an orphan): range selector, five stat cards, two Chart.js v4 charts (daily minutes bar, pages/day line — chunked via dynamic import so main.js stays untouched), per-library breakdown, most-read list, empty-state explainer. /analytics now 301s to /stats. The orphaned analytics.templ + analytics.ts removed (their containers and the page JSON never matched; the page was astub). - Dashboard 'This Week' widget (server-rendered, suppressed on service failure) and the book detail 'Your Reading Stats' card (lifetime totals of the viewer's own sessions) complete the tiers: per-user default, per-library breakdown inline, per-book on the page, per-system/admin aggregate parked as before. Tests: statistics_test.go (capture semantics at the chokepoint, tz/day buckets, zero-fill, streak math, finished detection, range validation, auth gate, widget, per-book lifetime). Full suite PASS.
This commit is contained in:
1 parent
8de8883acf
commit
6457fbaf72
28 files changed
+3061
-814
No files matched your search
@@ -155,6 +155,8 @@ func main() {
|
|||||||
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
|
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
|
||||||
conflictHandler := handlers.NewConflictHandler(queries, connManager)
|
conflictHandler := handlers.NewConflictHandler(queries, connManager)
|
||||||
analyticsHandler := handlers.NewAnalyticsHandler(queries)
|
analyticsHandler := handlers.NewAnalyticsHandler(queries)
|
||||||
|
statisticsService := services.NewStatisticsService(queries)
|
||||||
|
statsHandler := handlers.NewStatsHandler(statisticsService)
|
||||||
queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
|
queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
|
||||||
|
|
||||||
conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub")
|
conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub")
|
||||||
@@ -226,6 +228,8 @@ func main() {
|
|||||||
WSHandler: wsHandler,
|
WSHandler: wsHandler,
|
||||||
ConflictHandler: conflictHandler,
|
ConflictHandler: conflictHandler,
|
||||||
AnalyticsHandler: analyticsHandler,
|
AnalyticsHandler: analyticsHandler,
|
||||||
|
StatsHandler: statsHandler,
|
||||||
|
StatisticsService: statisticsService,
|
||||||
QueueHandler: queueHandler,
|
QueueHandler: queueHandler,
|
||||||
CollectionHandler: collectionHandler,
|
CollectionHandler: collectionHandler,
|
||||||
FiltersHandler: filtersHandler,
|
FiltersHandler: filtersHandler,
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reading-statistics coverage: session CAPTURE at the sync chokepoint,
|
||||||
|
// the summary aggregation (tz buckets, streaks, finished detection),
|
||||||
|
// range validation, the widget, and the per-book card.
|
||||||
|
//
|
||||||
|
// All seeded instants anchor to USER-TZ midnights (the test user is UTC)
|
||||||
|
// so day buckets are deterministic regardless of when the suite runs.
|
||||||
|
|
||||||
|
func pgUser(id uuid.UUID) pgtype.UUID { return pgtype.UUID{Bytes: [16]byte(id), Valid: true} }
|
||||||
|
func pgFloat(v float64) pgtype.Float8 { return pgtype.Float8{Float64: v, Valid: true} }
|
||||||
|
func pgInt4(v int32) pgtype.Int4 { return pgtype.Int4{Int32: v, Valid: true} }
|
||||||
|
func pgTS(v time.Time) pgtype.Timestamptz {
|
||||||
|
return pgtype.Timestamptz{Time: v, Valid: !v.IsZero()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// middayDaysAgo returns noon UTC N calendar days before today — a
|
||||||
|
// bucket-stable instant for any wall-clock time the suite runs at.
|
||||||
|
func middayDaysAgo(daysAgo int) time.Time {
|
||||||
|
midnight := time.Now().UTC()
|
||||||
|
midnight = time.Date(midnight.Year(), midnight.Month(), midnight.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
return midnight.AddDate(0, 0, -daysAgo).Add(12 * time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
func statsLogin(t *testing.T, setup *TestServerSetup, path string) (*http.Response, []byte) {
|
||||||
|
t.Helper()
|
||||||
|
req, _ := http.NewRequest("GET", setup.Server.URL+path, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
t.Setenv("STATS_LAST_BODY_PATH", path) // no-op; keeps body-close ordering explicit
|
||||||
|
}()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return resp, body
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadingStatsCapture(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
userID := getTestUserID(t, setup.DB)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
bookID := createTestMediaItemID(t, setup)
|
||||||
|
|
||||||
|
push := func(body map[string]interface{}) int {
|
||||||
|
payload, _ := json.Marshal(body)
|
||||||
|
req, _ := http.NewRequest("PUT", setup.Server.URL+"/api/media-items/"+bookID+"/progress", bytes.NewBuffer(payload))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
return resp.StatusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, push(map[string]interface{}{
|
||||||
|
"percentage": 0.10, "current_page": 10, "total_pages": 100,
|
||||||
|
}))
|
||||||
|
|
||||||
|
time.Sleep(1100 * time.Millisecond) // the second push derives a ~1s span
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, push(map[string]interface{}{
|
||||||
|
"percentage": 0.15, "current_page": 15, "total_pages": 100,
|
||||||
|
}))
|
||||||
|
|
||||||
|
rows, err := setup.DB.GetBookReadingSessions(ctx, database.GetBookReadingSessionsParams{
|
||||||
|
UserID: pgUser(userID),
|
||||||
|
MediaItemID: uuidToPGType(uuid.MustParse(bookID)),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, rows, 2, "each push records one session row")
|
||||||
|
|
||||||
|
first := rows[0]
|
||||||
|
assert.Equal(t, int32(0), first.TimeSpentSeconds.Int32, "first contact: marker with zero time")
|
||||||
|
assert.InEpsilon(t, 0.10, first.ProgressPercentage.Float64, 0.001)
|
||||||
|
|
||||||
|
second := rows[1]
|
||||||
|
assert.GreaterOrEqual(t, second.TimeSpentSeconds.Int32, int32(1), "the ~1.1s gap is attributed")
|
||||||
|
assert.LessOrEqual(t, second.TimeSpentSeconds.Int32, int32(10), "and bounded by the slept interval")
|
||||||
|
require.True(t, second.PagesRead.Valid, "page info was carried on both pushes")
|
||||||
|
assert.Equal(t, int32(5), second.PagesRead.Int32, "pages: 15 − 10")
|
||||||
|
|
||||||
|
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||||
|
MediaItemID: uuidToPGType(uuid.MustParse(bookID)),
|
||||||
|
UserID: pgUser(userID),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.InEpsilon(t, 0.15, progress.Percentage.Float64, 0.001, "capture never disturbs stored position")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadingStatsSummary(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
userID := getTestUserID(t, setup.DB)
|
||||||
|
ctx := context.Context(context.Background())
|
||||||
|
|
||||||
|
libIDStr := createLibraryDirect(t, setup.DB, userID, fmt.Sprintf("Test Stats Library %d", time.Now().UnixNano()), "ebooks")
|
||||||
|
libUUID := uuid.MustParse(libIDStr)
|
||||||
|
|
||||||
|
var finishedBookID, offRangeBookID string
|
||||||
|
insertSession := func(daysAgo int, minutes int32, pages int32, pct float64, title string, reuseMediaID ...string) string {
|
||||||
|
var media database.MediaItems
|
||||||
|
var err error
|
||||||
|
if len(reuseMediaID) > 0 && reuseMediaID[0] != "" {
|
||||||
|
media, err = setup.DB.GetMediaItem(ctx, uuidToPGType(uuid.MustParse(reuseMediaID[0])))
|
||||||
|
require.NoError(t, err, "reuse seeded media item")
|
||||||
|
} else {
|
||||||
|
media, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||||
|
LibraryID: pgtype.UUID{Bytes: [16]byte(libUUID), Valid: true},
|
||||||
|
Title: title,
|
||||||
|
FilePath: fmt.Sprintf("/tmp/stats-%s-%d.epub", title, time.Now().UnixNano()),
|
||||||
|
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
||||||
|
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "seed media item")
|
||||||
|
}
|
||||||
|
|
||||||
|
pushAt := middayDaysAgo(daysAgo)
|
||||||
|
_, err = setup.DB.CreateReadingHistory(ctx, database.CreateReadingHistoryParams{
|
||||||
|
UserID: pgUser(userID),
|
||||||
|
MediaItemID: media.ID,
|
||||||
|
ProgressPercentage: pgFloat(pct),
|
||||||
|
ReadingSessionStart: pgTS(pushAt.Add(-time.Duration(minutes) * time.Minute)),
|
||||||
|
ReadingSessionEnd: pgTS(pushAt),
|
||||||
|
PagesRead: pgInt4(pages),
|
||||||
|
TimeSpentSeconds: pgInt4(minutes * 60),
|
||||||
|
DeviceMetadata: []byte(`{"source":"test"}`),
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "seed session")
|
||||||
|
|
||||||
|
id, _ := uuid.FromBytes(media.ID.Bytes[0:16])
|
||||||
|
return id.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
finishedBookID = insertSession(0, 30, 12, 1.0, "Stats Finished Book")
|
||||||
|
insertSession(1, 45, 20, 0.5, "Stats Streak Book")
|
||||||
|
insertSession(2, 60, 25, 0.42, "Stats Back Book")
|
||||||
|
insertSession(2, 20, 8, 0.9, "Stats Finished Book (same book, second session)", finishedBookID)
|
||||||
|
offRangeBookID = insertSession(400, 120, 40, 0.2, "Stats OffRange Book")
|
||||||
|
|
||||||
|
// Summary -----------------------------------------------------------
|
||||||
|
resp, body := statsLogin(t, setup, "/api/stats/summary?range=7")
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var summary struct {
|
||||||
|
Range string `json:"range"`
|
||||||
|
TotalMinutes int `json:"total_minutes"`
|
||||||
|
TotalPages int `json:"total_pages"`
|
||||||
|
SessionCount int `json:"session_count"`
|
||||||
|
BooksActive int `json:"books_with_activity"`
|
||||||
|
BooksFinished int `json:"books_finished"`
|
||||||
|
CompletionRate float64 `json:"completion_rate"`
|
||||||
|
Daily []struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Minutes int `json:"minutes"`
|
||||||
|
Pages int `json:"pages"`
|
||||||
|
} `json:"daily"`
|
||||||
|
Streak struct {
|
||||||
|
Current int `json:"current"`
|
||||||
|
Longest int `json:"longest"`
|
||||||
|
ActiveToday bool `json:"active_today"`
|
||||||
|
} `json:"streak"`
|
||||||
|
TopBooks []struct {
|
||||||
|
MediaItemID string `json:"media_item_id"`
|
||||||
|
Minutes int `json:"minutes"`
|
||||||
|
} `json:"top_books"`
|
||||||
|
LibraryBreakdown []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Minutes int `json:"minutes"`
|
||||||
|
} `json:"library_breakdown"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(body, &summary))
|
||||||
|
|
||||||
|
assert.Equal(t, "7", summary.Range)
|
||||||
|
require.Len(t, summary.Daily, 7, "zero-filled inclusive calendar")
|
||||||
|
assert.Equal(t, 155, summary.TotalMinutes, "30+45+60+20 (the 400-day-day session is out of range)")
|
||||||
|
assert.Equal(t, 65, summary.TotalPages, "12+20+25+8")
|
||||||
|
assert.Equal(t, 4, summary.SessionCount)
|
||||||
|
assert.Equal(t, 3, summary.BooksActive, "distinct books with sessions")
|
||||||
|
assert.Equal(t, 1, summary.BooksFinished, "finished := any push ≥ 99.5%")
|
||||||
|
assert.InEpsilon(t, float64(1)/float64(3), summary.CompletionRate, 0.001)
|
||||||
|
|
||||||
|
// Streaks: today, −1d, −2d are consecutive; the lone −400d day isn't.
|
||||||
|
t.Logf("STREAK DUMP: %+v (values %#v)", summary.Streak, summary.Streak)
|
||||||
|
t.Logf("DAILY DUMP: %+v", summary.Daily)
|
||||||
|
assert.Equal(t, 3, summary.Streak.Current)
|
||||||
|
assert.Equal(t, 3, summary.Streak.Longest)
|
||||||
|
assert.True(t, summary.Streak.ActiveToday)
|
||||||
|
|
||||||
|
// Top books: minutes-ordered — the seeded 60-minute back book tops.
|
||||||
|
require.NotEmpty(t, summary.TopBooks)
|
||||||
|
assert.Equal(t, 60, summary.TopBooks[0].Minutes)
|
||||||
|
for i := 1; i < len(summary.TopBooks); i++ {
|
||||||
|
assert.GreaterOrEqual(t, summary.TopBooks[i-1].Minutes, summary.TopBooks[i].Minutes, "top books sorted by minutes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Library breakdown carries the seeded library.
|
||||||
|
found := false
|
||||||
|
for _, lib := range summary.LibraryBreakdown {
|
||||||
|
if lib.Minutes >= 155 {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, found, "library breakdown aggregates the seeded library")
|
||||||
|
|
||||||
|
// Range validation ---------------------------------------------------
|
||||||
|
badResp, badBody := statsLogin(t, setup, "/api/stats/summary?range=fortnight")
|
||||||
|
assert.Equal(t, http.StatusBadRequest, badResp.StatusCode)
|
||||||
|
assert.Contains(t, string(badBody), "invalid range")
|
||||||
|
|
||||||
|
// Auth gate ----------------------------------------------------------
|
||||||
|
anonym, err := http.Get(setup.Server.URL + "/api/stats/summary")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, anonym.StatusCode)
|
||||||
|
_ = anonym.Body.Close()
|
||||||
|
|
||||||
|
// Widget -------------------------------------------------------------
|
||||||
|
resp, body = statsLogin(t, setup, "/api/stats/this-week")
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var week struct {
|
||||||
|
WeekMinutes int `json:"week_minutes"`
|
||||||
|
WeekBooks int `json:"week_books"`
|
||||||
|
ActiveToday bool `json:"active_today"`
|
||||||
|
CurrentStreak int `json:"current_streak"`
|
||||||
|
PrevMinutes int `json:"prev_week_minutes"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(body, &week))
|
||||||
|
assert.Equal(t, 155, week.WeekMinutes)
|
||||||
|
assert.Equal(t, 3, week.WeekBooks)
|
||||||
|
assert.True(t, week.ActiveToday)
|
||||||
|
assert.Equal(t, 3, week.CurrentStreak)
|
||||||
|
assert.Equal(t, 0, week.PrevMinutes)
|
||||||
|
|
||||||
|
// Per-book card (lifetime, so the 400-day book still totals) -----------
|
||||||
|
resp, body = statsLogin(t, setup, "/api/stats/books/"+offRangeBookID)
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var bookStats struct {
|
||||||
|
TotalMinutes int `json:"total_minutes"`
|
||||||
|
SessionCount int `json:"session_count"`
|
||||||
|
MaxProgress float64 `json:"max_progress"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(body, &bookStats))
|
||||||
|
assert.Equal(t, 120, bookStats.TotalMinutes)
|
||||||
|
assert.Equal(t, 1, bookStats.SessionCount)
|
||||||
|
assert.InEpsilon(t, 0.2, bookStats.MaxProgress, 0.001)
|
||||||
|
|
||||||
|
_ = finishedBookID
|
||||||
|
}
|
||||||
@@ -507,6 +507,8 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
filtersHandler := handlers.NewFiltersHandler(queries)
|
filtersHandler := handlers.NewFiltersHandler(queries)
|
||||||
dashboardService := services.NewDashboardService(queries)
|
dashboardService := services.NewDashboardService(queries)
|
||||||
dashboardHandler := handlers.NewDashboardHandler(queries)
|
dashboardHandler := handlers.NewDashboardHandler(queries)
|
||||||
|
statisticsService := services.NewStatisticsService(queries)
|
||||||
|
statsHandler := handlers.NewStatsHandler(statisticsService)
|
||||||
seriesHandler := handlers.NewSeriesHandler(queries)
|
seriesHandler := handlers.NewSeriesHandler(queries)
|
||||||
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
||||||
mediaHandler.SetProgressService(progressService)
|
mediaHandler.SetProgressService(progressService)
|
||||||
@@ -553,6 +555,8 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
WSHandler: wsHandler,
|
WSHandler: wsHandler,
|
||||||
ConflictHandler: conflictHandler,
|
ConflictHandler: conflictHandler,
|
||||||
AnalyticsHandler: analyticsHandler,
|
AnalyticsHandler: analyticsHandler,
|
||||||
|
StatsHandler: statsHandler,
|
||||||
|
StatisticsService: statisticsService,
|
||||||
QueueHandler: queueHandler,
|
QueueHandler: queueHandler,
|
||||||
SystemSettingsHandler: systemSettingsHandler,
|
SystemSettingsHandler: systemSettingsHandler,
|
||||||
ProcessingIssuesHandler: processingIssuesHandler,
|
ProcessingIssuesHandler: processingIssuesHandler,
|
||||||
|
|||||||
@@ -153,6 +153,10 @@ type Querier interface {
|
|||||||
DeleteUnlinkedBook(ctx context.Context, id pgtype.UUID) error
|
DeleteUnlinkedBook(ctx context.Context, id pgtype.UUID) error
|
||||||
DeleteUser(ctx context.Context, id pgtype.UUID) error
|
DeleteUser(ctx context.Context, id pgtype.UUID) error
|
||||||
DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) error
|
DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) error
|
||||||
|
// Device existence probe for capture: the web/app progress paths
|
||||||
|
// historically stuff the user id into DeviceID (it only fed a broadcast
|
||||||
|
// label before stats); a capture row must only reference a REAL device.
|
||||||
|
DeviceExistsForUser(ctx context.Context, arg DeviceExistsForUserParams) (bool, error)
|
||||||
// Find content-duplicate groups (same library + SHA-256, more than one row)
|
// Find content-duplicate groups (same library + SHA-256, more than one row)
|
||||||
FindHashConflictGroups(ctx context.Context) ([]FindHashConflictGroupsRow, error)
|
FindHashConflictGroups(ctx context.Context) ([]FindHashConflictGroupsRow, error)
|
||||||
GenerateKoboEntitlementId(ctx context.Context) (interface{}, error)
|
GenerateKoboEntitlementId(ctx context.Context) (interface{}, error)
|
||||||
@@ -165,6 +169,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)
|
||||||
|
// Lifetime session rows for ONE book (the book-detail stats card).
|
||||||
|
GetBookReadingSessions(ctx context.Context, arg GetBookReadingSessionsParams) ([]GetBookReadingSessionsRow, error)
|
||||||
GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error)
|
GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error)
|
||||||
// Get collection
|
// Get collection
|
||||||
GetCollection(ctx context.Context, id pgtype.UUID) (Collections, error)
|
GetCollection(ctx context.Context, id pgtype.UUID) (Collections, error)
|
||||||
@@ -338,6 +344,8 @@ type Querier interface {
|
|||||||
GetSystemSettingFull(ctx context.Context, settingKey string) (SystemSettings, error)
|
GetSystemSettingFull(ctx context.Context, settingKey string) (SystemSettings, error)
|
||||||
GetSystemTimezone(ctx context.Context) (string, error)
|
GetSystemTimezone(ctx context.Context) (string, error)
|
||||||
GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTombstonedAnnotationsForBookParams) ([]GetTombstonedAnnotationsForBookRow, error)
|
GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTombstonedAnnotationsForBookParams) ([]GetTombstonedAnnotationsForBookRow, error)
|
||||||
|
// Most-read books for a range (minutes), for the top list.
|
||||||
|
GetTopBooksByMinutes(ctx context.Context, arg GetTopBooksByMinutesParams) ([]GetTopBooksByMinutesRow, error)
|
||||||
// Get universal progress for a book
|
// Get universal progress for a book
|
||||||
GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error)
|
GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error)
|
||||||
// Get unlinked book by ContentId
|
// Get unlinked book by ContentId
|
||||||
@@ -354,12 +362,27 @@ type Querier interface {
|
|||||||
// Get user device usage statistics
|
// Get user device usage statistics
|
||||||
GetUserDeviceUsage(ctx context.Context, userID pgtype.UUID) ([]GetUserDeviceUsageRow, error)
|
GetUserDeviceUsage(ctx context.Context, userID pgtype.UUID) ([]GetUserDeviceUsageRow, error)
|
||||||
GetUserForLogin(ctx context.Context, email string) (GetUserForLoginRow, error)
|
GetUserForLogin(ctx context.Context, email string) (GetUserForLoginRow, error)
|
||||||
|
// Per-library aggregation for the same range.
|
||||||
|
GetUserLibraryBreakdown(ctx context.Context, arg GetUserLibraryBreakdownParams) ([]GetUserLibraryBreakdownRow, error)
|
||||||
GetUserMediaItemsForSync(ctx context.Context, userID pgtype.UUID) ([]GetUserMediaItemsForSyncRow, error)
|
GetUserMediaItemsForSync(ctx context.Context, userID pgtype.UUID) ([]GetUserMediaItemsForSyncRow, error)
|
||||||
GetUserPasswordHash(ctx context.Context, id pgtype.UUID) (string, error)
|
GetUserPasswordHash(ctx context.Context, id pgtype.UUID) (string, error)
|
||||||
GetUserProgressForBooks(ctx context.Context, arg GetUserProgressForBooksParams) ([]GetUserProgressForBooksRow, error)
|
GetUserProgressForBooks(ctx context.Context, arg GetUserProgressForBooksParams) ([]GetUserProgressForBooksRow, error)
|
||||||
|
// Distinct ACTIVE days (any captured session) bucketed in the user's
|
||||||
|
// timezone — the streak calendar. Scoped to a long look-back so the
|
||||||
|
// "longest streak" and a current streak longer than the selected range
|
||||||
|
// are both real.
|
||||||
|
GetUserReadingActiveDays(ctx context.Context, arg GetUserReadingActiveDaysParams) ([]string, error)
|
||||||
// Analytics queries
|
// Analytics queries
|
||||||
// Get user reading history for analytics
|
// Get user reading history for analytics
|
||||||
GetUserReadingHistory(ctx context.Context, arg GetUserReadingHistoryParams) ([]GetUserReadingHistoryRow, error)
|
GetUserReadingHistory(ctx context.Context, arg GetUserReadingHistoryParams) ([]GetUserReadingHistoryRow, error)
|
||||||
|
// STATISTICS (reading sessions) — time spent, pages, streaks, breakdowns.
|
||||||
|
// Every stats query scopes to the requesting user's VISIBLE libraries
|
||||||
|
// (family lens at the source) and single scan of reading_history.
|
||||||
|
// Full session rows for a range (summary math runs in Go: tz buckets,
|
||||||
|
// streaks, finished-book detection). The bucket/range key is the session
|
||||||
|
// END instant (the push time; NULL falls back to created_at) — the same
|
||||||
|
// value the capture stamps every write with.
|
||||||
|
GetUserReadingSessionsRange(ctx context.Context, arg GetUserReadingSessionsRangeParams) ([]GetUserReadingSessionsRangeRow, error)
|
||||||
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
|
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
|
||||||
GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error)
|
GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error)
|
||||||
// Smart ("dynamic") collections: rules are evaluated LIVE at render, so
|
// Smart ("dynamic") collections: rules are evaluated LIVE at render, so
|
||||||
|
|||||||
@@ -2389,6 +2389,28 @@ func (q *Queries) DeleteUserSystemCollection(ctx context.Context, arg DeleteUser
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DeviceExistsForUser = `-- name: DeviceExistsForUser :one
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1 FROM devices
|
||||||
|
WHERE id = $1 AND user_id = $2
|
||||||
|
) AS has_device
|
||||||
|
`
|
||||||
|
|
||||||
|
type DeviceExistsForUserParams struct {
|
||||||
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
||||||
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device existence probe for capture: the web/app progress paths
|
||||||
|
// historically stuff the user id into DeviceID (it only fed a broadcast
|
||||||
|
// label before stats); a capture row must only reference a REAL device.
|
||||||
|
func (q *Queries) DeviceExistsForUser(ctx context.Context, arg DeviceExistsForUserParams) (bool, error) {
|
||||||
|
row := q.db.QueryRow(ctx, DeviceExistsForUser, arg.DeviceID, arg.UserID)
|
||||||
|
var has_device bool
|
||||||
|
err := row.Scan(&has_device)
|
||||||
|
return has_device, err
|
||||||
|
}
|
||||||
|
|
||||||
const FindHashConflictGroups = `-- name: FindHashConflictGroups :many
|
const FindHashConflictGroups = `-- name: FindHashConflictGroups :many
|
||||||
SELECT library_id, file_sha256, COUNT(*) AS dup_count
|
SELECT library_id, file_sha256, COUNT(*) AS dup_count
|
||||||
FROM media_items
|
FROM media_items
|
||||||
@@ -2726,6 +2748,65 @@ func (q *Queries) GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsF
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GetBookReadingSessions = `-- name: GetBookReadingSessions :many
|
||||||
|
SELECT
|
||||||
|
rh.id, rh.device_id, rh.progress_percentage,
|
||||||
|
rh.reading_session_start, rh.reading_session_end,
|
||||||
|
rh.pages_read, rh.time_spent_seconds, rh.device_metadata, rh.created_at
|
||||||
|
FROM reading_history rh
|
||||||
|
WHERE rh.user_id = $1
|
||||||
|
AND rh.media_item_id = $2
|
||||||
|
ORDER BY rh.created_at ASC
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetBookReadingSessionsParams struct {
|
||||||
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||||
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetBookReadingSessionsRow struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
||||||
|
ProgressPercentage pgtype.Float8 `db:"progress_percentage" json:"progress_percentage"`
|
||||||
|
ReadingSessionStart pgtype.Timestamptz `db:"reading_session_start" json:"reading_session_start"`
|
||||||
|
ReadingSessionEnd pgtype.Timestamptz `db:"reading_session_end" json:"reading_session_end"`
|
||||||
|
PagesRead pgtype.Int4 `db:"pages_read" json:"pages_read"`
|
||||||
|
TimeSpentSeconds pgtype.Int4 `db:"time_spent_seconds" json:"time_spent_seconds"`
|
||||||
|
DeviceMetadata []byte `db:"device_metadata" json:"device_metadata"`
|
||||||
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lifetime session rows for ONE book (the book-detail stats card).
|
||||||
|
func (q *Queries) GetBookReadingSessions(ctx context.Context, arg GetBookReadingSessionsParams) ([]GetBookReadingSessionsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, GetBookReadingSessions, arg.UserID, arg.MediaItemID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []GetBookReadingSessionsRow{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetBookReadingSessionsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.DeviceID,
|
||||||
|
&i.ProgressPercentage,
|
||||||
|
&i.ReadingSessionStart,
|
||||||
|
&i.ReadingSessionEnd,
|
||||||
|
&i.PagesRead,
|
||||||
|
&i.TimeSpentSeconds,
|
||||||
|
&i.DeviceMetadata,
|
||||||
|
&i.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const GetBooksByTag = `-- name: GetBooksByTag :many
|
const GetBooksByTag = `-- name: GetBooksByTag :many
|
||||||
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items
|
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items
|
||||||
WHERE library_id = $1 AND tags @> ARRAY[$2::text]
|
WHERE library_id = $1 AND tags @> ARRAY[$2::text]
|
||||||
@@ -7674,6 +7755,85 @@ func (q *Queries) GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTo
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GetTopBooksByMinutes = `-- name: GetTopBooksByMinutes :many
|
||||||
|
SELECT
|
||||||
|
mi.id::uuid AS media_item_id,
|
||||||
|
mi.title,
|
||||||
|
mi.author,
|
||||||
|
mi.library_id,
|
||||||
|
mi.cover_image_path,
|
||||||
|
SUM(COALESCE(rh.time_spent_seconds, 0))::int AS total_seconds,
|
||||||
|
SUM(COALESCE(rh.pages_read, 0))::int AS total_pages,
|
||||||
|
COUNT(*)::int AS session_count,
|
||||||
|
MAX(rh.created_at) AS last_read
|
||||||
|
FROM reading_history rh
|
||||||
|
JOIN media_items mi ON mi.id = rh.media_item_id
|
||||||
|
WHERE rh.user_id = $1
|
||||||
|
AND COALESCE(rh.reading_session_end, rh.created_at) >= $2
|
||||||
|
AND COALESCE(rh.reading_session_end, rh.created_at) <= $3
|
||||||
|
AND ($4::uuid[] IS NULL OR mi.library_id = ANY($4::uuid[]))
|
||||||
|
GROUP BY mi.id, mi.title, mi.author, mi.library_id, mi.cover_image_path
|
||||||
|
ORDER BY total_seconds DESC
|
||||||
|
LIMIT $5
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetTopBooksByMinutesParams struct {
|
||||||
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||||
|
StartTime pgtype.Timestamptz `db:"start_time" json:"start_time"`
|
||||||
|
EndTime pgtype.Timestamptz `db:"end_time" json:"end_time"`
|
||||||
|
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
|
||||||
|
Limit int32 `db:"limit" json:"limit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetTopBooksByMinutesRow struct {
|
||||||
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||||
|
Title string `db:"title" json:"title"`
|
||||||
|
Author pgtype.Text `db:"author" json:"author"`
|
||||||
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||||
|
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
|
||||||
|
TotalSeconds int32 `db:"total_seconds" json:"total_seconds"`
|
||||||
|
TotalPages int32 `db:"total_pages" json:"total_pages"`
|
||||||
|
SessionCount int32 `db:"session_count" json:"session_count"`
|
||||||
|
LastRead interface{} `db:"last_read" json:"last_read"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Most-read books for a range (minutes), for the top list.
|
||||||
|
func (q *Queries) GetTopBooksByMinutes(ctx context.Context, arg GetTopBooksByMinutesParams) ([]GetTopBooksByMinutesRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, GetTopBooksByMinutes,
|
||||||
|
arg.UserID,
|
||||||
|
arg.StartTime,
|
||||||
|
arg.EndTime,
|
||||||
|
arg.VisibleLibraryIds,
|
||||||
|
arg.Limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []GetTopBooksByMinutesRow{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetTopBooksByMinutesRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.MediaItemID,
|
||||||
|
&i.Title,
|
||||||
|
&i.Author,
|
||||||
|
&i.LibraryID,
|
||||||
|
&i.CoverImagePath,
|
||||||
|
&i.TotalSeconds,
|
||||||
|
&i.TotalPages,
|
||||||
|
&i.SessionCount,
|
||||||
|
&i.LastRead,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const GetUniversalProgress = `-- name: GetUniversalProgress :one
|
const GetUniversalProgress = `-- name: GetUniversalProgress :one
|
||||||
SELECT
|
SELECT
|
||||||
rp.id,
|
rp.id,
|
||||||
@@ -8191,6 +8351,74 @@ func (q *Queries) GetUserForLogin(ctx context.Context, email string) (GetUserFor
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GetUserLibraryBreakdown = `-- name: GetUserLibraryBreakdown :many
|
||||||
|
SELECT
|
||||||
|
l.id::uuid AS library_id,
|
||||||
|
l.name,
|
||||||
|
COUNT(*)::int AS session_count,
|
||||||
|
SUM(COALESCE(rh.time_spent_seconds, 0))::int AS total_seconds,
|
||||||
|
SUM(COALESCE(rh.pages_read, 0))::int AS total_pages,
|
||||||
|
COUNT(DISTINCT rh.media_item_id)::int AS book_count
|
||||||
|
FROM reading_history rh
|
||||||
|
JOIN media_items mi ON mi.id = rh.media_item_id
|
||||||
|
JOIN libraries l ON l.id = mi.library_id
|
||||||
|
WHERE rh.user_id = $1
|
||||||
|
AND COALESCE(rh.reading_session_end, rh.created_at) >= $2
|
||||||
|
AND COALESCE(rh.reading_session_end, rh.created_at) <= $3
|
||||||
|
AND ($4::uuid[] IS NULL OR mi.library_id = ANY($4::uuid[]))
|
||||||
|
GROUP BY l.id, l.name
|
||||||
|
ORDER BY total_seconds DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetUserLibraryBreakdownParams struct {
|
||||||
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||||
|
StartTime pgtype.Timestamptz `db:"start_time" json:"start_time"`
|
||||||
|
EndTime pgtype.Timestamptz `db:"end_time" json:"end_time"`
|
||||||
|
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetUserLibraryBreakdownRow struct {
|
||||||
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||||
|
Name string `db:"name" json:"name"`
|
||||||
|
SessionCount int32 `db:"session_count" json:"session_count"`
|
||||||
|
TotalSeconds int32 `db:"total_seconds" json:"total_seconds"`
|
||||||
|
TotalPages int32 `db:"total_pages" json:"total_pages"`
|
||||||
|
BookCount int32 `db:"book_count" json:"book_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-library aggregation for the same range.
|
||||||
|
func (q *Queries) GetUserLibraryBreakdown(ctx context.Context, arg GetUserLibraryBreakdownParams) ([]GetUserLibraryBreakdownRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, GetUserLibraryBreakdown,
|
||||||
|
arg.UserID,
|
||||||
|
arg.StartTime,
|
||||||
|
arg.EndTime,
|
||||||
|
arg.VisibleLibraryIds,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []GetUserLibraryBreakdownRow{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetUserLibraryBreakdownRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.LibraryID,
|
||||||
|
&i.Name,
|
||||||
|
&i.SessionCount,
|
||||||
|
&i.TotalSeconds,
|
||||||
|
&i.TotalPages,
|
||||||
|
&i.BookCount,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const GetUserMediaItemsForSync = `-- name: GetUserMediaItemsForSync :many
|
const GetUserMediaItemsForSync = `-- name: GetUserMediaItemsForSync :many
|
||||||
SELECT
|
SELECT
|
||||||
mi.id,
|
mi.id,
|
||||||
@@ -8358,6 +8586,52 @@ func (q *Queries) GetUserProgressForBooks(ctx context.Context, arg GetUserProgre
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GetUserReadingActiveDays = `-- name: GetUserReadingActiveDays :many
|
||||||
|
SELECT DISTINCT (COALESCE(rh.reading_session_end, rh.created_at) AT TIME ZONE $1::text)::date::text AS day
|
||||||
|
FROM reading_history rh
|
||||||
|
JOIN media_items mi ON mi.id = rh.media_item_id
|
||||||
|
WHERE rh.user_id = $2
|
||||||
|
AND COALESCE(rh.reading_session_end, rh.created_at) >= $3
|
||||||
|
AND ($4::uuid[] IS NULL OR mi.library_id = ANY($4::uuid[]))
|
||||||
|
ORDER BY day ASC
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetUserReadingActiveDaysParams struct {
|
||||||
|
Tz string `db:"tz" json:"tz"`
|
||||||
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||||
|
StartTime pgtype.Timestamptz `db:"start_time" json:"start_time"`
|
||||||
|
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distinct ACTIVE days (any captured session) bucketed in the user's
|
||||||
|
// timezone — the streak calendar. Scoped to a long look-back so the
|
||||||
|
// "longest streak" and a current streak longer than the selected range
|
||||||
|
// are both real.
|
||||||
|
func (q *Queries) GetUserReadingActiveDays(ctx context.Context, arg GetUserReadingActiveDaysParams) ([]string, error) {
|
||||||
|
rows, err := q.db.Query(ctx, GetUserReadingActiveDays,
|
||||||
|
arg.Tz,
|
||||||
|
arg.UserID,
|
||||||
|
arg.StartTime,
|
||||||
|
arg.VisibleLibraryIds,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var day string
|
||||||
|
if err := rows.Scan(&day); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, day)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const GetUserReadingHistory = `-- name: GetUserReadingHistory :many
|
const GetUserReadingHistory = `-- name: GetUserReadingHistory :many
|
||||||
|
|
||||||
SELECT
|
SELECT
|
||||||
@@ -8447,6 +8721,93 @@ func (q *Queries) GetUserReadingHistory(ctx context.Context, arg GetUserReadingH
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GetUserReadingSessionsRange = `-- name: GetUserReadingSessionsRange :many
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
rh.id, rh.media_item_id, rh.device_id, rh.progress_percentage,
|
||||||
|
rh.reading_session_start, rh.reading_session_end,
|
||||||
|
rh.pages_read, rh.time_spent_seconds, rh.device_metadata, rh.created_at,
|
||||||
|
mi.title, mi.author, mi.library_id, mi.cover_image_path
|
||||||
|
FROM reading_history rh
|
||||||
|
JOIN media_items mi ON mi.id = rh.media_item_id
|
||||||
|
WHERE rh.user_id = $1
|
||||||
|
AND COALESCE(rh.reading_session_end, rh.created_at) >= $2
|
||||||
|
AND COALESCE(rh.reading_session_end, rh.created_at) <= $3
|
||||||
|
AND ($4::uuid[] IS NULL OR mi.library_id = ANY($4::uuid[]))
|
||||||
|
ORDER BY rh.created_at ASC
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetUserReadingSessionsRangeParams struct {
|
||||||
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||||
|
StartTime pgtype.Timestamptz `db:"start_time" json:"start_time"`
|
||||||
|
EndTime pgtype.Timestamptz `db:"end_time" json:"end_time"`
|
||||||
|
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetUserReadingSessionsRangeRow struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||||
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
||||||
|
ProgressPercentage pgtype.Float8 `db:"progress_percentage" json:"progress_percentage"`
|
||||||
|
ReadingSessionStart pgtype.Timestamptz `db:"reading_session_start" json:"reading_session_start"`
|
||||||
|
ReadingSessionEnd pgtype.Timestamptz `db:"reading_session_end" json:"reading_session_end"`
|
||||||
|
PagesRead pgtype.Int4 `db:"pages_read" json:"pages_read"`
|
||||||
|
TimeSpentSeconds pgtype.Int4 `db:"time_spent_seconds" json:"time_spent_seconds"`
|
||||||
|
DeviceMetadata []byte `db:"device_metadata" json:"device_metadata"`
|
||||||
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||||
|
Title string `db:"title" json:"title"`
|
||||||
|
Author pgtype.Text `db:"author" json:"author"`
|
||||||
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||||
|
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// STATISTICS (reading sessions) — time spent, pages, streaks, breakdowns.
|
||||||
|
// Every stats query scopes to the requesting user's VISIBLE libraries
|
||||||
|
// (family lens at the source) and single scan of reading_history.
|
||||||
|
// Full session rows for a range (summary math runs in Go: tz buckets,
|
||||||
|
// streaks, finished-book detection). The bucket/range key is the session
|
||||||
|
// END instant (the push time; NULL falls back to created_at) — the same
|
||||||
|
// value the capture stamps every write with.
|
||||||
|
func (q *Queries) GetUserReadingSessionsRange(ctx context.Context, arg GetUserReadingSessionsRangeParams) ([]GetUserReadingSessionsRangeRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, GetUserReadingSessionsRange,
|
||||||
|
arg.UserID,
|
||||||
|
arg.StartTime,
|
||||||
|
arg.EndTime,
|
||||||
|
arg.VisibleLibraryIds,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []GetUserReadingSessionsRangeRow{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetUserReadingSessionsRangeRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.MediaItemID,
|
||||||
|
&i.DeviceID,
|
||||||
|
&i.ProgressPercentage,
|
||||||
|
&i.ReadingSessionStart,
|
||||||
|
&i.ReadingSessionEnd,
|
||||||
|
&i.PagesRead,
|
||||||
|
&i.TimeSpentSeconds,
|
||||||
|
&i.DeviceMetadata,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.Title,
|
||||||
|
&i.Author,
|
||||||
|
&i.LibraryID,
|
||||||
|
&i.CoverImagePath,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const GetUserVisibleLibraries = `-- name: GetUserVisibleLibraries :many
|
const GetUserVisibleLibraries = `-- name: GetUserVisibleLibraries :many
|
||||||
SELECT l.id, l.name, l.description, l.library_type_id, l.created_by_admin_id, l.created_at, l.updated_at, lt.name as type_name, lt.description as type_description,
|
SELECT l.id, l.name, l.description, l.library_type_id, l.created_by_admin_id, l.created_at, l.updated_at, lt.name as type_name, lt.description as type_description,
|
||||||
COALESCE(lv.is_visible, true) as is_visible
|
COALESCE(lv.is_visible, true) as is_visible
|
||||||
|
|||||||
@@ -1467,6 +1467,103 @@ SELECT
|
|||||||
ORDER BY read_count DESC
|
ORDER BY read_count DESC
|
||||||
LIMIT $2;
|
LIMIT $2;
|
||||||
|
|
||||||
|
-- STATISTICS (reading sessions) — time spent, pages, streaks, breakdowns.
|
||||||
|
-- Every stats query scopes to the requesting user's VISIBLE libraries
|
||||||
|
-- (family lens at the source) and single scan of reading_history.
|
||||||
|
|
||||||
|
-- Full session rows for a range (summary math runs in Go: tz buckets,
|
||||||
|
-- streaks, finished-book detection). The bucket/range key is the session
|
||||||
|
-- END instant (the push time; NULL falls back to created_at) — the same
|
||||||
|
-- value the capture stamps every write with.
|
||||||
|
-- name: GetUserReadingSessionsRange :many
|
||||||
|
SELECT
|
||||||
|
rh.id, rh.media_item_id, rh.device_id, rh.progress_percentage,
|
||||||
|
rh.reading_session_start, rh.reading_session_end,
|
||||||
|
rh.pages_read, rh.time_spent_seconds, rh.device_metadata, rh.created_at,
|
||||||
|
mi.title, mi.author, mi.library_id, mi.cover_image_path
|
||||||
|
FROM reading_history rh
|
||||||
|
JOIN media_items mi ON mi.id = rh.media_item_id
|
||||||
|
WHERE rh.user_id = sqlc.arg('user_id')
|
||||||
|
AND COALESCE(rh.reading_session_end, rh.created_at) >= sqlc.arg('start_time')
|
||||||
|
AND COALESCE(rh.reading_session_end, rh.created_at) <= sqlc.arg('end_time')
|
||||||
|
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||||
|
ORDER BY rh.created_at ASC;
|
||||||
|
|
||||||
|
-- Per-library aggregation for the same range.
|
||||||
|
-- name: GetUserLibraryBreakdown :many
|
||||||
|
SELECT
|
||||||
|
l.id::uuid AS library_id,
|
||||||
|
l.name,
|
||||||
|
COUNT(*)::int AS session_count,
|
||||||
|
SUM(COALESCE(rh.time_spent_seconds, 0))::int AS total_seconds,
|
||||||
|
SUM(COALESCE(rh.pages_read, 0))::int AS total_pages,
|
||||||
|
COUNT(DISTINCT rh.media_item_id)::int AS book_count
|
||||||
|
FROM reading_history rh
|
||||||
|
JOIN media_items mi ON mi.id = rh.media_item_id
|
||||||
|
JOIN libraries l ON l.id = mi.library_id
|
||||||
|
WHERE rh.user_id = sqlc.arg('user_id')
|
||||||
|
AND COALESCE(rh.reading_session_end, rh.created_at) >= sqlc.arg('start_time')
|
||||||
|
AND COALESCE(rh.reading_session_end, rh.created_at) <= sqlc.arg('end_time')
|
||||||
|
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||||
|
GROUP BY l.id, l.name
|
||||||
|
ORDER BY total_seconds DESC;
|
||||||
|
|
||||||
|
-- Most-read books for a range (minutes), for the top list.
|
||||||
|
-- name: GetTopBooksByMinutes :many
|
||||||
|
SELECT
|
||||||
|
mi.id::uuid AS media_item_id,
|
||||||
|
mi.title,
|
||||||
|
mi.author,
|
||||||
|
mi.library_id,
|
||||||
|
mi.cover_image_path,
|
||||||
|
SUM(COALESCE(rh.time_spent_seconds, 0))::int AS total_seconds,
|
||||||
|
SUM(COALESCE(rh.pages_read, 0))::int AS total_pages,
|
||||||
|
COUNT(*)::int AS session_count,
|
||||||
|
MAX(rh.created_at) AS last_read
|
||||||
|
FROM reading_history rh
|
||||||
|
JOIN media_items mi ON mi.id = rh.media_item_id
|
||||||
|
WHERE rh.user_id = sqlc.arg('user_id')
|
||||||
|
AND COALESCE(rh.reading_session_end, rh.created_at) >= sqlc.arg('start_time')
|
||||||
|
AND COALESCE(rh.reading_session_end, rh.created_at) <= sqlc.arg('end_time')
|
||||||
|
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||||
|
GROUP BY mi.id, mi.title, mi.author, mi.library_id, mi.cover_image_path
|
||||||
|
ORDER BY total_seconds DESC
|
||||||
|
LIMIT sqlc.arg('limit');
|
||||||
|
|
||||||
|
-- Lifetime session rows for ONE book (the book-detail stats card).
|
||||||
|
-- name: GetBookReadingSessions :many
|
||||||
|
SELECT
|
||||||
|
rh.id, rh.device_id, rh.progress_percentage,
|
||||||
|
rh.reading_session_start, rh.reading_session_end,
|
||||||
|
rh.pages_read, rh.time_spent_seconds, rh.device_metadata, rh.created_at
|
||||||
|
FROM reading_history rh
|
||||||
|
WHERE rh.user_id = sqlc.arg('user_id')
|
||||||
|
AND rh.media_item_id = sqlc.arg('media_item_id')
|
||||||
|
ORDER BY rh.created_at ASC;
|
||||||
|
|
||||||
|
-- Distinct ACTIVE days (any captured session) bucketed in the user's
|
||||||
|
-- timezone — the streak calendar. Scoped to a long look-back so the
|
||||||
|
-- "longest streak" and a current streak longer than the selected range
|
||||||
|
-- are both real.
|
||||||
|
-- name: GetUserReadingActiveDays :many
|
||||||
|
SELECT DISTINCT (COALESCE(rh.reading_session_end, rh.created_at) AT TIME ZONE sqlc.arg('tz')::text)::date::text AS day
|
||||||
|
FROM reading_history rh
|
||||||
|
JOIN media_items mi ON mi.id = rh.media_item_id
|
||||||
|
WHERE rh.user_id = sqlc.arg('user_id')
|
||||||
|
AND COALESCE(rh.reading_session_end, rh.created_at) >= sqlc.arg('start_time')
|
||||||
|
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||||
|
ORDER BY day ASC;
|
||||||
|
|
||||||
|
-- Device existence probe for capture: the web/app progress paths
|
||||||
|
-- historically stuff the user id into DeviceID (it only fed a broadcast
|
||||||
|
-- label before stats); a capture row must only reference a REAL device.
|
||||||
|
-- name: DeviceExistsForUser :one
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1 FROM devices
|
||||||
|
WHERE id = sqlc.arg('device_id') AND user_id = sqlc.arg('user_id')
|
||||||
|
) AS has_device;
|
||||||
|
|
||||||
|
|
||||||
-- ============================================
|
-- ============================================
|
||||||
-- DEVICE MANAGEMENT & AUTH
|
-- DEVICE MANAGEMENT & AUTH
|
||||||
-- ============================================
|
-- ============================================
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ type MediaDetail struct {
|
|||||||
Collections []database.Collections `json:"collections"`
|
Collections []database.Collections `json:"collections"`
|
||||||
ReadingProgress *database.ReadingProgress `json:"reading_progress,omitempty"`
|
ReadingProgress *database.ReadingProgress `json:"reading_progress,omitempty"`
|
||||||
|
|
||||||
|
// The viewer's own reading-history card for this book (nil when the
|
||||||
|
// user has no sessions here); rendered as the "Your Reading Stats"
|
||||||
|
// section of the book page.
|
||||||
|
BookStats *BookStatsData `json:"book_stats,omitempty"`
|
||||||
|
|
||||||
// Conflict data (if exists)
|
// Conflict data (if exists)
|
||||||
ActiveConflict *ConflictDetailResponse `json:"active_conflict,omitempty"`
|
ActiveConflict *ConflictDetailResponse `json:"active_conflict,omitempty"`
|
||||||
|
|
||||||
@@ -27,3 +32,14 @@ type MediaDetail struct {
|
|||||||
// page's "recently deleted" list with restore/permanent-delete actions.
|
// page's "recently deleted" list with restore/permanent-delete actions.
|
||||||
DeletedAnnotations []DeletedAnnotationResponse `json:"deleted_annotations"`
|
DeletedAnnotations []DeletedAnnotationResponse `json:"deleted_annotations"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BookStatsData is the per-book reading-stats card payload.
|
||||||
|
type BookStatsData struct {
|
||||||
|
TotalMinutes int
|
||||||
|
TotalPages int
|
||||||
|
SessionCount int
|
||||||
|
MaxProgress float64 // 0..1
|
||||||
|
Finished bool
|
||||||
|
LastSession string
|
||||||
|
FirstSession string
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/services"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StatsHandler serves the reading-statistics API (/api/stats/*) backed
|
||||||
|
// by the StatisticsService. The server owns every conversion: clients
|
||||||
|
// receive user-timezone-rendered strings plus raw minute/page counts.
|
||||||
|
type StatsHandler struct {
|
||||||
|
statsService *services.StatisticsService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStatsHandler(statsService *services.StatisticsService) *StatsHandler {
|
||||||
|
return &StatsHandler{statsService: statsService}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustUser(c *echo.Context) (database.Users, bool) {
|
||||||
|
user, ok := c.Get("user").(database.Users)
|
||||||
|
return user, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary handles GET /api/stats/summary?range=7|30|90|year — the /stats
|
||||||
|
// page's complete payload: totals, daily arrays, streaks, most-active
|
||||||
|
// day, top books, per-library breakdown.
|
||||||
|
func (h *StatsHandler) Summary(c *echo.Context) error {
|
||||||
|
user, ok := mustUser(c)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
|
||||||
|
}
|
||||||
|
summary, err := h.statsService.UserSummary(c.Request().Context(),
|
||||||
|
pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
|
||||||
|
user.Timezone.String,
|
||||||
|
c.QueryParam("range"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error()[:7] == "invalid" {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||||
|
}
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ThisWeek handles GET /api/stats/this-week — the dashboard widget's
|
||||||
|
// data: rolling 7-day totals vs the previous seven days, plus streak.
|
||||||
|
func (h *StatsHandler) ThisWeek(c *echo.Context) error {
|
||||||
|
user, ok := mustUser(c)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
|
||||||
|
}
|
||||||
|
week, err := h.statsService.ThisWeek(c.Request().Context(),
|
||||||
|
pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
|
||||||
|
user.Timezone.String,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, week)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BookStats handles GET /api/stats/books/:id — the per-book card on the
|
||||||
|
// book detail page (lifetime totals + recent sessions).
|
||||||
|
func (h *StatsHandler) BookStats(c *echo.Context) error {
|
||||||
|
user, ok := mustUser(c)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
|
||||||
|
}
|
||||||
|
mediaItemID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "invalid media item id")
|
||||||
|
}
|
||||||
|
stats, err := h.statsService.BookStats(c.Request().Context(),
|
||||||
|
pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
|
||||||
|
mediaItemID,
|
||||||
|
user.Timezone.String,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, stats)
|
||||||
|
}
|
||||||
@@ -8,9 +8,18 @@ func registerAnalyticsRoutes(cfg *Config) {
|
|||||||
|
|
||||||
protected := e.Group("/api", jwtMiddleware)
|
protected := e.Group("/api", jwtMiddleware)
|
||||||
|
|
||||||
// Analytics routes
|
// Analytics routes (legacy shape; kept for existing API consumers —
|
||||||
|
// the web UI moved to /stats)
|
||||||
analytics := protected.Group("/analytics")
|
analytics := protected.Group("/analytics")
|
||||||
analytics.GET("/reading-stats", cfg.AnalyticsHandler.GetReadingStats)
|
analytics.GET("/reading-stats", cfg.AnalyticsHandler.GetReadingStats)
|
||||||
analytics.GET("/device-usage", cfg.AnalyticsHandler.GetDeviceUsage)
|
analytics.GET("/device-usage", cfg.AnalyticsHandler.GetDeviceUsage)
|
||||||
analytics.GET("/popular-books", cfg.AnalyticsHandler.GetPopularBooks)
|
analytics.GET("/popular-books", cfg.AnalyticsHandler.GetPopularBooks)
|
||||||
|
|
||||||
|
// Reading-statistics API (/stats page + dashboard widget + per-book
|
||||||
|
// card). Same session seed, service-backed aggregates, user-timezone
|
||||||
|
// bucketing. The legacy /analytics page redirects to /stats.
|
||||||
|
stats := protected.Group("/stats")
|
||||||
|
stats.GET("/summary", cfg.StatsHandler.Summary)
|
||||||
|
stats.GET("/this-week", cfg.StatsHandler.ThisWeek)
|
||||||
|
stats.GET("/books/:id", cfg.StatsHandler.BookStats)
|
||||||
}
|
}
|
||||||
@@ -473,8 +473,30 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
sectionData := handlers.MarkActiveConflictsSections(c.Request().Context(), cfg.Queries, userPgID, handlers.BuildSections(visibleSections, libraryID))
|
sectionData := handlers.MarkActiveConflictsSections(c.Request().Context(), cfg.Queries, userPgID, handlers.BuildSections(visibleSections, libraryID))
|
||||||
allSectionsData := handlers.BuildSections(allSections, libraryID)
|
allSectionsData := handlers.BuildSections(allSections, libraryID)
|
||||||
|
|
||||||
|
// “This week” widget: the user's rolling 7-day reading pulse, from
|
||||||
|
// the same session seed the /stats page uses. Suppressed when the
|
||||||
|
// service fails (dashboard always renders).
|
||||||
|
var week *templates.WeekStatsData
|
||||||
|
if cfg.StatisticsService != nil {
|
||||||
|
weekSvc, weekErr := cfg.StatisticsService.ThisWeek(c.Request().Context(),
|
||||||
|
pgtype.UUID{Bytes: userUUID, Valid: true}, user.Timezone)
|
||||||
|
if weekErr == nil {
|
||||||
|
week = &templates.WeekStatsData{
|
||||||
|
WeekMinutes: weekSvc.WeekMinutes,
|
||||||
|
WeekPages: weekSvc.WeekPages,
|
||||||
|
WeekSessions: weekSvc.WeekSessions,
|
||||||
|
WeekBooks: weekSvc.WeekBooks,
|
||||||
|
PrevMinutes: weekSvc.PrevMinutes,
|
||||||
|
ActiveToday: weekSvc.ActiveToday,
|
||||||
|
CurrentStreak: weekSvc.CurrentStreak,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("ThisWeek widget failed: %v", weekErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err = templates.Dashboard(user, sectionData, allSectionsData, libRes.Libraries, libraryID, prefs.HiddenCollections, limit, errorMsg).Render(c.Request().Context(), &buf)
|
err = templates.Dashboard(user, sectionData, allSectionsData, libRes.Libraries, libraryID, prefs.HiddenCollections, limit, week, errorMsg).Render(c.Request().Context(), &buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -948,14 +970,22 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
return c.HTML(http.StatusOK, buf.String())
|
return c.HTML(http.StatusOK, buf.String())
|
||||||
})
|
})
|
||||||
|
|
||||||
// Analytics page
|
// Analytics page → /stats (the rebuilt reading-statistics page;
|
||||||
|
// nobody bookmarks orphan pages twice).
|
||||||
frontendProtected.GET("/analytics", func(c *echo.Context) error {
|
frontendProtected.GET("/analytics", func(c *echo.Context) error {
|
||||||
|
return c.Redirect(http.StatusMovedPermanently, "/stats")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reading statistics page — per-user by default, with library
|
||||||
|
// breakdown; the per-book tier lives on the book detail page and the
|
||||||
|
// dashboard carries the "this week" widget.
|
||||||
|
frontendProtected.GET("/stats", func(c *echo.Context) error {
|
||||||
user, err := getTemplateUserWithTheme(c, cfg)
|
user, err := getTemplateUserWithTheme(c, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||||
}
|
}
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err = templates.Analytics(user).Render(c.Request().Context(), &buf)
|
err = templates.Stats(user).Render(c.Request().Context(), &buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1577,6 +1607,24 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
UserID: pgUserID,
|
UserID: pgUserID,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Per-book reading stats (the viewer's own history card) — nil
|
||||||
|
// keeps the card off the page for untouched books / other users.
|
||||||
|
var bookStats *handlers.BookStatsData
|
||||||
|
if cfg.StatisticsService != nil {
|
||||||
|
if svcStats, serr := cfg.StatisticsService.BookStats(c.Request().Context(),
|
||||||
|
pgUserID, mediaUUID, user.Timezone); serr == nil && svcStats.SessionCount > 0 {
|
||||||
|
bookStats = &handlers.BookStatsData{
|
||||||
|
TotalMinutes: svcStats.TotalMinutes,
|
||||||
|
TotalPages: svcStats.TotalPages,
|
||||||
|
SessionCount: svcStats.SessionCount,
|
||||||
|
MaxProgress: svcStats.MaxProgress,
|
||||||
|
Finished: svcStats.Finished,
|
||||||
|
FirstSession: svcStats.FirstSession,
|
||||||
|
LastSession: svcStats.LastSession,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Assemble response (no field duplication!)
|
// Assemble response (no field duplication!)
|
||||||
detail := handlers.MediaDetail{
|
detail := handlers.MediaDetail{
|
||||||
MediaItems: mediaItem, // Embedded - ALL fields available
|
MediaItems: mediaItem, // Embedded - ALL fields available
|
||||||
@@ -1584,6 +1632,7 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
Rating: rating,
|
Rating: rating,
|
||||||
Collections: collections,
|
Collections: collections,
|
||||||
ReadingProgress: progress,
|
ReadingProgress: progress,
|
||||||
|
BookStats: bookStats,
|
||||||
ActiveConflict: activeConflict,
|
ActiveConflict: activeConflict,
|
||||||
NotesCount: len(notes),
|
NotesCount: len(notes),
|
||||||
HighlightsCount: len(highlights),
|
HighlightsCount: len(highlights),
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ type Config struct {
|
|||||||
WSHandler *handlers.WSHandler
|
WSHandler *handlers.WSHandler
|
||||||
ConflictHandler *handlers.ConflictHandler
|
ConflictHandler *handlers.ConflictHandler
|
||||||
AnalyticsHandler *handlers.AnalyticsHandler
|
AnalyticsHandler *handlers.AnalyticsHandler
|
||||||
|
StatsHandler *handlers.StatsHandler
|
||||||
|
StatisticsService *services.StatisticsService
|
||||||
QueueHandler *handlers.QueueHandler
|
QueueHandler *handlers.QueueHandler
|
||||||
CollectionHandler *handlers.CollectionHandler
|
CollectionHandler *handlers.CollectionHandler
|
||||||
Worker *services.Worker
|
Worker *services.Worker
|
||||||
|
|||||||
@@ -340,9 +340,9 @@ func (s *CollectionService) AddRule(ctx context.Context, collectionID uuid.UUID,
|
|||||||
}
|
}
|
||||||
|
|
||||||
updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{
|
updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{
|
||||||
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||||
AutoAssignRules: rulesJSON,
|
AutoAssignRules: rulesJSON,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return database.Collections{}, Rule{}, fmt.Errorf("failed to save rules: %w", err)
|
return database.Collections{}, Rule{}, fmt.Errorf("failed to save rules: %w", err)
|
||||||
}
|
}
|
||||||
@@ -374,9 +374,9 @@ func (s *CollectionService) SetRuleEnabled(ctx context.Context, collectionID uui
|
|||||||
}
|
}
|
||||||
|
|
||||||
updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{
|
updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{
|
||||||
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||||
AutoAssignRules: rulesJSON,
|
AutoAssignRules: rulesJSON,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return database.Collections{}, fmt.Errorf("failed to save rules: %w", err)
|
return database.Collections{}, fmt.Errorf("failed to save rules: %w", err)
|
||||||
}
|
}
|
||||||
@@ -410,9 +410,9 @@ func (s *CollectionService) RemoveRule(ctx context.Context, collectionID uuid.UU
|
|||||||
}
|
}
|
||||||
|
|
||||||
updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{
|
updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{
|
||||||
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||||
AutoAssignRules: rulesJSON,
|
AutoAssignRules: rulesJSON,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return database.Collections{}, fmt.Errorf("failed to save rules: %w", err)
|
return database.Collections{}, fmt.Errorf("failed to save rules: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,619 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/utils"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StatisticsService aggregates reading_history sessions into the
|
||||||
|
// reading-stats surfaces: the /stats page, its per-book card, and the
|
||||||
|
// dashboard "this week" widget. All day bucketing happens in the
|
||||||
|
// REQUESTING user's timezone — every calendar concept here is per-user
|
||||||
|
// (server convention; "today" is the user's calendar, never UTC).
|
||||||
|
type StatisticsService struct {
|
||||||
|
db *database.Queries
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStatisticsService(db *database.Queries) *StatisticsService {
|
||||||
|
return &StatisticsService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishedThreshold mirrors what the readers push at the end of a book:
|
||||||
|
// 0.995 leaves room for a TOC/colophon page pushing just under 100%
|
||||||
|
// (percentage is a 0..1 float everywhere on the server).
|
||||||
|
const finishedThreshold = 0.995
|
||||||
|
|
||||||
|
// streakLookbackDays bounds the streak calendar (both streaks computed
|
||||||
|
// over that recent window — "in record").
|
||||||
|
const streakLookbackDays = 730
|
||||||
|
|
||||||
|
// topBooksLimit is the "most read" list length on the /stats page.
|
||||||
|
const topBooksLimit = 5
|
||||||
|
|
||||||
|
// StatsRange is an inclusive calendar range in a user's timezone.
|
||||||
|
type StatsRange struct {
|
||||||
|
Start time.Time // first instant of the first day
|
||||||
|
End time.Time // last nanosecond of the last day
|
||||||
|
Days int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveStatsRange maps the UI's selector ("7", "30", "90", "year") to
|
||||||
|
// an inclusive window ending today in the user's timezone.
|
||||||
|
func ResolveStatsRange(tz string, rangeKey string) (StatsRange, error) {
|
||||||
|
loc := utils.UserLocation(tz)
|
||||||
|
now := time.Now().In(loc)
|
||||||
|
// Anchor the end-of-day to the START of today (clock time stripped):
|
||||||
|
// today.AddDate(0,0,1) minus a nanosecond would carry this instant's
|
||||||
|
// clock into tomorrow and resolve "today" a calendar day late.
|
||||||
|
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||||||
|
end := todayStart.AddDate(0, 0, 1).Add(-time.Nanosecond)
|
||||||
|
|
||||||
|
var days int
|
||||||
|
switch rangeKey {
|
||||||
|
case "", "7":
|
||||||
|
days = 7
|
||||||
|
case "30":
|
||||||
|
days = 30
|
||||||
|
case "90":
|
||||||
|
days = 90
|
||||||
|
case "year":
|
||||||
|
// Calendar year to date (Jan 1 → today).
|
||||||
|
jan1 := time.Date(now.Year(), time.January, 1, 0, 0, 0, 0, loc)
|
||||||
|
days = int(now.Sub(jan1).Hours()/24.0) + 1
|
||||||
|
if days < 1 {
|
||||||
|
days = 1
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return StatsRange{}, fmt.Errorf("invalid range: %s (use 7, 30, 90 or year)", rangeKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
start := todayStart.AddDate(0, 0, -(days - 1))
|
||||||
|
return StatsRange{Start: start, End: end, Days: days}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type DailyStats struct {
|
||||||
|
Date string `json:"date"` // YYYY-MM-DD, in the user's timezone
|
||||||
|
Minutes int `json:"minutes"`
|
||||||
|
Pages int `json:"pages"`
|
||||||
|
Sessions int `json:"sessions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StreakStats struct {
|
||||||
|
// Current counts consecutive active days ending at today; when today
|
||||||
|
// has no session yet, the streak that ends yesterday still counts.
|
||||||
|
Current int `json:"current"`
|
||||||
|
Longest int `json:"longest"` // longest active streak in record
|
||||||
|
ActiveToday bool `json:"active_today"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TopBookStats struct {
|
||||||
|
MediaItemID string `json:"media_item_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
CoverImagePath string `json:"cover_image_path"`
|
||||||
|
Minutes int `json:"minutes"`
|
||||||
|
Pages int `json:"pages"`
|
||||||
|
Sessions int `json:"sessions"`
|
||||||
|
LastRead string `json:"last_read"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LibraryStats struct {
|
||||||
|
LibraryID string `json:"library_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Minutes int `json:"minutes"`
|
||||||
|
Pages int `json:"pages"`
|
||||||
|
Sessions int `json:"sessions"`
|
||||||
|
Books int `json:"books"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserSummaryStats struct {
|
||||||
|
Range string `json:"range"`
|
||||||
|
Days int `json:"days"`
|
||||||
|
StartDate string `json:"start_date"`
|
||||||
|
EndDate string `json:"end_date"`
|
||||||
|
TotalMinutes int `json:"total_minutes"`
|
||||||
|
TotalPages int `json:"total_pages"`
|
||||||
|
SessionCount int `json:"session_count"`
|
||||||
|
BooksActive int `json:"books_with_activity"`
|
||||||
|
BooksFinished int `json:"books_finished"`
|
||||||
|
CompletionRate float64 `json:"completion_rate"` // finished / books with activity (0 when none)
|
||||||
|
AvgSessionMin float64 `json:"average_session_minutes"`
|
||||||
|
Daily []DailyStats `json:"daily"` // zero-filled across the range
|
||||||
|
Streak StreakStats `json:"streak"`
|
||||||
|
MostActiveWeekday string `json:"most_active_weekday"` // by minutes over the range; "" when nothing
|
||||||
|
MostActiveDay string `json:"most_active_day"` // date with the most minutes
|
||||||
|
MostActiveMinutes int `json:"most_active_day_minutes"`
|
||||||
|
TopBooks []TopBookStats `json:"top_books"`
|
||||||
|
LibraryBreakdown []LibraryStats `json:"library_breakdown"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WeekSummaryStats struct {
|
||||||
|
WeekMinutes int `json:"week_minutes"`
|
||||||
|
WeekPages int `json:"week_pages"`
|
||||||
|
WeekSessions int `json:"week_sessions"`
|
||||||
|
WeekBooks int `json:"week_books"`
|
||||||
|
PrevMinutes int `json:"prev_week_minutes"`
|
||||||
|
PrevPages int `json:"prev_week_pages"`
|
||||||
|
PrevSessions int `json:"prev_week_sessions"`
|
||||||
|
ActiveToday bool `json:"active_today"`
|
||||||
|
CurrentStreak int `json:"current_streak"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BookStats struct {
|
||||||
|
MediaItemID string `json:"media_item_id"`
|
||||||
|
TotalMinutes int `json:"total_minutes"`
|
||||||
|
TotalPages int `json:"total_pages"`
|
||||||
|
SessionCount int `json:"session_count"`
|
||||||
|
MaxProgress float64 `json:"max_progress"`
|
||||||
|
Finished bool `json:"finished"`
|
||||||
|
FirstSession string `json:"first_session"`
|
||||||
|
LastSession string `json:"last_session"`
|
||||||
|
RecentSessions []SessionSummary `json:"recent_sessions"` // newest first, capped
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionSummary struct {
|
||||||
|
StartedAt string `json:"started_at"`
|
||||||
|
EndedAt string `json:"ended_at"`
|
||||||
|
Minutes int `json:"minutes"`
|
||||||
|
Pages int `json:"pages"` // 0 when unknown (no page info on the pushes)
|
||||||
|
Progress float64 `json:"progress"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// userVisibleLibraryIDs loads the family-lens set for this user (empty →
|
||||||
|
// no visible-library filter, i.e. everything is visible by default).
|
||||||
|
func (s *StatisticsService) userVisibleLibraryIDs(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) {
|
||||||
|
rows, err := s.db.GetUserVisibleLibraries(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to check library access: %w", err)
|
||||||
|
}
|
||||||
|
ids := make([]pgtype.UUID, 0, len(rows))
|
||||||
|
for _, lib := range rows {
|
||||||
|
if lib.ID.Valid {
|
||||||
|
ids = append(ids, lib.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// visibleOrNil maps an empty visible set to a NULL filter argument.
|
||||||
|
func visibleOrNil(ids []pgtype.UUID) []pgtype.UUID {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserSummary builds the /stats page payload for a range: totals,
|
||||||
|
// zero-filled daily arrays, streaks, most-active day, top books, and
|
||||||
|
// the per-library breakdown — one call for the page.
|
||||||
|
func (s *StatisticsService) UserSummary(ctx context.Context, userID pgtype.UUID, tz, rangeKey string) (UserSummaryStats, error) {
|
||||||
|
statsRange, err := ResolveStatsRange(tz, rangeKey)
|
||||||
|
if err != nil {
|
||||||
|
return UserSummaryStats{}, err
|
||||||
|
}
|
||||||
|
loc := utils.UserLocation(tz)
|
||||||
|
|
||||||
|
visibleIDs, err := s.userVisibleLibraryIDs(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return UserSummaryStats{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions, err := s.db.GetUserReadingSessionsRange(ctx, database.GetUserReadingSessionsRangeParams{
|
||||||
|
UserID: userID,
|
||||||
|
StartTime: pgtype.Timestamptz{Time: statsRange.Start, Valid: true},
|
||||||
|
EndTime: pgtype.Timestamptz{Time: statsRange.End, Valid: true},
|
||||||
|
VisibleLibraryIds: visibleOrNil(visibleIDs),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return UserSummaryStats{}, fmt.Errorf("failed to load reading sessions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := UserSummaryStats{
|
||||||
|
Range: rangeKey,
|
||||||
|
Days: statsRange.Days,
|
||||||
|
StartDate: statsRange.Start.Format("2006-01-02"),
|
||||||
|
EndDate: statsRange.End.Format("2006-01-02"),
|
||||||
|
Daily: make([]DailyStats, 0, statsRange.Days),
|
||||||
|
TopBooks: []TopBookStats{},
|
||||||
|
LibraryBreakdown: []LibraryStats{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zero-filled calendar (inclusive of today).
|
||||||
|
dailyMap := make(map[string]*DailyStats, statsRange.Days)
|
||||||
|
for i := 0; i < statsRange.Days; i++ {
|
||||||
|
day := statsRange.Start.AddDate(0, 0, i)
|
||||||
|
entry := DailyStats{Date: day.Format("2006-01-02")}
|
||||||
|
summary.Daily = append(summary.Daily, entry)
|
||||||
|
dailyMap[entry.Date] = &summary.Daily[len(summary.Daily)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
bookActivity := make(map[string]bool, len(sessions))
|
||||||
|
finishedSet := make(map[string]bool)
|
||||||
|
var totalSessionSeconds int64
|
||||||
|
weekdayMinutes := make(map[time.Weekday]int)
|
||||||
|
|
||||||
|
for _, sess := range sessions {
|
||||||
|
// The bucket key matches the SQL range filter: the session END
|
||||||
|
// instant (what the capture stamps; NULL falls back to
|
||||||
|
// created_at) — not the row's created_at.
|
||||||
|
ts := sess.CreatedAt
|
||||||
|
if sess.ReadingSessionEnd.Valid {
|
||||||
|
ts = sess.ReadingSessionEnd
|
||||||
|
}
|
||||||
|
key := dayKey(ts, loc)
|
||||||
|
entry, ok := dailyMap[key]
|
||||||
|
if !ok {
|
||||||
|
continue // defensive: outside the inclusive window
|
||||||
|
}
|
||||||
|
|
||||||
|
if sess.TimeSpentSeconds.Valid {
|
||||||
|
seconds := int64(sess.TimeSpentSeconds.Int32)
|
||||||
|
totalSessionSeconds += seconds
|
||||||
|
summary.TotalMinutes += int(seconds / 60)
|
||||||
|
entry.Minutes += int(seconds / 60)
|
||||||
|
}
|
||||||
|
if sess.PagesRead.Valid {
|
||||||
|
summary.TotalPages += int(sess.PagesRead.Int32)
|
||||||
|
entry.Pages += int(sess.PagesRead.Int32)
|
||||||
|
}
|
||||||
|
entry.Sessions++
|
||||||
|
summary.SessionCount++
|
||||||
|
|
||||||
|
mediaUUID, err := uuid.FromBytes(sess.MediaItemID.Bytes[0:16])
|
||||||
|
if err == nil {
|
||||||
|
key := mediaUUID.String()
|
||||||
|
bookActivity[key] = true
|
||||||
|
if sess.ProgressPercentage.Valid && sess.ProgressPercentage.Float64 >= finishedThreshold {
|
||||||
|
finishedSet[key] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
localTime := ts.Time.In(loc)
|
||||||
|
if sess.TimeSpentSeconds.Valid {
|
||||||
|
weekdayMinutes[localTime.Weekday()] += int(sess.TimeSpentSeconds.Int32) / 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.BooksActive = len(bookActivity)
|
||||||
|
summary.BooksFinished = len(finishedSet)
|
||||||
|
if summary.BooksActive > 0 {
|
||||||
|
summary.CompletionRate = float64(summary.BooksFinished) / float64(summary.BooksActive)
|
||||||
|
}
|
||||||
|
if summary.SessionCount > 0 {
|
||||||
|
summary.AvgSessionMin = float64(totalSessionSeconds) / float64(summary.SessionCount) / 60.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Most-active weekday by minutes (empty string before any signal).
|
||||||
|
dayNames := []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
|
||||||
|
bestWeekdayMinutes := -1
|
||||||
|
var bestWeekday time.Weekday
|
||||||
|
for wd, mins := range weekdayMinutes {
|
||||||
|
if mins > bestWeekdayMinutes {
|
||||||
|
bestWeekdayMinutes = mins
|
||||||
|
bestWeekday = wd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bestWeekdayMinutes > 0 {
|
||||||
|
summary.MostActiveWeekday = dayNames[bestWeekday]
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, d := range summary.Daily {
|
||||||
|
if d.Minutes > summary.MostActiveMinutes {
|
||||||
|
summary.MostActiveMinutes = d.Minutes
|
||||||
|
summary.MostActiveDay = d.Date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streaks: a dedicated lightweight calendar (~2y look-back) so a
|
||||||
|
// current streak longer than the selected range is still real.
|
||||||
|
activeDays, err := s.db.GetUserReadingActiveDays(ctx, database.GetUserReadingActiveDaysParams{
|
||||||
|
Tz: loc.String(),
|
||||||
|
UserID: userID,
|
||||||
|
StartTime: pgtype.Timestamptz{Time: statsRange.Start.AddDate(0, 0, -(streakLookbackDays - statsRange.Days)), Valid: true},
|
||||||
|
VisibleLibraryIds: visibleOrNil(visibleIDs),
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
summary.Streak = computeStreaks(activeDays, statsRange.End.Format("2006-01-02"))
|
||||||
|
} else {
|
||||||
|
log.Printf("stats: active-days query failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top books by minutes in the range.
|
||||||
|
topRows, err := s.db.GetTopBooksByMinutes(ctx, database.GetTopBooksByMinutesParams{
|
||||||
|
UserID: userID,
|
||||||
|
StartTime: pgtype.Timestamptz{Time: statsRange.Start, Valid: true},
|
||||||
|
EndTime: pgtype.Timestamptz{Time: statsRange.End, Valid: true},
|
||||||
|
VisibleLibraryIds: visibleOrNil(visibleIDs),
|
||||||
|
Limit: topBooksLimit,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
for _, row := range topRows {
|
||||||
|
entry := TopBookStats{
|
||||||
|
Title: row.Title,
|
||||||
|
Author: row.Author.String,
|
||||||
|
CoverImagePath: utils.ResolveMediaURL(row.LibraryID, row.CoverImagePath),
|
||||||
|
Minutes: int(row.TotalSeconds) / 60,
|
||||||
|
Pages: int(row.TotalPages),
|
||||||
|
Sessions: int(row.SessionCount),
|
||||||
|
}
|
||||||
|
if row.MediaItemID.Valid {
|
||||||
|
entry.MediaItemID = uuid.UUID(row.MediaItemID.Bytes).String()
|
||||||
|
}
|
||||||
|
if row.LastRead != nil {
|
||||||
|
if t, ok := row.LastRead.(time.Time); ok {
|
||||||
|
entry.LastRead = utils.FormatTimeInTimezone(t, tz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
summary.TopBooks = append(summary.TopBooks, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-library breakdown in the range.
|
||||||
|
libRows, err := s.db.GetUserLibraryBreakdown(ctx, database.GetUserLibraryBreakdownParams{
|
||||||
|
UserID: userID,
|
||||||
|
StartTime: pgtype.Timestamptz{Time: statsRange.Start, Valid: true},
|
||||||
|
EndTime: pgtype.Timestamptz{Time: statsRange.End, Valid: true},
|
||||||
|
VisibleLibraryIds: visibleOrNil(visibleIDs),
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
for _, row := range libRows {
|
||||||
|
entry := LibraryStats{
|
||||||
|
Name: row.Name,
|
||||||
|
Minutes: int(row.TotalSeconds) / 60,
|
||||||
|
Pages: int(row.TotalPages),
|
||||||
|
Sessions: int(row.SessionCount),
|
||||||
|
Books: int(row.BookCount),
|
||||||
|
}
|
||||||
|
if row.LibraryID.Valid {
|
||||||
|
entry.LibraryID = uuid.UUID(row.LibraryID.Bytes).String()
|
||||||
|
}
|
||||||
|
summary.LibraryBreakdown = append(summary.LibraryBreakdown, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ThisWeek builds the dashboard widget: the current rolling 7-day
|
||||||
|
// window (today inclusive) versus the previous seven days, plus the
|
||||||
|
// streak context. One scan over 14 days, split in Go.
|
||||||
|
func (s *StatisticsService) ThisWeek(ctx context.Context, userID pgtype.UUID, tz string) (WeekSummaryStats, error) {
|
||||||
|
statsRange, err := ResolveStatsRange(tz, "7")
|
||||||
|
if err != nil {
|
||||||
|
return WeekSummaryStats{}, err
|
||||||
|
}
|
||||||
|
loc := utils.UserLocation(tz)
|
||||||
|
|
||||||
|
visibleIDs, err := s.userVisibleLibraryIDs(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return WeekSummaryStats{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan the 14-day window: [start - 7 days, end].
|
||||||
|
scanStart := statsRange.Start.AddDate(0, 0, -7)
|
||||||
|
sessions, err := s.db.GetUserReadingSessionsRange(ctx, database.GetUserReadingSessionsRangeParams{
|
||||||
|
UserID: userID,
|
||||||
|
StartTime: pgtype.Timestamptz{Time: scanStart, Valid: true},
|
||||||
|
EndTime: pgtype.Timestamptz{Time: statsRange.End, Valid: true},
|
||||||
|
VisibleLibraryIds: visibleOrNil(visibleIDs),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return WeekSummaryStats{}, fmt.Errorf("failed to load reading sessions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
week := map[string]bool{statsRange.Start.Format("2006-01-02"): true}
|
||||||
|
for i := 0; i < statsRange.Days; i++ {
|
||||||
|
week[statsRange.Start.AddDate(0, 0, i).Format("2006-01-02")] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
out := WeekSummaryStats{
|
||||||
|
ActiveToday: week[statsRange.End.Format("2006-01-02")],
|
||||||
|
CurrentStreak: 0,
|
||||||
|
}
|
||||||
|
booksThis, booksPrev := make(map[string]bool), make(map[string]bool)
|
||||||
|
|
||||||
|
for _, sess := range sessions {
|
||||||
|
// Same bucket key as UserSummary: the session END instant.
|
||||||
|
ts := sess.CreatedAt
|
||||||
|
if sess.ReadingSessionEnd.Valid {
|
||||||
|
ts = sess.ReadingSessionEnd
|
||||||
|
}
|
||||||
|
key := dayKey(ts, loc)
|
||||||
|
minutes := 0
|
||||||
|
if sess.TimeSpentSeconds.Valid {
|
||||||
|
minutes = int(sess.TimeSpentSeconds.Int32) / 60
|
||||||
|
}
|
||||||
|
pages := 0
|
||||||
|
if sess.PagesRead.Valid {
|
||||||
|
pages = int(sess.PagesRead.Int32)
|
||||||
|
}
|
||||||
|
mediaUUID, err := uuid.FromBytes(sess.MediaItemID.Bytes[0:16])
|
||||||
|
var bookKey string
|
||||||
|
if err == nil {
|
||||||
|
bookKey = mediaUUID.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
if week[key] { // current 7 days
|
||||||
|
out.WeekMinutes += minutes
|
||||||
|
out.WeekPages += pages
|
||||||
|
out.WeekSessions++
|
||||||
|
if bookKey != "" {
|
||||||
|
booksThis[bookKey] = true
|
||||||
|
}
|
||||||
|
} else { // previous 7 days
|
||||||
|
out.PrevMinutes += minutes
|
||||||
|
out.PrevPages += pages
|
||||||
|
out.PrevSessions++
|
||||||
|
if bookKey != "" {
|
||||||
|
booksPrev[bookKey] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.WeekBooks = len(booksThis)
|
||||||
|
_ = booksPrev
|
||||||
|
|
||||||
|
activeDays, err := s.db.GetUserReadingActiveDays(ctx, database.GetUserReadingActiveDaysParams{
|
||||||
|
Tz: loc.String(),
|
||||||
|
UserID: userID,
|
||||||
|
StartTime: pgtype.Timestamptz{Time: statsRange.Start.AddDate(0, 0, -(streakLookbackDays - statsRange.Days)), Valid: true},
|
||||||
|
VisibleLibraryIds: visibleOrNil(visibleIDs),
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
out.CurrentStreak = computeStreaks(activeDays, statsRange.End.Format("2006-01-02")).Current
|
||||||
|
out.ActiveToday = computeStreaks(activeDays, statsRange.End.Format("2006-01-02")).ActiveToday
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BookStats is the per-book card: lifetime totals + the most recent
|
||||||
|
// sessions (bounded so the detail page never scans unbounded history).
|
||||||
|
func (s *StatisticsService) BookStats(ctx context.Context, userID pgtype.UUID, mediaItemID uuid.UUID, tz string) (BookStats, error) {
|
||||||
|
sessions, err := s.db.GetBookReadingSessions(ctx, database.GetBookReadingSessionsParams{
|
||||||
|
UserID: userID,
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return BookStats{}, fmt.Errorf("failed to load book sessions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := BookStats{
|
||||||
|
MediaItemID: mediaItemID.String(),
|
||||||
|
RecentSessions: []SessionSummary{},
|
||||||
|
}
|
||||||
|
for i, sess := range sessions {
|
||||||
|
if sess.TimeSpentSeconds.Valid {
|
||||||
|
out.TotalMinutes += int(sess.TimeSpentSeconds.Int32) / 60
|
||||||
|
}
|
||||||
|
if sess.PagesRead.Valid {
|
||||||
|
out.TotalPages += int(sess.PagesRead.Int32)
|
||||||
|
}
|
||||||
|
out.SessionCount++
|
||||||
|
if sess.ProgressPercentage.Valid && sess.ProgressPercentage.Float64 > out.MaxProgress {
|
||||||
|
out.MaxProgress = sess.ProgressPercentage.Float64
|
||||||
|
}
|
||||||
|
out.Finished = out.Finished || (sess.ProgressPercentage.Valid && sess.ProgressPercentage.Float64 >= finishedThreshold)
|
||||||
|
|
||||||
|
start := ""
|
||||||
|
if sess.ReadingSessionStart.Valid {
|
||||||
|
start = utils.FormatTimeInTimezone(sess.ReadingSessionStart.Time, tz)
|
||||||
|
}
|
||||||
|
end := ""
|
||||||
|
if sess.ReadingSessionEnd.Valid {
|
||||||
|
end = utils.FormatTimeInTimezone(sess.ReadingSessionEnd.Time, tz)
|
||||||
|
}
|
||||||
|
if sess.ReadingSessionEnd.Valid {
|
||||||
|
if i == 0 {
|
||||||
|
out.FirstSession = end
|
||||||
|
}
|
||||||
|
out.LastSession = end // ascending order → last assignment wins
|
||||||
|
}
|
||||||
|
|
||||||
|
if i >= len(sessions)-10 { // the most recent 10 sessions only
|
||||||
|
summary := SessionSummary{
|
||||||
|
StartedAt: start,
|
||||||
|
EndedAt: end,
|
||||||
|
Minutes: int(sess.TimeSpentSeconds.Int32) / 60,
|
||||||
|
Source: metadataSource(sess.DeviceMetadata),
|
||||||
|
}
|
||||||
|
if sess.PagesRead.Valid {
|
||||||
|
summary.Pages = int(sess.PagesRead.Int32)
|
||||||
|
}
|
||||||
|
if sess.ProgressPercentage.Valid {
|
||||||
|
summary.Progress = sess.ProgressPercentage.Float64
|
||||||
|
}
|
||||||
|
out.RecentSessions = append(out.RecentSessions, summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Most-recent first for display.
|
||||||
|
for i, j := 0, len(out.RecentSessions)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
out.RecentSessions[i], out.RecentSessions[j] = out.RecentSessions[j], out.RecentSessions[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeStreaks walks an ascending ISO-date list (the active-day
|
||||||
|
// calendar in the user's timezone) anchored at TODAY's date.
|
||||||
|
func computeStreaks(activeDays []string, today string) StreakStats {
|
||||||
|
activeSet := make(map[string]bool, len(activeDays))
|
||||||
|
for _, d := range activeDays {
|
||||||
|
activeSet[d] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
out := StreakStats{ActiveToday: activeSet[today], Current: 0, Longest: 0}
|
||||||
|
|
||||||
|
// Current: walk back from today; an inactive TODAY falls through to
|
||||||
|
// yesterday so a not-yet-read day doesn't erase a live streak.
|
||||||
|
anchor := today
|
||||||
|
if !activeSet[anchor] {
|
||||||
|
if d, err := time.Parse("2006-01-02", today); err == nil {
|
||||||
|
anchor = d.AddDate(0, 0, -1).Format("2006-01-02")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for cur := anchor; activeSet[cur]; {
|
||||||
|
out.Current++
|
||||||
|
d, err := time.Parse("2006-01-02", cur)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cur = d.AddDate(0, 0, -1).Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Longest run in the ascending ordered list.
|
||||||
|
run := 0
|
||||||
|
var prev string
|
||||||
|
for _, d := range activeDays {
|
||||||
|
if prev != "" && nextDay(prev) == d {
|
||||||
|
run++
|
||||||
|
} else {
|
||||||
|
run = 1
|
||||||
|
}
|
||||||
|
if run > out.Longest {
|
||||||
|
out.Longest = run
|
||||||
|
}
|
||||||
|
prev = d
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextDay returns the day after an ISO date, preserving the format.
|
||||||
|
func nextDay(iso string) string {
|
||||||
|
d, err := time.Parse("2006-01-02", iso)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return d.AddDate(0, 0, 1).Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
// dayKey buckets a session instant into the user's calendar date.
|
||||||
|
func dayKey(ts pgtype.Timestamptz, loc *time.Location) string {
|
||||||
|
if !ts.Valid {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return ts.Time.In(loc).Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
// metadataSource extracts the capture's source tag from device_metadata.
|
||||||
|
func metadataSource(raw []byte) string {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var meta struct {
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
if jsonErr := json.Unmarshal(raw, &meta); jsonErr == nil {
|
||||||
|
return meta.Source
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -597,9 +597,96 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reading-history capture: threading stats off the sync stream. Every
|
||||||
|
// successful progress write yields one reading_history session row —
|
||||||
|
// the span since the PREVIOUS write instant (bounds are one or the
|
||||||
|
// other: the row is recorded regardless, time attribution is
|
||||||
|
// conservative). Client cadence drives the granularity (web/app
|
||||||
|
// debounced pushes, KOReader page-count pushes); nothing second-level
|
||||||
|
// is promised and nothing client-side changes.
|
||||||
|
s.captureReadingSession(ctx, req, existing, hasExisting, result)
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxAttributedSessionSeconds caps a derived session: pushes farther
|
||||||
|
// apart than this carry unattributable idle time (overnight suspend →
|
||||||
|
// resume, offline queue flushes) and are kept as zero-second markers
|
||||||
|
// instead of inflating totals.
|
||||||
|
const maxAttributedSessionSeconds = int32(2 * 60 * 60)
|
||||||
|
|
||||||
|
// captureReadingSession records one reading_history row for this push.
|
||||||
|
// Never fails the sync: capture errors are logged and swallowed.
|
||||||
|
func (s *ProgressService) captureReadingSession(ctx context.Context, req SaveProgressRequest, existing database.ReadingProgress, hasExisting bool, saved database.ReadingProgress) {
|
||||||
|
var seconds int32
|
||||||
|
sessionEnd := saved.LastReadAt
|
||||||
|
sessionStart := sessionEnd // first-contact writes start at the push itself
|
||||||
|
if sessionEnd.Valid && hasExisting && existing.LastReadAt.Valid {
|
||||||
|
sessionStart = existing.LastReadAt
|
||||||
|
if elapsed := int32(sessionEnd.Time.Sub(existing.LastReadAt.Time).Seconds()); elapsed > 0 {
|
||||||
|
if elapsed <= maxAttributedSessionSeconds {
|
||||||
|
seconds = elapsed // verified-continuous span
|
||||||
|
} // else: idle gap — marker row, zero time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pages read: only when both sides carry a real page index (0 means
|
||||||
|
// unknown for several clients, e.g. LOC-less KOReader pushes).
|
||||||
|
var pages pgtype.Int4
|
||||||
|
if hasExisting && existing.CurrentPage.Valid && existing.CurrentPage.Int32 > 0 &&
|
||||||
|
saved.CurrentPage.Valid && saved.CurrentPage.Int32 > 0 {
|
||||||
|
if deltaPages := saved.CurrentPage.Int32 - existing.CurrentPage.Int32; deltaPages > 0 && deltaPages <= 5000 {
|
||||||
|
pages = pgtype.Int4{Int32: deltaPages, Valid: true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// device_id references real devices (FK). The web/app progress paths
|
||||||
|
// historically stuff the USER id into DeviceID (it only fed a
|
||||||
|
// broadcast label before stats existed) — probe before writing,
|
||||||
|
// otherwise capture a device-less session and keep the label in
|
||||||
|
// metadata only.
|
||||||
|
var deviceID pgtype.UUID
|
||||||
|
if req.DeviceID.Valid {
|
||||||
|
exists, probeErr := s.db.DeviceExistsForUser(ctx, database.DeviceExistsForUserParams{
|
||||||
|
DeviceID: req.DeviceID,
|
||||||
|
UserID: req.UserID,
|
||||||
|
})
|
||||||
|
if probeErr == nil && exists {
|
||||||
|
deviceID = req.DeviceID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metadata := map[string]string{"source": req.Source}
|
||||||
|
|
||||||
|
_, err := s.db.CreateReadingHistory(ctx, database.CreateReadingHistoryParams{
|
||||||
|
UserID: req.UserID,
|
||||||
|
MediaItemID: req.MediaItemID,
|
||||||
|
DeviceID: deviceID,
|
||||||
|
ProgressPercentage: saved.Percentage,
|
||||||
|
ReadingSessionStart: sessionStart,
|
||||||
|
ReadingSessionEnd: sessionEnd,
|
||||||
|
PagesRead: pages,
|
||||||
|
TimeSpentSeconds: pgtype.Int4{Int32: seconds, Valid: true},
|
||||||
|
DeviceMetadata: mustJSON(metadata),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("reading-history capture failed (non-fatal) for %s: %v", uniqueKeyString(req), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueKeyString(req SaveProgressRequest) string {
|
||||||
|
return uuid.UUID(req.MediaItemID.Bytes).String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustJSON marshals metadata or an empty document on failure — capture
|
||||||
|
// must never break on a serialization corner.
|
||||||
|
func mustJSON(v interface{}) []byte {
|
||||||
|
data, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return []byte(`{}`)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
func buildProgressSnapshot(req SaveProgressRequest) map[string]interface{} {
|
func buildProgressSnapshot(req SaveProgressRequest) map[string]interface{} {
|
||||||
data := map[string]interface{}{}
|
data := map[string]interface{}{}
|
||||||
if req.Percentage != nil {
|
if req.Percentage != nil {
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
package templates
|
|
||||||
|
|
||||||
templ Analytics(user User) {
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8"/>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
||||||
<title>Reading Analytics - Bookhoard</title>
|
|
||||||
<script src="/static/htmx.min.js"></script>
|
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
|
||||||
</head>
|
|
||||||
<body x-data="analytics" x-init="loadAnalytics" class="theme-{ user.Theme }">
|
|
||||||
@Header(user, "/analytics")
|
|
||||||
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
|
||||||
<div class="mb-8">
|
|
||||||
<div class="flex items-center gap-3 mb-1">
|
|
||||||
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
|
||||||
@Icon("chart", "h-5 w-5")
|
|
||||||
</span>
|
|
||||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Reading Analytics</h1>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm" style="color: var(--text-secondary)">Track your reading habits and device usage</p>
|
|
||||||
</div>
|
|
||||||
<div class="card p-4 mb-6">
|
|
||||||
<div class="flex flex-wrap gap-4 items-end">
|
|
||||||
<div class="flex-1 min-w-[200px]">
|
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Start Date</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
id="start-date"
|
|
||||||
onchange="loadAnalytics()"
|
|
||||||
class="input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-w-[200px]">
|
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">End Date</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
id="end-date"
|
|
||||||
onchange="loadAnalytics()"
|
|
||||||
class="input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button @click="loadAnalytics()" class="btn btn-primary">Update</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8" id="stats-container">
|
|
||||||
<div class="stat-card">
|
|
||||||
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Books Read</h3>
|
|
||||||
<p id="total-books" class="text-3xl font-bold" style="color: var(--accent);">-</p>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Pages Read</h3>
|
|
||||||
<p id="total-pages" class="text-3xl font-bold" style="color: var(--accent);">-</p>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Reading Time</h3>
|
|
||||||
<p id="reading-time" class="text-3xl font-bold" style="color: var(--accent);">-</p>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Completion Rate</h3>
|
|
||||||
<p id="completion-rate" class="text-3xl font-bold" style="color: var(--accent);">-</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
|
||||||
<div class="card p-6">
|
|
||||||
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary);">Daily Reading Minutes</h3>
|
|
||||||
<div class="h-80">
|
|
||||||
<canvas id="daily-reading-chart"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card p-6">
|
|
||||||
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary);">Device Usage</h3>
|
|
||||||
<div class="h-80">
|
|
||||||
<canvas id="device-usage-chart"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card p-6">
|
|
||||||
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary);">Most Read Books</h3>
|
|
||||||
<div id="popular-books" class="space-y-3">
|
|
||||||
<div class="text-center py-8" style="color: var(--text-secondary);">
|
|
||||||
<div class="loading-spinner mx-auto mb-4"></div>
|
|
||||||
<p>Loading analytics...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
// Code generated by templ - DO NOT EDIT.
|
|
||||||
|
|
||||||
// templ: version: v0.3.1020
|
|
||||||
package templates
|
|
||||||
|
|
||||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
|
||||||
|
|
||||||
import "github.com/a-h/templ"
|
|
||||||
import templruntime "github.com/a-h/templ/runtime"
|
|
||||||
|
|
||||||
func Analytics(user User) templ.Component {
|
|
||||||
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
|
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
|
||||||
return templ_7745c5c3_CtxErr
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
|
||||||
if !templ_7745c5c3_IsBuffer {
|
|
||||||
defer func() {
|
|
||||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err == nil {
|
|
||||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
ctx = templ.InitializeContext(ctx)
|
|
||||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
|
||||||
if templ_7745c5c3_Var1 == nil {
|
|
||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
|
||||||
}
|
|
||||||
ctx = templ.ClearChildren(ctx)
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Reading Analytics - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body x-data=\"analytics\" x-init=\"loadAnalytics\" class=\"theme-{ user.Theme }\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = Header(user, "/analytics").Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = Icon("chart", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Reading Analytics</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Track your reading habits and device usage</p></div><div class=\"card p-4 mb-6\"><div class=\"flex flex-wrap gap-4 items-end\"><div class=\"flex-1 min-w-[200px]\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Start Date</label> <input type=\"date\" id=\"start-date\" onchange=\"loadAnalytics()\" class=\"input\"></div><div class=\"flex-1 min-w-[200px]\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">End Date</label> <input type=\"date\" id=\"end-date\" onchange=\"loadAnalytics()\" class=\"input\"></div><div><button @click=\"loadAnalytics()\" class=\"btn btn-primary\">Update</button></div></div></div><div class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8\" id=\"stats-container\"><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Books Read</h3><p id=\"total-books\" class=\"text-3xl font-bold\" style=\"color: var(--accent);\">-</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Pages Read</h3><p id=\"total-pages\" class=\"text-3xl font-bold\" style=\"color: var(--accent);\">-</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Reading Time</h3><p id=\"reading-time\" class=\"text-3xl font-bold\" style=\"color: var(--accent);\">-</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Completion Rate</h3><p id=\"completion-rate\" class=\"text-3xl font-bold\" style=\"color: var(--accent);\">-</p></div></div><div class=\"grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8\"><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary);\">Daily Reading Minutes</h3><div class=\"h-80\"><canvas id=\"daily-reading-chart\"></canvas></div></div><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary);\">Device Usage</h3><div class=\"h-80\"><canvas id=\"device-usage-chart\"></canvas></div></div></div><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary);\">Most Read Books</h3><div id=\"popular-books\" class=\"space-y-3\"><div class=\"text-center py-8\" style=\"color: var(--text-secondary);\"><div class=\"loading-spinner mx-auto mb-4\"></div><p>Loading analytics...</p></div></div></div></div></body></html>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = templruntime.GeneratedTemplate
|
|
||||||
@@ -281,6 +281,38 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
<!-- Reading stats (the viewer's own sessions on this book) -->
|
||||||
|
if book.BookStats != nil {
|
||||||
|
<div class="card p-6 mb-6">
|
||||||
|
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Your Reading Stats</h3>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Time Reading</p>
|
||||||
|
if book.BookStats.TotalMinutes >= 60 {
|
||||||
|
<p class="text-xl font-bold" style="color: var(--accent)">{ fmt.Sprintf("%dh %dmin", book.BookStats.TotalMinutes/60, book.BookStats.TotalMinutes%60) }</p>
|
||||||
|
} else {
|
||||||
|
<p class="text-xl font-bold" style="color: var(--accent)">{ fmt.Sprintf("%d min", book.BookStats.TotalMinutes) }</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Sessions</p>
|
||||||
|
<p class="text-xl font-bold" style="color: var(--accent)">{ fmt.Sprintf("%d", book.BookStats.SessionCount) }</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Pages Synced</p>
|
||||||
|
<p class="text-xl font-bold" style="color: var(--accent)">{ fmt.Sprintf("%d", book.BookStats.TotalPages) }</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Finished</p>
|
||||||
|
if book.BookStats.Finished {
|
||||||
|
<p class="text-xl font-bold" style="color: var(--text-primary)">✓</p>
|
||||||
|
} else {
|
||||||
|
<p class="text-xl font-bold" style="color: var(--text-secondary)">—</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<!-- Metadata grid -->
|
<!-- Metadata grid -->
|
||||||
<div class="card p-6 mb-6">
|
<div class="card p-6 mb-6">
|
||||||
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Metadata</h3>
|
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Metadata</h3>
|
||||||
|
|||||||
+402
-310
File diff suppressed because it is too large.
Load diff
@@ -5,7 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
templ Dashboard(user User, sections []handlers.SectionData, allSections []handlers.SectionData, libData []LibraryData, currentLibraryID string, hiddenCollections []string, itemsPerSection int, errorMessage string) {
|
templ Dashboard(user User, sections []handlers.SectionData, allSections []handlers.SectionData, libData []LibraryData, currentLibraryID string, hiddenCollections []string, itemsPerSection int, week *WeekStatsData, errorMessage string) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -24,6 +24,9 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
|
|||||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Your Library</h1>
|
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Your Library</h1>
|
||||||
<p class="text-sm mt-1" style="color: var(--text-secondary)">Pick up where you left off, or explore something new.</p>
|
<p class="text-sm mt-1" style="color: var(--text-secondary)">Pick up where you left off, or explore something new.</p>
|
||||||
</div>
|
</div>
|
||||||
|
if week != nil {
|
||||||
|
@DashboardWeekWidget(*week)
|
||||||
|
}
|
||||||
for _, section := range sections {
|
for _, section := range sections {
|
||||||
@CollectionCarousel(section)
|
@CollectionCarousel(section)
|
||||||
}
|
}
|
||||||
@@ -247,3 +250,87 @@ templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WeekStatsData is the dashboard's "this week" widget payload
|
||||||
|
// (server-rendered from the statistics service; nil = suppressed).
|
||||||
|
// Times rendered in the user's timezone at the server, per convention.
|
||||||
|
type WeekStatsData struct {
|
||||||
|
WeekMinutes int
|
||||||
|
WeekPages int
|
||||||
|
WeekSessions int
|
||||||
|
WeekBooks int
|
||||||
|
PrevMinutes int
|
||||||
|
ActiveToday bool
|
||||||
|
CurrentStreak int
|
||||||
|
}
|
||||||
|
|
||||||
|
// BookStatsData is the per-book reading-stats card on the book detail
|
||||||
|
// page (nil = the viewer has no sessions on this book → hide it).
|
||||||
|
type BookStatsData struct {
|
||||||
|
TotalMinutes int
|
||||||
|
TotalPages int
|
||||||
|
SessionCount int
|
||||||
|
MaxProgress float64 // 0..1
|
||||||
|
Finished bool
|
||||||
|
LastSession string
|
||||||
|
FirstSession string
|
||||||
|
}
|
||||||
|
|
||||||
|
templ DashboardWeekWidget(week WeekStatsData) {
|
||||||
|
<div class="card p-4 md:p-6 mb-10">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="grid place-items-center h-9 w-9 rounded-lg text-lg" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
|
@Icon("chart", "h-5 w-5")
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-bold tracking-tight" style="color: var(--text-primary)">This Week</h2>
|
||||||
|
<p class="text-xs" style="color: var(--text-secondary)">Synced from your reading devices</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/stats" class="text-sm font-medium hover:underline" style="color: var(--accent);">Full stats →</a>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mt-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d min", week.WeekMinutes) }</p>
|
||||||
|
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">
|
||||||
|
{ readingTimeSuffix(week.WeekMinutes, week.PrevMinutes) }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d", week.WeekPages) }</p>
|
||||||
|
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">pages read</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d", week.WeekSessions) }</p>
|
||||||
|
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">reading sessions</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d", week.WeekBooks) }</p>
|
||||||
|
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">books touched</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs mt-4" style="color: var(--text-secondary)">
|
||||||
|
if week.CurrentStreak > 0 {
|
||||||
|
if week.ActiveToday {
|
||||||
|
{ fmt.Sprintf("🔥 %d-day streak — keep it going today", week.CurrentStreak) }
|
||||||
|
} else {
|
||||||
|
{ fmt.Sprintf("🔥 %d-day streak — read today to keep it", week.CurrentStreak) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
{ "A reading day today starts your streak." }
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
func readingTimeSuffix(minutes, prevMinutes int) string {
|
||||||
|
if prevMinutes <= 0 {
|
||||||
|
if minutes > 0 {
|
||||||
|
return "reading time"
|
||||||
|
}
|
||||||
|
return "no reading yet this week"
|
||||||
|
}
|
||||||
|
percent := float64(minutes-prevMinutes) / float64(prevMinutes) * 100
|
||||||
|
return fmt.Sprintf("%+.0f%% vs last week", percent)
|
||||||
|
}
|
||||||
+211
-30
@@ -13,7 +13,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Dashboard(user User, sections []handlers.SectionData, allSections []handlers.SectionData, libData []LibraryData, currentLibraryID string, hiddenCollections []string, itemsPerSection int, errorMessage string) templ.Component {
|
func Dashboard(user User, sections []handlers.SectionData, allSections []handlers.SectionData, libData []LibraryData, currentLibraryID string, hiddenCollections []string, itemsPerSection int, week *WeekStatsData, errorMessage string) 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
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
@@ -50,6 +50,12 @@ func Dashboard(user User, sections []handlers.SectionData, allSections []handler
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
if week != nil {
|
||||||
|
templ_7745c5c3_Err = DashboardWeekWidget(*week).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, section := range sections {
|
for _, section := range sections {
|
||||||
templ_7745c5c3_Err = CollectionCarousel(section).Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = CollectionCarousel(section).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -104,7 +110,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 40, Col: 33}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 43, Col: 33}
|
||||||
}
|
}
|
||||||
_, 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 {
|
||||||
@@ -117,7 +123,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.IsSystem)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.IsSystem)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 41, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 44, Col: 35}
|
||||||
}
|
}
|
||||||
_, 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 {
|
||||||
@@ -130,7 +136,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 45, Col: 130}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 48, Col: 130}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -143,7 +149,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 47, Col: 100}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 50, Col: 100}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -161,7 +167,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(section.Description)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(section.Description)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 49, Col: 83}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 52, Col: 83}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -184,7 +190,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var8 templ.SafeURL
|
var templ_7745c5c3_Var8 templ.SafeURL
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(section.ViewAllURL)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(section.ViewAllURL)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 55, Col: 30}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 58, Col: 30}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -210,7 +216,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 68, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 71, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -231,7 +237,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("carousel-track-" + section.ID)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("carousel-track-" + section.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 76, Col: 39}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 79, Col: 39}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -249,7 +255,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.MediaItemID)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.MediaItemID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 81, Col: 92}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 84, Col: 92}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -289,7 +295,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 95, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 98, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -339,7 +345,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var14 templ.SafeURL
|
var templ_7745c5c3_Var14 templ.SafeURL
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 108, Col: 40}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 111, Col: 40}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -357,7 +363,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var15 string
|
var templ_7745c5c3_Var15 string
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.CoverImagePath)
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.CoverImagePath)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 115, Col: 31}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 118, Col: 31}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -370,7 +376,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 116, Col: 22}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 119, Col: 22}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -388,7 +394,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 124, Col: 22}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 127, Col: 22}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -406,7 +412,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var18 string
|
var templ_7745c5c3_Var18 string
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 130, Col: 117}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 133, Col: 117}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -419,7 +425,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var19 string
|
var templ_7745c5c3_Var19 string
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 131, Col: 17}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 134, Col: 17}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -437,7 +443,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var20 string
|
var templ_7745c5c3_Var20 string
|
||||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Author)
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Author)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 134, Col: 100}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 137, Col: 100}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -450,7 +456,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var21 string
|
var templ_7745c5c3_Var21 string
|
||||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 135, Col: 19}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 138, Col: 19}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -473,7 +479,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var22 templ.SafeURL
|
var templ_7745c5c3_Var22 templ.SafeURL
|
||||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID)
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 143, Col: 40}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 146, Col: 40}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -486,7 +492,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var23 string
|
var templ_7745c5c3_Var23 string
|
||||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("Resolve progress conflict for " + item.Title)
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("Resolve progress conflict for " + item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 145, Col: 63}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 148, Col: 63}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -512,7 +518,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var24 templ.SafeURL
|
var templ_7745c5c3_Var24 templ.SafeURL
|
||||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs("/readers/" + item.MediaItemID)
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs("/readers/" + item.MediaItemID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 152, Col: 42}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 155, Col: 42}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -525,7 +531,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var25 string
|
var templ_7745c5c3_Var25 string
|
||||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("Read " + item.Title)
|
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("Read " + item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 154, Col: 38}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 157, Col: 38}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -593,7 +599,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
|||||||
var templ_7745c5c3_Var27 string
|
var templ_7745c5c3_Var27 string
|
||||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 185, Col: 37}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 188, Col: 37}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -606,7 +612,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
|||||||
var templ_7745c5c3_Var28 string
|
var templ_7745c5c3_Var28 string
|
||||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%v", section.IsSystem))
|
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%v", section.IsSystem))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 186, Col: 58}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 189, Col: 58}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -627,7 +633,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
|||||||
var templ_7745c5c3_Var29 string
|
var templ_7745c5c3_Var29 string
|
||||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
|
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 191, Col: 43}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 194, Col: 43}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -640,7 +646,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
|||||||
var templ_7745c5c3_Var30 string
|
var templ_7745c5c3_Var30 string
|
||||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 193, Col: 84}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 196, Col: 84}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -668,7 +674,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
|||||||
var templ_7745c5c3_Var31 string
|
var templ_7745c5c3_Var31 string
|
||||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 203, Col: 42}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 206, Col: 42}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -701,7 +707,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
|||||||
var templ_7745c5c3_Var32 string
|
var templ_7745c5c3_Var32 string
|
||||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(itemsPerSection)
|
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(itemsPerSection)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 229, Col: 128}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 232, Col: 128}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -714,7 +720,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
|||||||
var templ_7745c5c3_Var33 string
|
var templ_7745c5c3_Var33 string
|
||||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(itemsPerSection)
|
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(itemsPerSection)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 236, Col: 28}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 239, Col: 28}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -728,4 +734,179 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WeekStatsData is the dashboard's "this week" widget payload
|
||||||
|
// (server-rendered from the statistics service; nil = suppressed).
|
||||||
|
// Times rendered in the user's timezone at the server, per convention.
|
||||||
|
type WeekStatsData struct {
|
||||||
|
WeekMinutes int
|
||||||
|
WeekPages int
|
||||||
|
WeekSessions int
|
||||||
|
WeekBooks int
|
||||||
|
PrevMinutes int
|
||||||
|
ActiveToday bool
|
||||||
|
CurrentStreak int
|
||||||
|
}
|
||||||
|
|
||||||
|
// BookStatsData is the per-book reading-stats card on the book detail
|
||||||
|
// page (nil = the viewer has no sessions on this book → hide it).
|
||||||
|
type BookStatsData struct {
|
||||||
|
TotalMinutes int
|
||||||
|
TotalPages int
|
||||||
|
SessionCount int
|
||||||
|
MaxProgress float64 // 0..1
|
||||||
|
Finished bool
|
||||||
|
LastSession string
|
||||||
|
FirstSession string
|
||||||
|
}
|
||||||
|
|
||||||
|
func DashboardWeekWidget(week WeekStatsData) templ.Component {
|
||||||
|
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
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var34 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var34 == nil {
|
||||||
|
templ_7745c5c3_Var34 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<div class=\"card p-4 md:p-6 mb-10\"><div class=\"flex flex-wrap items-center justify-between gap-4\"><div class=\"flex items-center gap-3\"><span class=\"grid place-items-center h-9 w-9 rounded-lg text-lg\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("chart", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</span><div><h2 class=\"text-lg font-bold tracking-tight\" style=\"color: var(--text-primary)\">This Week</h2><p class=\"text-xs\" style=\"color: var(--text-secondary)\">Synced from your reading devices</p></div></div><a href=\"/stats\" class=\"text-sm font-medium hover:underline\" style=\"color: var(--accent);\">Full stats →</a></div><div class=\"grid grid-cols-2 lg:grid-cols-4 gap-4 mt-4\"><div><p class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var35 string
|
||||||
|
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d min", week.WeekMinutes))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 295, Col: 110}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</p><p class=\"text-xs mt-0.5\" style=\"color: var(--text-secondary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var36 string
|
||||||
|
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(readingTimeSuffix(week.WeekMinutes, week.PrevMinutes))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 297, Col: 60}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</p></div><div><p class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var37 string
|
||||||
|
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.WeekPages))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 301, Col: 104}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</p><p class=\"text-xs mt-0.5\" style=\"color: var(--text-secondary)\">pages read</p></div><div><p class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var38 string
|
||||||
|
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.WeekSessions))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 305, Col: 107}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</p><p class=\"text-xs mt-0.5\" style=\"color: var(--text-secondary)\">reading sessions</p></div><div><p class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var39 string
|
||||||
|
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.WeekBooks))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 309, Col: 104}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</p><p class=\"text-xs mt-0.5\" style=\"color: var(--text-secondary)\">books touched</p></div></div><p class=\"text-xs mt-4\" style=\"color: var(--text-secondary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if week.CurrentStreak > 0 {
|
||||||
|
if week.ActiveToday {
|
||||||
|
var templ_7745c5c3_Var40 string
|
||||||
|
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("🔥 %d-day streak — keep it going today", week.CurrentStreak))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 316, Col: 84}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var templ_7745c5c3_Var41 string
|
||||||
|
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("🔥 %d-day streak — read today to keep it", week.CurrentStreak))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 318, Col: 86}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var templ_7745c5c3_Var42 string
|
||||||
|
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs("A reading day today starts your streak.")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 321, Col: 47}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</p></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func readingTimeSuffix(minutes, prevMinutes int) string {
|
||||||
|
if prevMinutes <= 0 {
|
||||||
|
if minutes > 0 {
|
||||||
|
return "reading time"
|
||||||
|
}
|
||||||
|
return "no reading yet this week"
|
||||||
|
}
|
||||||
|
percent := float64(minutes-prevMinutes) / float64(prevMinutes) * 100
|
||||||
|
return fmt.Sprintf("%+.0f%% vs last week", percent)
|
||||||
|
}
|
||||||
|
|
||||||
var _ = templruntime.GeneratedTemplate
|
var _ = templruntime.GeneratedTemplate
|
||||||
@@ -67,6 +67,10 @@ templ Header(user User, currentPath string) {
|
|||||||
@Icon("clock", "h-5 w-5 shrink-0")
|
@Icon("clock", "h-5 w-5 shrink-0")
|
||||||
<span>Progress</span>
|
<span>Progress</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/stats" class={ activeClass(currentPath, "/stats") }>
|
||||||
|
@Icon("chart", "h-5 w-5 shrink-0")
|
||||||
|
<span>Stats</span>
|
||||||
|
</a>
|
||||||
<a href="/devices" class={ activeClass(currentPath, "/devices") }>
|
<a href="/devices" class={ activeClass(currentPath, "/devices") }>
|
||||||
@Icon("device", "h-5 w-5 shrink-0")
|
@Icon("device", "h-5 w-5 shrink-0")
|
||||||
<span>Devices</span>
|
<span>Devices</span>
|
||||||
|
|||||||
+208
-178
@@ -191,12 +191,12 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var12 = []any{activeClass(currentPath, "/devices")}
|
var templ_7745c5c3_Var12 = []any{activeClass(currentPath, "/stats")}
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var12...)
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var12...)
|
||||||
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, 18, "<a href=\"/devices\" class=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<a href=\"/stats\" class=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -213,11 +213,41 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("chart", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<span>Stats</span></a> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var14 = []any{activeClass(currentPath, "/devices")}
|
||||||
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var14...)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<a href=\"/devices\" class=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var15 string
|
||||||
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var14).String())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
templ_7745c5c3_Err = Icon("device", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = Icon("device", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
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, 20, "<span>Devices</span></a></nav><!-- Bottom: theme picker + user --><div x-data=\"{ openPanel: window.location.pathname.startsWith('/admin') ? 'admin' : null }\" class=\"shrink-0 px-3 py-3 space-y-2 border-t\" style=\"border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<span>Devices</span></a></nav><!-- Bottom: theme picker + user --><div x-data=\"{ openPanel: window.location.pathname.startsWith('/admin') ? 'admin' : null }\" class=\"shrink-0 px-3 py-3 space-y-2 border-t\" style=\"border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -232,7 +262,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<!-- Theme picker --><div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'appearance' ? null : 'appearance'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-content-muted hover:text-content hover:bg-surface-hover\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<!-- Theme picker --><div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'appearance' ? null : 'appearance'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-content-muted hover:text-content hover:bg-surface-hover\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -240,7 +270,7 @@ func Header(user User, currentPath string) 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, 22, "<span>Appearance</span> <span class=\"ml-auto h-4 w-4 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'appearance' }\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<span>Appearance</span> <span class=\"ml-auto h-4 w-4 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'appearance' }\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -248,86 +278,86 @@ func Header(user User, currentPath string) 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, 23, "</span></button><div x-show=\"openPanel === 'appearance'\" x-cloak x-transition class=\"mt-1 space-y-0.5 pl-1\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</span></button><div x-show=\"openPanel === 'appearance'\" x-cloak x-transition class=\"mt-1 space-y-0.5 pl-1\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, opt := range ThemeOptions {
|
for _, opt := range ThemeOptions {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<button type=\"button\" @click=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<button type=\"button\" @click=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var14 string
|
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("changeTheme('" + opt.Name + "'); openPanel = null")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 103, Col: 68}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" class=\"theme-btn w-full flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" data-theme=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var15 string
|
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 105, Col: 29}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" style=\"color: var(--text-secondary);\"><span class=\"inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10\" style=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: " + opt.Color)
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("changeTheme('" + opt.Name + "'); openPanel = null")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 108, Col: 130}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 107, Col: 68}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||||
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, 27, "\"></span> <span class=\"flex-1 text-left\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" class=\"theme-btn w-full flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" data-theme=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 109, Col: 50}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 109, Col: 29}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||||
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, 28, "</span> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" style=\"color: var(--text-secondary);\"><span class=\"inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10\" style=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var18 = []any{"theme-check shrink-0" + themeCheckClass(opt.Name, user.Theme)}
|
var templ_7745c5c3_Var18 string
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var18...)
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: " + opt.Color)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 112, Col: 130}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||||
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, "<span class=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\"></span> <span class=\"flex-1 text-left\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var19 string
|
var templ_7745c5c3_Var19 string
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var18).String())
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 113, Col: 50}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||||
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, 30, "\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</span> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var20 = []any{"theme-check shrink-0" + themeCheckClass(opt.Name, user.Theme)}
|
||||||
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var20...)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<span class=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var21 string
|
||||||
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var20).String())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -335,94 +365,94 @@ func Header(user User, currentPath string) 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, 31, "</span></button>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</span></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, 32, "<div class=\"my-1.5 mx-3 border-t\" style=\"border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\"></div><p class=\"px-3 pb-1 text-xs font-medium uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Bookshelf</p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<div class=\"my-1.5 mx-3 border-t\" style=\"border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\"></div><p class=\"px-3 pb-1 text-xs font-medium uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Bookshelf</p>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, w := range WoodOptions {
|
for _, w := range WoodOptions {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<button type=\"button\" @click=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<button type=\"button\" @click=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var20 string
|
var templ_7745c5c3_Var22 string
|
||||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("changeWoodPaneling('" + w.Name + "')")
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("changeWoodPaneling('" + w.Name + "')")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 120, Col: 55}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 124, Col: 55}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||||
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, 34, "\" class=\"wood-paneling-btn w-full flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-sm transition-colors\" data-wood=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" class=\"wood-paneling-btn w-full flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-sm transition-colors\" data-wood=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var21 string
|
|
||||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(w.Name)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 122, Col: 26}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" style=\"color: var(--text-secondary);\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if w.Name == "none" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<span class=\"inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10\" style=\"background-color: color-mix(in srgb, var(--text-primary) 12%, transparent);\"></span> ")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<span class=\"inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10\" style=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var22 string
|
|
||||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-image: url(/static/textures/thumb-" + w.Name + ".webp); background-size: cover;")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 128, Col: 191}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\"></span> ")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<span class=\"flex-1 text-left\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var23 string
|
var templ_7745c5c3_Var23 string
|
||||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(w.Label)
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(w.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 130, Col: 48}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 126, Col: 26}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||||
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, 40, "</span></button>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\" style=\"color: var(--text-secondary);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if w.Name == "none" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<span class=\"inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10\" style=\"background-color: color-mix(in srgb, var(--text-primary) 12%, transparent);\"></span> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<span class=\"inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10\" style=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var24 string
|
||||||
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-image: url(/static/textures/thumb-" + w.Name + ".webp); background-size: cover;")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 132, Col: 191}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\"></span> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<span class=\"flex-1 text-left\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var25 string
|
||||||
|
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(w.Label)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 134, Col: 48}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</span></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, 41, "</div></div><!-- Admin section (visible to admins only) -->")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</div></div><!-- Admin section (visible to admins only) -->")
|
||||||
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, 42, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'admin' ? null : 'admin'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-content-muted hover:text-content hover:bg-surface-hover\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'admin' ? null : 'admin'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-content-muted hover:text-content hover:bg-surface-hover\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -430,46 +460,16 @@ func Header(user User, currentPath string) 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, 43, "<span>Administration</span> <svg class=\"h-4 w-4 ml-auto transition-transform\" :class=\"{ 'rotate-180': openPanel === 'admin' }\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M19 9l-7 7-7-7\"></path></svg></button><div x-show=\"openPanel === 'admin'\" x-cloak x-transition class=\"mt-1 space-y-0.5 pl-1\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<span>Administration</span> <svg class=\"h-4 w-4 ml-auto transition-transform\" :class=\"{ 'rotate-180': openPanel === 'admin' }\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M19 9l-7 7-7-7\"></path></svg></button><div x-show=\"openPanel === 'admin'\" x-cloak x-transition class=\"mt-1 space-y-0.5 pl-1\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var24 = []any{activeClass(currentPath, "/admin")}
|
var templ_7745c5c3_Var26 = []any{activeClass(currentPath, "/admin")}
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var24...)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<a href=\"/admin\" class=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var25 string
|
|
||||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var24).String())
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = Icon("grid", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<span>Dashboard</span></a> ")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var26 = []any{activeClass(currentPath, "/admin/library")}
|
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var26...)
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var26...)
|
||||||
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, "<a href=\"/admin/library\" class=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<a href=\"/admin\" class=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -486,20 +486,20 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = Icon("library", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = Icon("grid", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
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, "<span>Libraries</span></a> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<span>Dashboard</span></a> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var28 = []any{activeClass(currentPath, "/admin/hash-conflicts")}
|
var templ_7745c5c3_Var28 = []any{activeClass(currentPath, "/admin/library")}
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var28...)
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var28...)
|
||||||
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, 50, "<a href=\"/admin/hash-conflicts\" class=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<a href=\"/admin/library\" class=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -516,20 +516,20 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = Icon("copy", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = Icon("library", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
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, 52, "<span>Hash Conflicts</span></a> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<span>Libraries</span></a> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var30 = []any{activeClass(currentPath, "/admin/users")}
|
var templ_7745c5c3_Var30 = []any{activeClass(currentPath, "/admin/hash-conflicts")}
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var30...)
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var30...)
|
||||||
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, "<a href=\"/admin/users\" class=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<a href=\"/admin/hash-conflicts\" class=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -546,20 +546,20 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = Icon("users", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = Icon("copy", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
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, "<span>Users</span></a> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<span>Hash Conflicts</span></a> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var32 = []any{activeClass(currentPath, "/admin/settings")}
|
var templ_7745c5c3_Var32 = []any{activeClass(currentPath, "/admin/users")}
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var32...)
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var32...)
|
||||||
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, 56, "<a href=\"/admin/settings\" class=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<a href=\"/admin/users\" class=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -576,16 +576,46 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("users", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<span>Users</span></a> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var34 = []any{activeClass(currentPath, "/admin/settings")}
|
||||||
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var34...)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<a href=\"/admin/settings\" class=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var35 string
|
||||||
|
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var34).String())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
templ_7745c5c3_Err = Icon("gear", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = Icon("gear", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
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, 58, "<span>Settings</span></a></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<span>Settings</span></a></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, 59, "</div></aside><!-- Topbar --><header class=\"app-topbar\"><div class=\"flex items-center gap-3 px-4 sm:px-6 h-full\"><button type=\"button\" class=\"app-mobile-only icon-btn\" @click=\"mobileMenuOpen = true\" aria-label=\"Open menu\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</div></aside><!-- Topbar --><header class=\"app-topbar\"><div class=\"flex items-center gap-3 px-4 sm:px-6 h-full\"><button type=\"button\" class=\"app-mobile-only icon-btn\" @click=\"mobileMenuOpen = true\" aria-label=\"Open menu\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -593,7 +623,7 @@ func Header(user User, currentPath string) 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, 60, "</button><div class=\"relative flex-1 max-w-xl\"><span class=\"absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none\" style=\"color: var(--text-secondary);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</button><div class=\"relative flex-1 max-w-xl\"><span class=\"absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none\" style=\"color: var(--text-secondary);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -601,7 +631,7 @@ func Header(user User, currentPath string) 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, 61, "</span> <input type=\"text\" id=\"header-search\" placeholder=\"Search your library…\" class=\"input pl-10\" autocomplete=\"off\"></div></div></header><script type=\"module\" src=\"/static/main.js\"></script></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</span> <input type=\"text\" id=\"header-search\" placeholder=\"Search your library…\" class=\"input pl-10\" autocomplete=\"off\"></div></div></header><script type=\"module\" src=\"/static/main.js\"></script></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -626,12 +656,12 @@ func SidebarUserMenu(user User) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var34 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var36 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var34 == nil {
|
if templ_7745c5c3_Var36 == nil {
|
||||||
templ_7745c5c3_Var34 = templ.NopComponent
|
templ_7745c5c3_Var36 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'user' ? null : 'user'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\"><span class=\"grid place-items-center h-7 w-7 rounded-full shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'user' ? null : 'user'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\"><span class=\"grid place-items-center h-7 w-7 rounded-full shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -639,20 +669,20 @@ func SidebarUserMenu(user User) 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, 63, "</span> <span class=\"flex-1 text-left truncate\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</span> <span class=\"flex-1 text-left truncate\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var35 string
|
var templ_7745c5c3_Var37 string
|
||||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 220, Col: 58}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 224, Col: 58}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
|
||||||
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, 64, "</span> <span class=\"h-4 w-4 shrink-0 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'user' }\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</span> <span class=\"h-4 w-4 shrink-0 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'user' }\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -660,7 +690,7 @@ func SidebarUserMenu(user User) 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, 65, "</span></button><div x-show=\"openPanel === 'user'\" x-cloak x-transition class=\"mt-1 space-y-0.5 pl-1\"><a href=\"/profile\" class=\"flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</span></button><div x-show=\"openPanel === 'user'\" x-cloak x-transition class=\"mt-1 space-y-0.5 pl-1\"><a href=\"/profile\" class=\"flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -668,7 +698,7 @@ func SidebarUserMenu(user User) 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, 66, "<span>Profile</span></a> <button type=\"button\" @click=\"logout()\" class=\"w-full flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<span>Profile</span></a> <button type=\"button\" @click=\"logout()\" class=\"w-full flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -676,7 +706,7 @@ func SidebarUserMenu(user User) 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, 67, "<span>Logout</span></button></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<span>Logout</span></button></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -702,12 +732,12 @@ func SidebarSignIn(currentPath string) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var36 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var38 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var36 == nil {
|
if templ_7745c5c3_Var38 == nil {
|
||||||
templ_7745c5c3_Var36 = templ.NopComponent
|
templ_7745c5c3_Var38 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'signin' ? null : 'signin'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\"><span class=\"grid place-items-center h-7 w-7 rounded-full shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'signin' ? null : 'signin'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\"><span class=\"grid place-items-center h-7 w-7 rounded-full shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -715,7 +745,7 @@ func SidebarSignIn(currentPath string) 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, 69, "</span> <span class=\"flex-1 text-left\">Sign in</span> <span class=\"h-4 w-4 shrink-0 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'signin' }\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</span> <span class=\"flex-1 text-left\">Sign in</span> <span class=\"h-4 w-4 shrink-0 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'signin' }\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -723,30 +753,30 @@ func SidebarSignIn(currentPath string) 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, 70, "</span></button><div x-show=\"openPanel === 'signin'\" x-cloak x-transition class=\"mt-1 px-1\"><form hx-post=\"/api/auth/login\" hx-target=\"#login-result\" hx-swap=\"innerHTML\" class=\"space-y-2\"><input type=\"hidden\" name=\"redirect\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</span></button><div x-show=\"openPanel === 'signin'\" x-cloak x-transition class=\"mt-1 px-1\"><form hx-post=\"/api/auth/login\" hx-target=\"#login-result\" hx-swap=\"innerHTML\" class=\"space-y-2\"><input type=\"hidden\" name=\"redirect\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var37 string
|
var templ_7745c5c3_Var39 string
|
||||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
|
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 268, Col: 60}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 272, Col: 60}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
|
||||||
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, 71, "\"> <input type=\"text\" name=\"login\" class=\"input py-1.5 text-sm\" placeholder=\"Email or username\" required> <input type=\"password\" name=\"password\" class=\"input py-1.5 text-sm\" placeholder=\"Password\" required> <button type=\"submit\" class=\"btn btn-primary w-full py-1.5 text-sm\">Sign In</button></form><div id=\"login-result\"></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\"> <input type=\"text\" name=\"login\" class=\"input py-1.5 text-sm\" placeholder=\"Email or username\" required> <input type=\"password\" name=\"password\" class=\"input py-1.5 text-sm\" placeholder=\"Password\" required> <button type=\"submit\" class=\"btn btn-primary w-full py-1.5 text-sm\">Sign In</button></form><div id=\"login-result\"></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if SignupVisible() {
|
if SignupVisible() {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<a href=\"/register\" class=\"block text-center text-xs mt-2 hover:underline\" style=\"color: var(--text-secondary);\">Create an account</a>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "<a href=\"/register\" class=\"block text-center text-xs mt-2 hover:underline\" style=\"color: var(--text-secondary);\">Create an account</a>")
|
||||||
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, 73, "</div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
templ Stats(user User) {
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Reading Stats - Bookhoard</title>
|
||||||
|
<script src="/static/htmx.min.js"></script>
|
||||||
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
|
</head>
|
||||||
|
<body x-data="stats" x-init="initStatsPage()" class="theme-{ user.Theme }">
|
||||||
|
@Header(user, "/stats")
|
||||||
|
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex items-center gap-3 mb-1">
|
||||||
|
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
|
@Icon("chart", "h-5 w-5")
|
||||||
|
</span>
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Reading Stats</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">Days are bucketed in your timezone ({ user.Timezone }) · totals derive from your devices' sync pushes, not a stopwatch.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4 mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div class="flex gap-2" id="stats-range-selector">
|
||||||
|
<button data-range="7" @click="selectRange('7')" class="stats-range-btn btn btn-secondary text-sm">Last 7 days</button>
|
||||||
|
<button data-range="30" @click="selectRange('30')" class="stats-range-btn btn btn-secondary text-sm">30 days</button>
|
||||||
|
<button data-range="90" @click="selectRange('90')" class="stats-range-btn btn btn-secondary text-sm">90 days</button>
|
||||||
|
<button data-range="year" @click="selectRange('year')" class="stats-range-btn btn btn-secondary text-sm">This year</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs" style="color: var(--text-secondary)" id="stats-window-label"></p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Reading Time</h3>
|
||||||
|
<p id="stats-total-minutes" class="text-3xl font-bold" style="color: var(--accent)">–</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Pages</h3>
|
||||||
|
<p id="stats-total-pages" class="text-3xl font-bold" style="color: var(--accent)">–</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Streaks</h3>
|
||||||
|
<p id="stats-streaks" class="text-lg font-bold leading-tight mt-1" style="color: var(--text-primary)">–</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Books Finished</h3>
|
||||||
|
<p id="stats-books-finished" class="text-3xl font-bold" style="color: var(--accent)">–</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Most Active</h3>
|
||||||
|
<p id="stats-most-active" class="text-sm font-bold leading-snug mt-2" style="color: var(--text-primary)">–</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||||
|
<div class="card p-6">
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">Daily Reading Minutes</h3>
|
||||||
|
<div class="h-64">
|
||||||
|
<canvas id="stats-daily-minutes-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-6">
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">Pages per Day</h3>
|
||||||
|
<div class="h-64">
|
||||||
|
<canvas id="stats-daily-pages-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||||
|
<div class="card p-6">
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">By Library</h3>
|
||||||
|
<div id="stats-library-breakdown" class="space-y-3">
|
||||||
|
<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-6">
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">Most Read</h3>
|
||||||
|
<div id="stats-top-books" class="space-y-3">
|
||||||
|
<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="stats-empty" class="card p-8 text-center hidden">
|
||||||
|
<p class="text-sm mb-2" style="color: var(--text-primary)">No reading sessions recorded in this range yet.</p>
|
||||||
|
<p class="text-xs" style="color: var(--text-secondary)">Sessions are captured every time a device syncs reading progress (KOReader, the app, the web reader).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@ErrorToast("")
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
|
// templ: version: v0.3.1020
|
||||||
|
package templates
|
||||||
|
|
||||||
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
|
|
||||||
|
import "github.com/a-h/templ"
|
||||||
|
import templruntime "github.com/a-h/templ/runtime"
|
||||||
|
|
||||||
|
func Stats(user User) templ.Component {
|
||||||
|
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
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var1 == nil {
|
||||||
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Reading Stats - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body x-data=\"stats\" x-init=\"initStatsPage()\" class=\"theme-{ user.Theme }\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Header(user, "/stats").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("chart", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Reading Stats</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Days are bucketed in your timezone (")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var2 string
|
||||||
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(user.Timezone)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `stats.templ`, Line: 24, Col: 112}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, ") · totals derive from your devices' sync pushes, not a stopwatch.</p></div><div class=\"card p-4 mb-6 flex flex-wrap items-center justify-between gap-3\"><div class=\"flex gap-2\" id=\"stats-range-selector\"><button data-range=\"7\" @click=\"selectRange('7')\" class=\"stats-range-btn btn btn-secondary text-sm\">Last 7 days</button> <button data-range=\"30\" @click=\"selectRange('30')\" class=\"stats-range-btn btn btn-secondary text-sm\">30 days</button> <button data-range=\"90\" @click=\"selectRange('90')\" class=\"stats-range-btn btn btn-secondary text-sm\">90 days</button> <button data-range=\"year\" @click=\"selectRange('year')\" class=\"stats-range-btn btn btn-secondary text-sm\">This year</button></div><p class=\"text-xs\" style=\"color: var(--text-secondary)\" id=\"stats-window-label\"></p></div><div class=\"grid grid-cols-2 lg:grid-cols-5 gap-4 mb-8\"><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Reading Time</h3><p id=\"stats-total-minutes\" class=\"text-3xl font-bold\" style=\"color: var(--accent)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Pages</h3><p id=\"stats-total-pages\" class=\"text-3xl font-bold\" style=\"color: var(--accent)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Streaks</h3><p id=\"stats-streaks\" class=\"text-lg font-bold leading-tight mt-1\" style=\"color: var(--text-primary)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Books Finished</h3><p id=\"stats-books-finished\" class=\"text-3xl font-bold\" style=\"color: var(--accent)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Most Active</h3><p id=\"stats-most-active\" class=\"text-sm font-bold leading-snug mt-2\" style=\"color: var(--text-primary)\">–</p></div></div><div class=\"grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8\"><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">Daily Reading Minutes</h3><div class=\"h-64\"><canvas id=\"stats-daily-minutes-chart\"></canvas></div></div><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">Pages per Day</h3><div class=\"h-64\"><canvas id=\"stats-daily-pages-chart\"></canvas></div></div></div><div class=\"grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8\"><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">By Library</h3><div id=\"stats-library-breakdown\" class=\"space-y-3\"><div class=\"text-center py-6 text-sm\" style=\"color: var(--text-secondary)\">Loading…</div></div></div><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">Most Read</h3><div id=\"stats-top-books\" class=\"space-y-3\"><div class=\"text-center py-6 text-sm\" style=\"color: var(--text-secondary)\">Loading…</div></div></div></div><div id=\"stats-empty\" class=\"card p-8 text-center hidden\"><p class=\"text-sm mb-2\" style=\"color: var(--text-primary)\">No reading sessions recorded in this range yet.</p><p class=\"text-xs\" style=\"color: var(--text-secondary)\">Sessions are captured every time a device syncs reading progress (KOReader, the app, the web reader).</p></div></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = ErrorToast("").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</body></html>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = templruntime.GeneratedTemplate
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import { showToast } from "./toast";
|
|
||||||
import { Alpine } from "./alpine";
|
|
||||||
|
|
||||||
async function loadAnalytics(): Promise<void> {
|
|
||||||
const token = localStorage.getItem("token");
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [statsRes, devicesRes, popularRes] = await Promise.all([
|
|
||||||
fetch("/api/analytics/stats", {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
}),
|
|
||||||
fetch("/api/analytics/devices", {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
}),
|
|
||||||
fetch("/api/analytics/popular", {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (statsRes.ok) {
|
|
||||||
const stats: ReadingStatsResponse = await statsRes.json();
|
|
||||||
renderReadingStats(stats);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (devicesRes.ok) {
|
|
||||||
const devices: DeviceUsageResponse = await devicesRes.json();
|
|
||||||
renderDeviceUsage(devices);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (popularRes.ok) {
|
|
||||||
const popular: PopularBooksResponse = await popularRes.json();
|
|
||||||
renderPopularBooks(popular);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load analytics:", error);
|
|
||||||
showToast("Failed to load analytics data", "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderReadingStats(stats: ReadingStatsResponse): void {
|
|
||||||
const container = document.getElementById("reading-stats");
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
<div class="stat-card p-4 rounded-lg" style="background-color: var(--bg-secondary)">
|
|
||||||
<p class="text-2xl font-bold" style="color: var(--text-primary)">${stats.total_books_read}</p>
|
|
||||||
<p class="text-sm" style="color: var(--text-secondary)">Books Read</p>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card p-4 rounded-lg" style="background-color: var(--bg-secondary)">
|
|
||||||
<p class="text-2xl font-bold" style="color: var(--text-primary)">${stats.total_pages_read}</p>
|
|
||||||
<p class="text-sm" style="color: var(--text-secondary)">Pages Read</p>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card p-4 rounded-lg" style="background-color: var(--bg-secondary)">
|
|
||||||
<p class="text-2xl font-bold" style="color: var(--text-primary)">${stats.total_reading_time_minutes}</p>
|
|
||||||
<p class="text-sm" style="color: var(--text-secondary)">Minutes Reading</p>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card p-4 rounded-lg" style="background-color: var(--bg-secondary)">
|
|
||||||
<p class="text-2xl font-bold" style="color: var(--text-primary)">${Math.round(stats.completion_rate * 100)}%</p>
|
|
||||||
<p class="text-sm" style="color: var(--text-secondary)">Completion Rate</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDeviceUsage(devices: DeviceUsageResponse): void {
|
|
||||||
const container = document.getElementById("device-usage");
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
if (!devices.devices || devices.devices.length === 0) {
|
|
||||||
container.innerHTML =
|
|
||||||
'<p style="color: var(--text-secondary)">No device usage data available</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
container.innerHTML = devices.devices
|
|
||||||
.map(
|
|
||||||
(device) => `
|
|
||||||
<div class="p-3 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<div>
|
|
||||||
<p class="font-medium" style="color: var(--text-primary)">${device.device_name}</p>
|
|
||||||
<p class="text-sm" style="color: var(--text-secondary)">${device.device_type}</p>
|
|
||||||
</div>
|
|
||||||
<div class="text-right">
|
|
||||||
<p class="font-medium" style="color: var(--text-primary)">${Math.round(device.total_time_minutes)} min</p>
|
|
||||||
<p class="text-sm" style="color: var(--text-secondary)">${device.sync_count} syncs</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPopularBooks(popular: PopularBooksResponse): void {
|
|
||||||
const container = document.getElementById("popular-books");
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
if (!popular.books || popular.books.length === 0) {
|
|
||||||
container.innerHTML =
|
|
||||||
'<p style="color: var(--text-secondary)">No reading history available</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
container.innerHTML = popular.books
|
|
||||||
.map(
|
|
||||||
(book) => `
|
|
||||||
<div class="p-3 rounded-lg border flex items-center space-x-3" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
|
||||||
<div class="flex-1">
|
|
||||||
<p class="font-medium" style="color: var(--text-primary)">${book.title}</p>
|
|
||||||
<p class="text-sm" style="color: var(--text-secondary)">${book.author}</p>
|
|
||||||
</div>
|
|
||||||
<div class="text-right">
|
|
||||||
<p class="font-medium" style="color: var(--text-primary)">${book.read_count}x</p>
|
|
||||||
<p class="text-sm" style="color: var(--text-secondary)">${Math.round(book.avg_completion * 100)}%</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
export { loadAnalytics };
|
|
||||||
|
|
||||||
Alpine.data("analytics", () => ({
|
|
||||||
loadAnalytics,
|
|
||||||
}));
|
|
||||||
+1
-1
@@ -2,7 +2,6 @@ import "./alpine";
|
|||||||
import { Alpine } from "./alpine";
|
import { Alpine } from "./alpine";
|
||||||
|
|
||||||
import "./admin";
|
import "./admin";
|
||||||
import "./analytics";
|
|
||||||
import "./api";
|
import "./api";
|
||||||
import "./api-explorer";
|
import "./api-explorer";
|
||||||
import "./api-explorer-docs";
|
import "./api-explorer-docs";
|
||||||
@@ -31,6 +30,7 @@ import "./register";
|
|||||||
import "./search";
|
import "./search";
|
||||||
import "./series";
|
import "./series";
|
||||||
import "./setup";
|
import "./setup";
|
||||||
|
import "./stats";
|
||||||
import "./storage";
|
import "./storage";
|
||||||
import "./theme";
|
import "./theme";
|
||||||
import "./toast";
|
import "./toast";
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
import { Alpine } from "./alpine";
|
||||||
|
import { showToast } from "./toast";
|
||||||
|
import type { ChartConfiguration } from "chart.js";
|
||||||
|
|
||||||
|
// Reading statistics page (/stats): one summary fetch per range, charts
|
||||||
|
// hydrated client-side. Chart.js is dynamically imported so the chunk
|
||||||
|
// only loads on this page.
|
||||||
|
|
||||||
|
interface DailyEntry {
|
||||||
|
date: string;
|
||||||
|
minutes: number;
|
||||||
|
pages: number;
|
||||||
|
sessions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Streak {
|
||||||
|
current: number;
|
||||||
|
longest: number;
|
||||||
|
active_today: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TopBook {
|
||||||
|
media_item_id: string;
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
cover_image_path: string;
|
||||||
|
minutes: number;
|
||||||
|
pages: number;
|
||||||
|
sessions: number;
|
||||||
|
last_read: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LibraryRow {
|
||||||
|
library_id: string;
|
||||||
|
name: string;
|
||||||
|
minutes: number;
|
||||||
|
pages: number;
|
||||||
|
sessions: number;
|
||||||
|
books: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Summary {
|
||||||
|
range: string;
|
||||||
|
days: number;
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
total_minutes: number;
|
||||||
|
total_pages: number;
|
||||||
|
session_count: number;
|
||||||
|
books_with_activity: number;
|
||||||
|
books_finished: number;
|
||||||
|
completion_rate: number;
|
||||||
|
average_session_minutes: number;
|
||||||
|
daily: DailyEntry[];
|
||||||
|
streak: Streak;
|
||||||
|
most_active_weekday: string;
|
||||||
|
most_active_day: string;
|
||||||
|
most_active_day_minutes: number;
|
||||||
|
top_books: TopBook[];
|
||||||
|
library_breakdown: LibraryRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChartLike {
|
||||||
|
destroy(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let minutesChart: ChartLike | null = null;
|
||||||
|
let pagesChart: ChartLike | null = null;
|
||||||
|
let currentRange = "7";
|
||||||
|
|
||||||
|
// Page-scoped helpers -----------------------------------------------------
|
||||||
|
|
||||||
|
async function chartCtor(): Promise<new (el: HTMLCanvasElement, cfg: ChartConfiguration) => ChartLike> {
|
||||||
|
const mod = await import("chart.js/auto");
|
||||||
|
return (mod as unknown as { default: new (el: HTMLCanvasElement, cfg: ChartConfiguration) => ChartLike }).default;
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(id: string, value: string): void {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.textContent = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveRange(): string {
|
||||||
|
const btn = document.querySelector(".stats-range-btn.btn-active") as HTMLElement | null;
|
||||||
|
return btn?.dataset.range || "7";
|
||||||
|
}
|
||||||
|
|
||||||
|
function markRangeButtons(active: string): void {
|
||||||
|
document.querySelectorAll<HTMLElement>(".stats-range-btn").forEach((btn) => {
|
||||||
|
const isActive = btn.dataset.range === active;
|
||||||
|
btn.classList.toggle("btn-active", isActive);
|
||||||
|
btn.classList.toggle("opacity-50", !isActive);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRange(range: string): void {
|
||||||
|
markRangeButtons(range);
|
||||||
|
currentRange = range;
|
||||||
|
void loadSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSummary(): Promise<void> {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
document.getElementById("stats-empty")?.classList.add("hidden");
|
||||||
|
const windowLabel = document.getElementById("stats-window-label");
|
||||||
|
if (windowLabel) windowLabel.textContent = "Loading…";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/stats/summary?range=${encodeURIComponent(currentRange)}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (res.status === 400) {
|
||||||
|
const err = await res.json();
|
||||||
|
showToast(err.error || "Invalid range", "error");
|
||||||
|
if (windowLabel) windowLabel.textContent = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(String(res.status));
|
||||||
|
|
||||||
|
renderSummary((await res.json()) as Summary);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load stats:", error);
|
||||||
|
showToast("Failed to load reading stats", "error");
|
||||||
|
if (windowLabel) windowLabel.textContent = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rendering ----------------------------------------------------------------
|
||||||
|
|
||||||
|
function fmtDay(iso: string): string {
|
||||||
|
const parts = iso.split("-");
|
||||||
|
return parts.length >= 3 ? `${parts[1]}-${parts[2]}` : iso;
|
||||||
|
}
|
||||||
|
|
||||||
|
function themeVar(name: string, fallback: string): string {
|
||||||
|
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||||
|
return value || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSummary(summary: Summary): void {
|
||||||
|
const hasData = summary.total_minutes > 0 || summary.session_count > 0;
|
||||||
|
document.getElementById("stats-empty")?.classList.toggle("hidden", hasData);
|
||||||
|
const windowLabel = document.getElementById("stats-window-label");
|
||||||
|
if (windowLabel) windowLabel.textContent = `${summary.start_date} → ${summary.end_date} (${summary.days} days)`;
|
||||||
|
|
||||||
|
const mins = summary.total_minutes;
|
||||||
|
text("stats-total-minutes", mins >= 60 ? `${Math.floor(mins / 60)} h ${mins % 60} min` : `${mins} min`);
|
||||||
|
text("stats-total-pages", `${summary.total_pages}`);
|
||||||
|
text(
|
||||||
|
"stats-streaks",
|
||||||
|
summary.streak.current > 0 || summary.streak.longest > 0
|
||||||
|
? `🔥 ${summary.streak.current} · best ${summary.streak.longest}`
|
||||||
|
: "—",
|
||||||
|
);
|
||||||
|
text(
|
||||||
|
"stats-books-finished",
|
||||||
|
summary.books_with_activity > 0 ? `${summary.books_finished} of ${summary.books_with_activity}` : "–",
|
||||||
|
);
|
||||||
|
|
||||||
|
const mostActive = document.getElementById("stats-most-active");
|
||||||
|
if (mostActive) {
|
||||||
|
if (summary.most_active_day) {
|
||||||
|
mostActive.innerHTML = `${summary.most_active_day} (${summary.most_active_day_minutes} min)<span class="block text-xs font-normal" style="color: ${themeVar("--text-secondary", "#999")}">${summary.most_active_weekday}</span>`;
|
||||||
|
} else {
|
||||||
|
mostActive.textContent = "—";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void renderDailyCharts(summary.daily);
|
||||||
|
renderLibraries(summary.library_breakdown);
|
||||||
|
renderTopBooks(summary.top_books);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderDailyCharts(daily: DailyEntry[]): Promise<void> {
|
||||||
|
const ChartCtor = await chartCtor();
|
||||||
|
|
||||||
|
const accent = themeVar("--accent", "#7aa2f7");
|
||||||
|
const secondary = themeVar("--text-secondary", "#9aa0b5");
|
||||||
|
const labels = daily.map((d) => fmtDay(d.date));
|
||||||
|
const baseOptions: ChartConfiguration["options"] = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
scales: {
|
||||||
|
x: { grid: { display: false }, ticks: { color: secondary, maxTicksLimit: 10 } },
|
||||||
|
y: { grid: { color: "rgba(128,128,128,.15)" }, ticks: { color: secondary }, beginAtZero: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
minutesChart?.destroy();
|
||||||
|
pagesChart?.destroy();
|
||||||
|
minutesChart = null;
|
||||||
|
pagesChart = null;
|
||||||
|
|
||||||
|
const minutesCanvas = document.getElementById("stats-daily-minutes-chart") as HTMLCanvasElement | null;
|
||||||
|
if (minutesCanvas) {
|
||||||
|
minutesChart = new ChartCtor(minutesCanvas, {
|
||||||
|
type: "bar",
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [{ data: daily.map((d) => d.minutes), backgroundColor: accent, borderRadius: 4 }],
|
||||||
|
},
|
||||||
|
options: baseOptions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const pagesCanvas = document.getElementById("stats-daily-pages-chart") as HTMLCanvasElement | null;
|
||||||
|
if (pagesCanvas) {
|
||||||
|
pagesChart = new ChartCtor(pagesCanvas, {
|
||||||
|
type: "line",
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: daily.map((d) => d.pages),
|
||||||
|
borderColor: accent,
|
||||||
|
backgroundColor: `${accent}33`,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.25,
|
||||||
|
pointRadius: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: baseOptions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLibraries(rows: LibraryRow[]): void {
|
||||||
|
const container = document.getElementById("stats-library-breakdown");
|
||||||
|
if (!container) return;
|
||||||
|
if (rows.length === 0) {
|
||||||
|
container.innerHTML = `<p class="text-sm text-center py-6" style="color: var(--text-secondary)">No library activity in this range.</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const max = Math.max(...rows.map((r) => r.minutes), 1);
|
||||||
|
container.innerHTML = rows
|
||||||
|
.map((row) => {
|
||||||
|
const pct = Math.round((row.minutes / max) * 100);
|
||||||
|
const time =
|
||||||
|
row.minutes >= 60
|
||||||
|
? `${Math.floor(row.minutes / 60)}h${row.minutes % 60 ? ` ${(row.minutes % 60)}m` : ""}`
|
||||||
|
: `${row.minutes} min`;
|
||||||
|
return `
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between text-sm mb-1">
|
||||||
|
<span class="font-medium" style="color: var(--text-primary)">${escapeHtml(row.name)}</span>
|
||||||
|
<span style="color: var(--text-secondary)">${time} · ${row.sessions} sessions</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-2 rounded-full overflow-hidden" style="background-color: var(--bg-primary)">
|
||||||
|
<div class="h-full rounded-full" style="width: ${pct}%; background-color: var(--accent)"></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTopBooks(books: TopBook[]): void {
|
||||||
|
const container = document.getElementById("stats-top-books");
|
||||||
|
if (!container) return;
|
||||||
|
if (books.length === 0) {
|
||||||
|
container.innerHTML = `<p class="text-sm text-center py-6" style="color: var(--text-secondary)">No books read in this range.</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = books
|
||||||
|
.map(
|
||||||
|
(book) => `
|
||||||
|
<a href="/media/${book.media_item_id}" class="flex items-center gap-3 p-2 rounded-lg hover:bg-surface-hover">
|
||||||
|
<span class="w-10 h-14 rounded overflow-hidden shrink-0" style="background-color: var(--bg-primary)">
|
||||||
|
<img src="${book.cover_image_path || "/static/placeholder-book.svg"}" alt="" class="w-full h-full object-cover" loading="lazy" onerror="this.style.display='none'"/>
|
||||||
|
</span>
|
||||||
|
<span class="flex-1 min-w-0">
|
||||||
|
<span class="block text-sm font-medium truncate" style="color: var(--text-primary)">${escapeHtml(book.title)}</span>
|
||||||
|
<span class="block text-xs truncate" style="color: var(--text-secondary)">${escapeHtml(book.author || "")}</span>
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-right shrink-0" style="color: var(--text-secondary)">${book.minutes} min<br/>${book.sessions} sessions</span>
|
||||||
|
</a>`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initStatsPage(): void {
|
||||||
|
const initial = new URLSearchParams(window.location.search).get("range") || "7";
|
||||||
|
currentRange = ["7", "30", "90", "year"].includes(initial) ? initial : "7";
|
||||||
|
markRangeButtons(currentRange);
|
||||||
|
void loadSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
export { initStatsPage, loadSummary, renderSummary, selectRange };
|
||||||
|
|
||||||
|
Alpine.data("stats", () => ({
|
||||||
|
initStatsPage,
|
||||||
|
loadSummary,
|
||||||
|
selectRange,
|
||||||
|
}));
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
|
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
|
||||||
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
|
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
|
||||||
Reference in new issue
Block a user