From 00faf9cea8b6566a6bac30ff6a61667633d78480 Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Sat, 2 May 2026 15:32:10 -0400 Subject: [PATCH] feat(pagination): paginate activity and progress lists --- api/ko-routes.go | 5 +- api/v1/activity.go | 37 ++++++-- api/v1/api.gen.go | 15 ++- api/v1/documents.go | 24 +++-- api/v1/openapi.yaml | 24 ++++- api/v1/progress.go | 19 ++-- database/query.sql | 37 ++++++++ database/query.sql.go | 92 ++++++++++++++++++- frontend/src/components/Pagination.tsx | 51 ++++++++++ frontend/src/components/index.ts | 1 + .../src/generated/model/activityResponse.ts | 5 + .../src/generated/model/getActivityParams.ts | 2 +- frontend/src/pages/ActivityPage.tsx | 37 +++++++- frontend/src/pages/DocumentsPage.tsx | 28 ++---- frontend/src/pages/ProgressPage.tsx | 23 ++++- 15 files changed, 341 insertions(+), 59 deletions(-) create mode 100644 frontend/src/components/Pagination.tsx diff --git a/api/ko-routes.go b/api/ko-routes.go index 27ad78d..b02d1d0 100644 --- a/api/ko-routes.go +++ b/api/ko-routes.go @@ -407,7 +407,10 @@ func (api *API) koCheckDocumentsSync(c *gin.Context) { return } - wantedDocs, err := api.db.Queries.GetWantedDocuments(c, string(jsonHaves)) + wantedDocs, err := api.db.Queries.GetWantedDocuments(c, database.GetWantedDocumentsParams{ + JsonEach: string(jsonHaves), + DocumentIds: string(jsonHaves), + }) if err != nil { log.Error("GetWantedDocuments DB Error", err) apiErrorPage(c, http.StatusBadRequest, "Invalid Request") diff --git a/api/v1/activity.go b/api/v1/activity.go index c9fac7a..3466a04 100644 --- a/api/v1/activity.go +++ b/api/v1/activity.go @@ -25,12 +25,12 @@ func (s *Server) GetActivity(ctx context.Context, request GetActivityRequestObje documentID = *request.Params.DocumentId } - offset := int64(0) - if request.Params.Offset != nil { - offset = *request.Params.Offset + page := int64(1) + if request.Params.Page != nil { + page = *request.Params.Page } - limit := int64(100) + limit := int64(25) if request.Params.Limit != nil { limit = *request.Params.Limit } @@ -39,13 +39,33 @@ func (s *Server) GetActivity(ctx context.Context, request GetActivityRequestObje UserID: auth.UserName, DocFilter: docFilter, DocumentID: documentID, - Offset: offset, + Offset: (page - 1) * limit, Limit: limit, }) if err != nil { return GetActivity500JSONResponse{Code: 500, Message: err.Error()}, nil } + // Get Total Count + total, err := s.db.Queries.GetActivityCount(ctx, database.GetActivityCountParams{ + UserID: auth.UserName, + DocFilter: docFilter, + DocumentID: documentID, + }) + if err != nil { + return GetActivity500JSONResponse{Code: 500, Message: err.Error()}, nil + } + + // Calculate Pagination + var nextPage *int64 + var previousPage *int64 + if page*limit < total { + nextPage = ptrOf(page + 1) + } + if page > 1 { + previousPage = ptrOf(page - 1) + } + apiActivities := make([]Activity, len(activities)) for i, a := range activities { // Convert StartTime from interface{} to string @@ -70,7 +90,12 @@ func (s *Server) GetActivity(ctx context.Context, request GetActivityRequestObje } response := ActivityResponse{ - Activities: apiActivities, + Activities: apiActivities, + Page: page, + Limit: limit, + Total: total, + NextPage: nextPage, + PreviousPage: previousPage, } return GetActivity200JSONResponse(response), nil } diff --git a/api/v1/api.gen.go b/api/v1/api.gen.go index 7a3dba6..cba8e34 100644 --- a/api/v1/api.gen.go +++ b/api/v1/api.gen.go @@ -158,7 +158,12 @@ type Activity struct { // ActivityResponse defines model for ActivityResponse. type ActivityResponse struct { - Activities []Activity `json:"activities"` + Activities []Activity `json:"activities"` + Limit int64 `json:"limit"` + NextPage *int64 `json:"next_page,omitempty"` + Page int64 `json:"page"` + PreviousPage *int64 `json:"previous_page,omitempty"` + Total int64 `json:"total"` } // BackupType defines model for BackupType. @@ -470,7 +475,7 @@ type UsersResponse struct { type GetActivityParams struct { DocFilter *bool `form:"doc_filter,omitempty" json:"doc_filter,omitempty"` DocumentId *string `form:"document_id,omitempty" json:"document_id,omitempty"` - Offset *int64 `form:"offset,omitempty" json:"offset,omitempty"` + Page *int64 `form:"page,omitempty" json:"page,omitempty"` Limit *int64 `form:"limit,omitempty" json:"limit,omitempty"` } @@ -740,11 +745,11 @@ func (siw *ServerInterfaceWrapper) GetActivity(w http.ResponseWriter, r *http.Re return } - // ------------- Optional query parameter "offset" ------------- + // ------------- Optional query parameter "page" ------------- - err = runtime.BindQueryParameterWithOptions("form", true, false, "offset", r.URL.Query(), ¶ms.Offset, runtime.BindQueryParameterOptions{Type: "integer", Format: "int64"}) + err = runtime.BindQueryParameterWithOptions("form", true, false, "page", r.URL.Query(), ¶ms.Page, runtime.BindQueryParameterOptions{Type: "integer", Format: "int64"}) if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "offset", Err: err}) + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "page", Err: err}) return } diff --git a/api/v1/documents.go b/api/v1/documents.go index 242ed6e..c1273fb 100644 --- a/api/v1/documents.go +++ b/api/v1/documents.go @@ -33,16 +33,16 @@ func (s *Server) GetDocuments(ctx context.Context, request GetDocumentsRequestOb limit = *request.Params.Limit } - search := "" - if request.Params.Search != nil { - search = "%" + *request.Params.Search + "%" + var search *string + if request.Params.Search != nil && *request.Params.Search != "" { + search = ptrOf("%" + *request.Params.Search + "%") } rows, err := s.db.Queries.GetDocumentsWithStats( ctx, database.GetDocumentsWithStatsParams{ UserID: auth.UserName, - Query: &search, + Query: search, Deleted: ptrOf(false), Offset: (page - 1) * limit, Limit: limit, @@ -52,7 +52,19 @@ func (s *Server) GetDocuments(ctx context.Context, request GetDocumentsRequestOb return GetDocuments500JSONResponse{Code: 500, Message: err.Error()}, nil } - total := int64(len(rows)) + // Get Total Count + total, err := s.db.Queries.GetDocumentsWithStatsCount( + ctx, + database.GetDocumentsWithStatsCountParams{ + Query: search, + Deleted: ptrOf(false), + }, + ) + if err != nil { + return GetDocuments500JSONResponse{Code: 500, Message: err.Error()}, nil + } + + // Calculate Pagination var nextPage *int64 var previousPage *int64 if page*limit < total { @@ -219,7 +231,6 @@ func (s *Server) EditDocument(ctx context.Context, request EditDocumentRequestOb doc := docs[0] - apiDoc := Document{ Id: doc.ID, Title: *doc.Title, @@ -561,7 +572,6 @@ func (s *Server) UploadDocumentCover(ctx context.Context, request UploadDocument doc := docs[0] - apiDoc := Document{ Id: doc.ID, Title: *doc.Title, diff --git a/api/v1/openapi.yaml b/api/v1/openapi.yaml index 5dc92a4..d864d0f 100644 --- a/api/v1/openapi.yaml +++ b/api/v1/openapi.yaml @@ -350,8 +350,26 @@ components: type: array items: $ref: '#/components/schemas/Activity' + page: + type: integer + format: int64 + limit: + type: integer + format: int64 + next_page: + type: integer + format: int64 + previous_page: + type: integer + format: int64 + total: + type: integer + format: int64 required: - activities + - page + - limit + - total Device: type: object @@ -1174,18 +1192,18 @@ paths: in: query schema: type: string - - name: offset + - name: page in: query schema: type: integer format: int64 - default: 0 + default: 1 - name: limit in: query schema: type: integer format: int64 - default: 100 + default: 25 security: - BearerAuth: [] responses: diff --git a/api/v1/progress.go b/api/v1/progress.go index 5461b28..bc725af 100644 --- a/api/v1/progress.go +++ b/api/v1/progress.go @@ -2,7 +2,6 @@ package v1 import ( "context" - "math" "time" log "github.com/sirupsen/logrus" @@ -43,13 +42,21 @@ func (s *Server) GetProgressList(ctx context.Context, request GetProgressListReq return GetProgressList500JSONResponse{Code: 500, Message: "Database error"}, nil } - total := int64(len(progress)) + // Get Total Count + total, err := s.db.Queries.GetProgressCount(ctx, database.GetProgressCountParams{ + UserID: auth.UserName, + DocFilter: filter.DocFilter, + DocumentID: filter.DocumentID, + }) + if err != nil { + log.Error("GetProgressCount DB Error:", err) + return GetProgressList500JSONResponse{Code: 500, Message: "Database error"}, nil + } + + // Calculate Pagination var nextPage *int64 var previousPage *int64 - - // Calculate total pages - totalPages := int64(math.Ceil(float64(total) / float64(limit))) - if page < totalPages { + if page*limit < total { nextPage = ptrOf(page + 1) } if page > 1 { diff --git a/database/query.sql b/database/query.sql index 1929773..5bd28ce 100644 --- a/database/query.sql +++ b/database/query.sql @@ -396,3 +396,40 @@ SET isbn10 = COALESCE(excluded.isbn10, isbn10), isbn13 = COALESCE(excluded.isbn13, isbn13) RETURNING *; + +-- name: GetDocumentsWithStatsCount :one +SELECT COUNT(*) AS count +FROM documents AS docs +WHERE + (docs.id = sqlc.narg('id') OR $id IS NULL) + AND (docs.deleted = sqlc.narg(deleted) OR $deleted IS NULL) + AND ( + ( + docs.title LIKE sqlc.narg('query') OR + docs.author LIKE $query + ) OR $query IS NULL + ); + +-- name: GetProgressCount :one +SELECT COUNT(*) AS count +FROM document_progress AS progress +WHERE + progress.user_id = $user_id + AND ( + ( + CAST($doc_filter AS BOOLEAN) = TRUE + AND document_id = $document_id + ) OR $doc_filter = FALSE + ); + +-- name: GetActivityCount :one +SELECT COUNT(*) AS count +FROM activity +WHERE + activity.user_id = $user_id + AND ( + ( + CAST($doc_filter AS BOOLEAN) = TRUE + AND document_id = $document_id + ) OR $doc_filter = FALSE + ); diff --git a/database/query.sql.go b/database/query.sql.go index 1f5acb3..f5d612b 100644 --- a/database/query.sql.go +++ b/database/query.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.29.0 +// sqlc v1.31.1 // source: query.sql package database @@ -264,6 +264,32 @@ func (q *Queries) GetActivity(ctx context.Context, arg GetActivityParams) ([]Get return items, nil } +const getActivityCount = `-- name: GetActivityCount :one +SELECT COUNT(*) AS count +FROM activity +WHERE + activity.user_id = ?1 + AND ( + ( + CAST(?2 AS BOOLEAN) = TRUE + AND document_id = ?3 + ) OR ?2 = FALSE + ) +` + +type GetActivityCountParams struct { + UserID string `json:"user_id"` + DocFilter bool `json:"doc_filter"` + DocumentID string `json:"document_id"` +} + +func (q *Queries) GetActivityCount(ctx context.Context, arg GetActivityCountParams) (int64, error) { + row := q.db.QueryRowContext(ctx, getActivityCount, arg.UserID, arg.DocFilter, arg.DocumentID) + var count int64 + err := row.Scan(&count) + return count, err +} + const getDailyReadStats = `-- name: GetDailyReadStats :many WITH RECURSIVE last_30_days AS ( SELECT LOCAL_DATE(STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now'), timezone) AS date @@ -734,6 +760,33 @@ func (q *Queries) GetDocumentsWithStats(ctx context.Context, arg GetDocumentsWit return items, nil } +const getDocumentsWithStatsCount = `-- name: GetDocumentsWithStatsCount :one +SELECT COUNT(*) AS count +FROM documents AS docs +WHERE + (docs.id = ?1 OR ?1 IS NULL) + AND (docs.deleted = ?2 OR ?2 IS NULL) + AND ( + ( + docs.title LIKE ?3 OR + docs.author LIKE ?3 + ) OR ?3 IS NULL + ) +` + +type GetDocumentsWithStatsCountParams struct { + ID *string `json:"id"` + Deleted *bool `json:"-"` + Query *string `json:"query"` +} + +func (q *Queries) GetDocumentsWithStatsCount(ctx context.Context, arg GetDocumentsWithStatsCountParams) (int64, error) { + row := q.db.QueryRowContext(ctx, getDocumentsWithStatsCount, arg.ID, arg.Deleted, arg.Query) + var count int64 + err := row.Scan(&count) + return count, err +} + const getLastActivity = `-- name: GetLastActivity :one SELECT start_time FROM activity @@ -897,6 +950,32 @@ func (q *Queries) GetProgress(ctx context.Context, arg GetProgressParams) ([]Get return items, nil } +const getProgressCount = `-- name: GetProgressCount :one +SELECT COUNT(*) AS count +FROM document_progress AS progress +WHERE + progress.user_id = ?1 + AND ( + ( + CAST(?2 AS BOOLEAN) = TRUE + AND document_id = ?3 + ) OR ?2 = FALSE + ) +` + +type GetProgressCountParams struct { + UserID string `json:"user_id"` + DocFilter bool `json:"doc_filter"` + DocumentID string `json:"document_id"` +} + +func (q *Queries) GetProgressCount(ctx context.Context, arg GetProgressCountParams) (int64, error) { + row := q.db.QueryRowContext(ctx, getProgressCount, arg.UserID, arg.DocFilter, arg.DocumentID) + var count int64 + err := row.Scan(&count) + return count, err +} + const getUser = `-- name: GetUser :one SELECT id, pass, auth_hash, admin, timezone, created_at FROM users WHERE id = ?1 LIMIT 1 @@ -1088,17 +1167,22 @@ WHERE ( AND documents.filepath IS NULL ) OR (documents.id IS NULL) -OR CAST(?1 AS TEXT) != CAST(?1 AS TEXT) +OR CAST(?2 AS TEXT) != CAST(?2 AS TEXT) ` +type GetWantedDocumentsParams struct { + JsonEach interface{} `json:"json_each"` + DocumentIds string `json:"document_ids"` +} + type GetWantedDocumentsRow struct { ID string `json:"id"` WantFile bool `json:"want_file"` WantMetadata bool `json:"want_metadata"` } -func (q *Queries) GetWantedDocuments(ctx context.Context, documentIds string) ([]GetWantedDocumentsRow, error) { - rows, err := q.db.QueryContext(ctx, getWantedDocuments, documentIds) +func (q *Queries) GetWantedDocuments(ctx context.Context, arg GetWantedDocumentsParams) ([]GetWantedDocumentsRow, error) { + rows, err := q.db.QueryContext(ctx, getWantedDocuments, arg.JsonEach, arg.DocumentIds) if err != nil { return nil, err } diff --git a/frontend/src/components/Pagination.tsx b/frontend/src/components/Pagination.tsx new file mode 100644 index 0000000..efe63e6 --- /dev/null +++ b/frontend/src/components/Pagination.tsx @@ -0,0 +1,51 @@ +interface PaginationProps { + page: number; + previousPage?: number; + nextPage?: number; + total?: number; + limit?: number; + onPageChange: (page: number) => void; +} + +export function Pagination({ + page, + previousPage, + nextPage, + total, + limit, + onPageChange, +}: PaginationProps) { + if (!previousPage && !nextPage) { + return null; + } + + const totalPages = total && limit ? Math.ceil(total / limit) : undefined; + + return ( +
+ {previousPage && previousPage > 0 ? ( + + ) : null} + {totalPages ? ( + + Page {page} of {totalPages} + + ) : null} + {nextPage && nextPage > 0 ? ( + + ) : null} +
+ ); +} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index cc324ff..a87615a 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -18,6 +18,7 @@ export { InlineLoader, } from './Skeleton'; export { LoadingState } from './LoadingState'; +export { Pagination } from './Pagination'; // Field components export { Field, FieldLabel, FieldValue, FieldActions } from './Field'; diff --git a/frontend/src/generated/model/activityResponse.ts b/frontend/src/generated/model/activityResponse.ts index 14032e6..1bfaf85 100644 --- a/frontend/src/generated/model/activityResponse.ts +++ b/frontend/src/generated/model/activityResponse.ts @@ -9,4 +9,9 @@ import type { Activity } from './activity'; export interface ActivityResponse { activities: Activity[]; + page: number; + limit: number; + next_page?: number; + previous_page?: number; + total: number; } diff --git a/frontend/src/generated/model/getActivityParams.ts b/frontend/src/generated/model/getActivityParams.ts index d89aa21..2d93234 100644 --- a/frontend/src/generated/model/getActivityParams.ts +++ b/frontend/src/generated/model/getActivityParams.ts @@ -9,6 +9,6 @@ export type GetActivityParams = { doc_filter?: boolean; document_id?: string; -offset?: number; +page?: number; limit?: number; }; diff --git a/frontend/src/pages/ActivityPage.tsx b/frontend/src/pages/ActivityPage.tsx index 0699d04..f91d78b 100644 --- a/frontend/src/pages/ActivityPage.tsx +++ b/frontend/src/pages/ActivityPage.tsx @@ -1,12 +1,29 @@ -import { Link } from 'react-router-dom'; +import { useEffect, useState } from 'react'; +import { Link, useSearchParams } from 'react-router-dom'; import { useGetActivity } from '../generated/anthoLumeAPIV1'; import type { Activity } from '../generated/model'; +import { Pagination } from '../components'; import { Table, type Column } from '../components/Table'; import { formatDuration } from '../utils/formatters'; export default function ActivityPage() { - const { data, isLoading } = useGetActivity({ offset: 0, limit: 100 }); - const activities = data?.status === 200 ? data.data.activities : []; + const [searchParams] = useSearchParams(); + const documentID = searchParams.get('document') || undefined; + const [page, setPage] = useState(1); + const limit = 25; + + useEffect(() => { + setPage(1); + }, [documentID]); + + const { data, isLoading } = useGetActivity({ + doc_filter: Boolean(documentID), + document_id: documentID, + page, + limit, + }); + const response = data?.status === 200 ? data.data : undefined; + const activities = response?.activities ?? []; const columns: Column[] = [ { @@ -35,5 +52,17 @@ export default function ActivityPage() { }, ]; - return ; + return ( +
+
+ + + ); } diff --git a/frontend/src/pages/DocumentsPage.tsx b/frontend/src/pages/DocumentsPage.tsx index db11b0f..9578e58 100644 --- a/frontend/src/pages/DocumentsPage.tsx +++ b/frontend/src/pages/DocumentsPage.tsx @@ -3,7 +3,7 @@ import { Link, useNavigate } from 'react-router-dom'; import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1'; import type { Document, DocumentsResponse } from '../generated/model'; import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons'; -import { LoadingState } from '../components'; +import { LoadingState, Pagination } from '../components'; import { useToasts } from '../components/ToastContext'; import { formatDuration } from '../utils/formatters'; import { useDebounce } from '../hooks/useDebounce'; @@ -272,24 +272,14 @@ export default function DocumentsPage() { )} -
- {previousPage && previousPage > 0 && ( - - )} - {nextPage && nextPage > 0 && ( - - )} -
+
[] = [ { @@ -35,5 +40,17 @@ export default function ProgressPage() { }, ]; - return
; + return ( +
+
+ + + ); }