feat(pagination): paginate activity and progress lists

This commit is contained in:
2026-05-02 15:32:10 -04:00
parent 75c872264f
commit 00faf9cea8
15 changed files with 341 additions and 59 deletions

View File

@@ -407,7 +407,10 @@ func (api *API) koCheckDocumentsSync(c *gin.Context) {
return 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 { if err != nil {
log.Error("GetWantedDocuments DB Error", err) log.Error("GetWantedDocuments DB Error", err)
apiErrorPage(c, http.StatusBadRequest, "Invalid Request") apiErrorPage(c, http.StatusBadRequest, "Invalid Request")

View File

@@ -25,12 +25,12 @@ func (s *Server) GetActivity(ctx context.Context, request GetActivityRequestObje
documentID = *request.Params.DocumentId documentID = *request.Params.DocumentId
} }
offset := int64(0) page := int64(1)
if request.Params.Offset != nil { if request.Params.Page != nil {
offset = *request.Params.Offset page = *request.Params.Page
} }
limit := int64(100) limit := int64(25)
if request.Params.Limit != nil { if request.Params.Limit != nil {
limit = *request.Params.Limit limit = *request.Params.Limit
} }
@@ -39,13 +39,33 @@ func (s *Server) GetActivity(ctx context.Context, request GetActivityRequestObje
UserID: auth.UserName, UserID: auth.UserName,
DocFilter: docFilter, DocFilter: docFilter,
DocumentID: documentID, DocumentID: documentID,
Offset: offset, Offset: (page - 1) * limit,
Limit: limit, Limit: limit,
}) })
if err != nil { if err != nil {
return GetActivity500JSONResponse{Code: 500, Message: err.Error()}, 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)) apiActivities := make([]Activity, len(activities))
for i, a := range activities { for i, a := range activities {
// Convert StartTime from interface{} to string // Convert StartTime from interface{} to string
@@ -70,7 +90,12 @@ func (s *Server) GetActivity(ctx context.Context, request GetActivityRequestObje
} }
response := ActivityResponse{ response := ActivityResponse{
Activities: apiActivities, Activities: apiActivities,
Page: page,
Limit: limit,
Total: total,
NextPage: nextPage,
PreviousPage: previousPage,
} }
return GetActivity200JSONResponse(response), nil return GetActivity200JSONResponse(response), nil
} }

View File

@@ -158,7 +158,12 @@ type Activity struct {
// ActivityResponse defines model for ActivityResponse. // ActivityResponse defines model for ActivityResponse.
type ActivityResponse struct { 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. // BackupType defines model for BackupType.
@@ -470,7 +475,7 @@ type UsersResponse struct {
type GetActivityParams struct { type GetActivityParams struct {
DocFilter *bool `form:"doc_filter,omitempty" json:"doc_filter,omitempty"` DocFilter *bool `form:"doc_filter,omitempty" json:"doc_filter,omitempty"`
DocumentId *string `form:"document_id,omitempty" json:"document_id,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"` Limit *int64 `form:"limit,omitempty" json:"limit,omitempty"`
} }
@@ -740,11 +745,11 @@ func (siw *ServerInterfaceWrapper) GetActivity(w http.ResponseWriter, r *http.Re
return return
} }
// ------------- Optional query parameter "offset" ------------- // ------------- Optional query parameter "page" -------------
err = runtime.BindQueryParameterWithOptions("form", true, false, "offset", r.URL.Query(), &params.Offset, runtime.BindQueryParameterOptions{Type: "integer", Format: "int64"}) err = runtime.BindQueryParameterWithOptions("form", true, false, "page", r.URL.Query(), &params.Page, runtime.BindQueryParameterOptions{Type: "integer", Format: "int64"})
if err != nil { if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "offset", Err: err}) siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "page", Err: err})
return return
} }

View File

@@ -33,16 +33,16 @@ func (s *Server) GetDocuments(ctx context.Context, request GetDocumentsRequestOb
limit = *request.Params.Limit limit = *request.Params.Limit
} }
search := "" var search *string
if request.Params.Search != nil { if request.Params.Search != nil && *request.Params.Search != "" {
search = "%" + *request.Params.Search + "%" search = ptrOf("%" + *request.Params.Search + "%")
} }
rows, err := s.db.Queries.GetDocumentsWithStats( rows, err := s.db.Queries.GetDocumentsWithStats(
ctx, ctx,
database.GetDocumentsWithStatsParams{ database.GetDocumentsWithStatsParams{
UserID: auth.UserName, UserID: auth.UserName,
Query: &search, Query: search,
Deleted: ptrOf(false), Deleted: ptrOf(false),
Offset: (page - 1) * limit, Offset: (page - 1) * limit,
Limit: limit, Limit: limit,
@@ -52,7 +52,19 @@ func (s *Server) GetDocuments(ctx context.Context, request GetDocumentsRequestOb
return GetDocuments500JSONResponse{Code: 500, Message: err.Error()}, nil 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 nextPage *int64
var previousPage *int64 var previousPage *int64
if page*limit < total { if page*limit < total {
@@ -219,7 +231,6 @@ func (s *Server) EditDocument(ctx context.Context, request EditDocumentRequestOb
doc := docs[0] doc := docs[0]
apiDoc := Document{ apiDoc := Document{
Id: doc.ID, Id: doc.ID,
Title: *doc.Title, Title: *doc.Title,
@@ -561,7 +572,6 @@ func (s *Server) UploadDocumentCover(ctx context.Context, request UploadDocument
doc := docs[0] doc := docs[0]
apiDoc := Document{ apiDoc := Document{
Id: doc.ID, Id: doc.ID,
Title: *doc.Title, Title: *doc.Title,

View File

@@ -350,8 +350,26 @@ components:
type: array type: array
items: items:
$ref: '#/components/schemas/Activity' $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: required:
- activities - activities
- page
- limit
- total
Device: Device:
type: object type: object
@@ -1174,18 +1192,18 @@ paths:
in: query in: query
schema: schema:
type: string type: string
- name: offset - name: page
in: query in: query
schema: schema:
type: integer type: integer
format: int64 format: int64
default: 0 default: 1
- name: limit - name: limit
in: query in: query
schema: schema:
type: integer type: integer
format: int64 format: int64
default: 100 default: 25
security: security:
- BearerAuth: [] - BearerAuth: []
responses: responses:

View File

@@ -2,7 +2,6 @@ package v1
import ( import (
"context" "context"
"math"
"time" "time"
log "github.com/sirupsen/logrus" 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 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 nextPage *int64
var previousPage *int64 var previousPage *int64
if page*limit < total {
// Calculate total pages
totalPages := int64(math.Ceil(float64(total) / float64(limit)))
if page < totalPages {
nextPage = ptrOf(page + 1) nextPage = ptrOf(page + 1)
} }
if page > 1 { if page > 1 {

View File

@@ -396,3 +396,40 @@ SET
isbn10 = COALESCE(excluded.isbn10, isbn10), isbn10 = COALESCE(excluded.isbn10, isbn10),
isbn13 = COALESCE(excluded.isbn13, isbn13) isbn13 = COALESCE(excluded.isbn13, isbn13)
RETURNING *; 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
);

View File

@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.29.0 // sqlc v1.31.1
// source: query.sql // source: query.sql
package database package database
@@ -264,6 +264,32 @@ func (q *Queries) GetActivity(ctx context.Context, arg GetActivityParams) ([]Get
return items, nil 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 const getDailyReadStats = `-- name: GetDailyReadStats :many
WITH RECURSIVE last_30_days AS ( WITH RECURSIVE last_30_days AS (
SELECT LOCAL_DATE(STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now'), timezone) AS date 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 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 const getLastActivity = `-- name: GetLastActivity :one
SELECT start_time SELECT start_time
FROM activity FROM activity
@@ -897,6 +950,32 @@ func (q *Queries) GetProgress(ctx context.Context, arg GetProgressParams) ([]Get
return items, nil 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 const getUser = `-- name: GetUser :one
SELECT id, pass, auth_hash, admin, timezone, created_at FROM users SELECT id, pass, auth_hash, admin, timezone, created_at FROM users
WHERE id = ?1 LIMIT 1 WHERE id = ?1 LIMIT 1
@@ -1088,17 +1167,22 @@ WHERE (
AND documents.filepath IS NULL AND documents.filepath IS NULL
) )
OR (documents.id 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 { type GetWantedDocumentsRow struct {
ID string `json:"id"` ID string `json:"id"`
WantFile bool `json:"want_file"` WantFile bool `json:"want_file"`
WantMetadata bool `json:"want_metadata"` WantMetadata bool `json:"want_metadata"`
} }
func (q *Queries) GetWantedDocuments(ctx context.Context, documentIds string) ([]GetWantedDocumentsRow, error) { func (q *Queries) GetWantedDocuments(ctx context.Context, arg GetWantedDocumentsParams) ([]GetWantedDocumentsRow, error) {
rows, err := q.db.QueryContext(ctx, getWantedDocuments, documentIds) rows, err := q.db.QueryContext(ctx, getWantedDocuments, arg.JsonEach, arg.DocumentIds)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -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 (
<div className="mt-4 flex w-full items-center justify-center gap-4 text-content">
{previousPage && previousPage > 0 ? (
<button
type="button"
onClick={() => onPageChange(previousPage)}
className="w-24 rounded bg-surface p-2 text-center text-sm font-medium shadow-lg hover:bg-surface-strong focus:outline-none"
>
</button>
) : null}
{totalPages ? (
<span className="text-sm text-content-muted">
Page {page} of {totalPages}
</span>
) : null}
{nextPage && nextPage > 0 ? (
<button
type="button"
onClick={() => onPageChange(nextPage)}
className="w-24 rounded bg-surface p-2 text-center text-sm font-medium shadow-lg hover:bg-surface-strong focus:outline-none"
>
</button>
) : null}
</div>
);
}

View File

@@ -18,6 +18,7 @@ export {
InlineLoader, InlineLoader,
} from './Skeleton'; } from './Skeleton';
export { LoadingState } from './LoadingState'; export { LoadingState } from './LoadingState';
export { Pagination } from './Pagination';
// Field components // Field components
export { Field, FieldLabel, FieldValue, FieldActions } from './Field'; export { Field, FieldLabel, FieldValue, FieldActions } from './Field';

View File

@@ -9,4 +9,9 @@ import type { Activity } from './activity';
export interface ActivityResponse { export interface ActivityResponse {
activities: Activity[]; activities: Activity[];
page: number;
limit: number;
next_page?: number;
previous_page?: number;
total: number;
} }

View File

@@ -9,6 +9,6 @@
export type GetActivityParams = { export type GetActivityParams = {
doc_filter?: boolean; doc_filter?: boolean;
document_id?: string; document_id?: string;
offset?: number; page?: number;
limit?: number; limit?: number;
}; };

View File

@@ -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 { useGetActivity } from '../generated/anthoLumeAPIV1';
import type { Activity } from '../generated/model'; import type { Activity } from '../generated/model';
import { Pagination } from '../components';
import { Table, type Column } from '../components/Table'; import { Table, type Column } from '../components/Table';
import { formatDuration } from '../utils/formatters'; import { formatDuration } from '../utils/formatters';
export default function ActivityPage() { export default function ActivityPage() {
const { data, isLoading } = useGetActivity({ offset: 0, limit: 100 }); const [searchParams] = useSearchParams();
const activities = data?.status === 200 ? data.data.activities : []; 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<Activity>[] = [ const columns: Column<Activity>[] = [
{ {
@@ -35,5 +52,17 @@ export default function ActivityPage() {
}, },
]; ];
return <Table columns={columns} data={activities || []} loading={isLoading} />; return (
<div className="flex flex-col gap-4">
<Table columns={columns} data={activities} loading={isLoading} />
<Pagination
page={page}
previousPage={response?.previous_page}
nextPage={response?.next_page}
total={response?.total}
limit={limit}
onPageChange={setPage}
/>
</div>
);
} }

View File

@@ -3,7 +3,7 @@ import { Link, useNavigate } from 'react-router-dom';
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1'; import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
import type { Document, DocumentsResponse } from '../generated/model'; import type { Document, DocumentsResponse } from '../generated/model';
import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons'; import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons';
import { LoadingState } from '../components'; import { LoadingState, Pagination } from '../components';
import { useToasts } from '../components/ToastContext'; import { useToasts } from '../components/ToastContext';
import { formatDuration } from '../utils/formatters'; import { formatDuration } from '../utils/formatters';
import { useDebounce } from '../hooks/useDebounce'; import { useDebounce } from '../hooks/useDebounce';
@@ -272,24 +272,14 @@ export default function DocumentsPage() {
</div> </div>
)} )}
<div className="mt-4 flex w-full justify-center gap-4 text-content"> <Pagination
{previousPage && previousPage > 0 && ( page={page}
<button previousPage={previousPage}
onClick={() => setPage(page - 1)} nextPage={nextPage}
className="w-24 rounded bg-surface p-2 text-center text-sm font-medium shadow-lg hover:bg-surface-strong focus:outline-none" total={(data?.data as DocumentsResponse | undefined)?.total}
> limit={limit}
onPageChange={setPage}
</button> />
)}
{nextPage && nextPage > 0 && (
<button
onClick={() => setPage(page + 1)}
className="w-24 rounded bg-surface p-2 text-center text-sm font-medium shadow-lg hover:bg-surface-strong focus:outline-none"
>
</button>
)}
</div>
<div className="fixed bottom-6 right-6 flex items-center justify-center rounded-full"> <div className="fixed bottom-6 right-6 flex items-center justify-center rounded-full">
<input <input

View File

@@ -1,11 +1,16 @@
import { useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useGetProgressList } from '../generated/anthoLumeAPIV1'; import { useGetProgressList } from '../generated/anthoLumeAPIV1';
import type { Progress } from '../generated/model'; import type { Progress } from '../generated/model';
import { Pagination } from '../components';
import { Table, type Column } from '../components/Table'; import { Table, type Column } from '../components/Table';
export default function ProgressPage() { export default function ProgressPage() {
const { data, isLoading } = useGetProgressList({ page: 1, limit: 15 }); const [page, setPage] = useState(1);
const progress = data?.status === 200 ? (data.data.progress ?? []) : []; const limit = 15;
const { data, isLoading } = useGetProgressList({ page, limit });
const response = data?.status === 200 ? data.data : undefined;
const progress = response?.progress ?? [];
const columns: Column<Progress>[] = [ const columns: Column<Progress>[] = [
{ {
@@ -35,5 +40,17 @@ export default function ProgressPage() {
}, },
]; ];
return <Table columns={columns} data={progress || []} loading={isLoading} />; return (
<div className="flex flex-col gap-4">
<Table columns={columns} data={progress} loading={isLoading} />
<Pagination
page={page}
previousPage={response?.previous_page}
nextPage={response?.next_page}
total={response?.total}
limit={limit}
onPageChange={setPage}
/>
</div>
);
} }