diff --git a/internal/handlers/progress.go b/internal/handlers/progress.go
index 3d9dfe7..a9b4c00 100644
--- a/internal/handlers/progress.go
+++ b/internal/handlers/progress.go
@@ -6,6 +6,7 @@ import (
"context"
"net/http"
"strconv"
+ "time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
@@ -226,3 +227,138 @@ func (h *Handler) GetProgressHistory(c echo.Context) error {
"count": len(history),
})
}
+
+type ProgressWithMedia struct {
+ MediaItemID uuid.UUID
+ Title string
+ Author string
+ CoverImagePath string
+ Percentage float64
+ CurrentPage int32
+ TotalPages int32
+ LastReadAt time.Time
+ Epubcfi string
+ LastSyncDevice string
+}
+
+// GetAllProgress retrieves all progress for a user with sync source info
+func (h *Handler) GetAllProgress(c echo.Context) error {
+ user := MustGetAuthenticatedUser(c)
+
+ progressList := []ProgressWithMedia{}
+
+ mediaItems, err := h.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{
+ Limit: 1000, Offset: 0,
+ })
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, "failed to get media items")
+ }
+
+ for _, mediaItem := range mediaItems {
+ progress, err := h.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{
+ MediaItemID: mediaItem.ID,
+ UserID: user.ID,
+ })
+ if err != nil {
+ continue
+ }
+
+ coverPath := ""
+ if mediaItem.CoverImagePath.Valid {
+ coverPath = mediaItem.CoverImagePath.String
+ }
+
+ author := ""
+ if mediaItem.Author.Valid {
+ author = mediaItem.Author.String
+ }
+
+ epubcfi := ""
+ if progress.Epubcfi.Valid {
+ epubcfi = progress.Epubcfi.String
+ }
+
+ deviceName := ""
+ if progress.LastSyncDevice.Valid {
+ deviceName = progress.LastSyncDevice.String
+ }
+
+ progressList = append(progressList, ProgressWithMedia{
+ MediaItemID: progress.MediaItemID.Bytes,
+ Title: mediaItem.Title,
+ Author: author,
+ CoverImagePath: coverPath,
+ Percentage: progress.Percentage.Float64,
+ CurrentPage: progress.CurrentPage.Int32,
+ TotalPages: progress.TotalPages.Int32,
+ LastReadAt: progress.LastReadAt.Time,
+ Epubcfi: epubcfi,
+ LastSyncDevice: deviceName,
+ })
+ }
+
+ return c.JSON(http.StatusOK, map[string]interface{}{
+ "progress": progressList,
+ "total": len(progressList),
+ })
+}
+
+func (h *Handler) GetAllProgressData(c echo.Context) ([]ProgressWithMedia, error) {
+ user := MustGetAuthenticatedUser(c)
+
+ progressList := []ProgressWithMedia{}
+
+ mediaItems, err := h.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{
+ Limit: 1000,
+ Offset: 0,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ for _, mediaItem := range mediaItems {
+ progress, err := h.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{
+ MediaItemID: mediaItem.ID,
+ UserID: user.ID,
+ })
+ if err != nil {
+ continue
+ }
+
+ coverPath := ""
+ if mediaItem.CoverImagePath.Valid {
+ coverPath = mediaItem.CoverImagePath.String
+ }
+
+ author := ""
+ if mediaItem.Author.Valid {
+ author = mediaItem.Author.String
+ }
+
+ epubcfi := ""
+ if progress.Epubcfi.Valid {
+ epubcfi = progress.Epubcfi.String
+ }
+
+ deviceName := ""
+ if progress.LastSyncDevice.Valid {
+ deviceName = progress.LastSyncDevice.String
+ }
+
+ progressList = append(progressList, ProgressWithMedia{
+ MediaItemID: progress.MediaItemID.Bytes,
+ Title: mediaItem.Title,
+ Author: author,
+ CoverImagePath: coverPath,
+ Percentage: progress.Percentage.Float64,
+ CurrentPage: progress.CurrentPage.Int32,
+ TotalPages: progress.TotalPages.Int32,
+ LastReadAt: progress.LastReadAt.Time,
+ Epubcfi: epubcfi,
+ LastSyncDevice: deviceName,
+ })
+ }
+
+ return progressList, nil
+}
+
diff --git a/templates/progress.templ b/templates/progress.templ
new file mode 100644
index 0000000..999cdda
--- /dev/null
+++ b/templates/progress.templ
@@ -0,0 +1,111 @@
+package templates
+
+templ Progress(user User, progressData []ProgressItemData) {
+
+
+
+
+
+ Reading Progress - Bookmann
+
+
+
+
+
+
+ @Header(user, "/progress")
+
+
+
+
Reading Progress
+
Track your reading progress across all devices
+
+
+
+ if len(progressData) == 0 {
+
+
📖
+
No Progress Yet
+
Start reading a book to track your progress
+
+ }
+
+ for _, item := range progressData {
+
+
+
+

+
+
+
+
+
{ item.Title }
+ if item.Author != "" {
+
by { item.Author }
+ }
+
+
+ { item.ProgressPercentage }%
+
+
+
+
+
+
+
+
Current Position
+
+ { item.CurrentPage } / { item.TotalPages }
+
+
+
+
Last Updated
+
{ item.LastUpdated }
+
+
+
Synced From
+
+ { item.DeviceIcon }
+ { item.DeviceName }
+
+
+
+
+ if item.DeviceIcon != "" {
+
+
+ 🔄
+ Last sync from { item.DeviceName }
+ ({ item.DeviceType })
+
+
+ }
+
+ if item.EpubCFI != "" {
+
+ 📍
+ CFI: { item.EpubCFI }
+
+ }
+
+
+
+ }
+
+
+
+
+
+
+}
diff --git a/templates/progress_templ.go b/templates/progress_templ.go
new file mode 100644
index 0000000..b2d73df
--- /dev/null
+++ b/templates/progress_templ.go
@@ -0,0 +1,233 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.977
+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 Progress(user User, progressData []ProgressItemData) 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, "Reading Progress - Bookmann")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = Header(user, "/progress").Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "Reading Progress
Track your reading progress across all devices
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if len(progressData) == 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
📖
No Progress Yet
Start reading a book to track your progress
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ for _, item := range progressData {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "

")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var2 string
+ templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 44, Col: 121}
+ }
+ _, 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, 5, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if item.Author != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
by ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 46, Col: 116}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var4 string
+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(item.ProgressPercentage)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 50, Col: 127}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "%
Current Position
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.CurrentPage)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 64, Col: 62}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " / ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var6 string
+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.TotalPages)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 64, Col: 84}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
Last Updated
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var7 string
+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.LastUpdated)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 69, Col: 96}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
Synced From
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var8 string
+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceIcon)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 74, Col: 83}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var9 string
+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceName)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 75, Col: 102}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if item.DeviceIcon != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
🔄 Last sync from ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var10 string
+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceName)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 84, Col: 90}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " (")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var11 string
+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceType)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 85, Col: 68}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, ")
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if item.EpubCFI != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
📍 CFI: ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var12 string
+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(item.EpubCFI)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 93, Col: 65}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate