add pagination

This commit is contained in:
Aleksandr Trushkin
2024-04-07 23:49:06 +03:00
parent 68810d93a7
commit 605e117586
23 changed files with 639 additions and 153 deletions

View File

@ -1 +1 @@
3cf236a901d03e42352790df844d58c5
de22f926e2940e47830ef3a33736db71

View File

@ -43,9 +43,9 @@ func setupCoursesHTTP(srv xhttp.Server, router *mux.Router, _ *slog.Logger) {
coursesListLearningOnlyPath := makePathTemplate(xhttp.LearningTypePathParam)
coursesListFullPath := makePathTemplate(xhttp.LearningTypePathParam, xhttp.ThematicTypePathParam)
muxHandleFunc(coursesRouter, "/", coursesAPI.Index).Methods(http.MethodGet)
muxHandleFunc(coursesRouter, coursesListLearningOnlyPath, coursesAPI.List).Methods(http.MethodGet)
muxHandleFunc(coursesRouter, coursesListFullPath, coursesAPI.List).Methods(http.MethodGet)
muxHandleFunc(coursesRouter, "index", "/", coursesAPI.Index).Methods(http.MethodGet)
muxHandleFunc(coursesRouter, "list_learning", coursesListLearningOnlyPath, coursesAPI.List).Methods(http.MethodGet)
muxHandleFunc(coursesRouter, "list_full", coursesListFullPath, coursesAPI.List).Methods(http.MethodGet)
}
func setupHTTP(cfg config.HTTP, srv xhttp.Server, log *slog.Logger) *http.Server {
@ -111,6 +111,7 @@ func middlewareTrace() mux.MiddlewareFunc {
reqidAttr := attribute.Key("http.request_id")
statusAttr := attribute.Key("http.status_code")
payloadAttr := attribute.Key("http.payload_size")
pathAttr := attribute.Key("http.template_path")
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -118,7 +119,16 @@ func middlewareTrace() mux.MiddlewareFunc {
reqid := xcontext.GetRequestID(ctx)
var span trace.Span
ctx, span = webtracer.Start(ctx, r.URL.String(), trace.WithAttributes(reqidAttr.String(reqid)))
route := mux.CurrentRoute(r)
hname := route.GetName()
hpath, _ := route.GetPathTemplate()
ctx, span = webtracer.Start(
ctx, "http."+hname,
trace.WithAttributes(
reqidAttr.String(reqid),
pathAttr.String(hpath),
),
)
defer span.End()
next.ServeHTTP(w, r.WithContext(ctx))
@ -131,6 +141,8 @@ func middlewareTrace() mux.MiddlewareFunc {
)
if statusCode > 399 {
span.SetStatus(codes.Error, "error during request")
} else {
span.SetStatus(codes.Ok, "request completed")
}
}
})

View File

@ -22,7 +22,7 @@ import (
"go.opentelemetry.io/otel/sdk/trace"
)
var webtracer = otel.Tracer("kuriweb")
var webtracer = otel.Tracer("kuriweb.http")
type shutdownFunc func(context.Context) error
@ -126,7 +126,7 @@ func newMeterProvider() (*metric.MeterProvider, error) {
return meterProvider, nil
}
func muxHandleFunc(router *mux.Router, path string, hf http.HandlerFunc) *mux.Route {
func muxHandleFunc(router *mux.Router, name, path string, hf http.HandlerFunc) *mux.Route {
h := otelhttp.WithRouteTag(path, hf)
return router.Handle(path, h)
return router.Handle(path, h).Name(name)
}

View File

@ -44,7 +44,7 @@
<p class="justify-content-center">Here you can find course for any taste</p>
</div>
<div class="container w-75">
<div class="container w-75 mb-4">
<div class="row g-4">
@ -55,6 +55,11 @@
<hr/>
<p>In this category you can find courses of types such as</p>
<p>web-development, backend development, frontend developent</p>
<ul>
<li><span class="d-inline-block text-truncate col-8">web-development</span></li>
<li>backend development</li>
<li>frontend development</li>
</ul>
<p>This category contains <span>128</span> courses.</p>
<div class="d-flex justify-content-between align-items-center">
<a href="#" class="btn btn-sm btn-outline-primary col-6">
@ -127,56 +132,19 @@
</div>
<div class="row categories">
<div class="col">
<div class="block">
<ul>
<p>Category 1</p>
<li>
<div class="block">item</div>
<nav aria-label="Page navigation example">
<ul class="pagination justify-content-center">
<li class="page-item disabled">
<a class="page-link">Previous</a>
</li>
<li>
<div class="block">item</div>
</li>
<li>
<div class="block">item</div>
<li class="page-item active"><a class="page-link" href="#">1</a></li>
<li class="page-item"><a class="page-link" href="#">2</a></li>
<li class="page-item"><a class="page-link" href="#">3</a></li>
<li class="page-item">
<a class="page-link" href="#">Next</a>
</li>
</ul>
</div>
</div>
<div class="col">
<div class="block">
<ul>
<p>Category 2</p>
<li>
<div class="block">item</div>
</li>
<li>
<div class="block">item</div>
</li>
<li>
<div class="block">item</div>
</li>
</ul>
</div>
</div>
<div class="col">
<div class="block">
<ul>
<p>Category 3</p>
<li>
<div class="block">item</div>
</li>
<li>
<div class="block">item</div>
</li>
<li>
<div class="block">item</div>
</li>
</ul>
</div>
</div>
</div>
</nav>
</div>
</body>

View File

@ -9,6 +9,7 @@ import (
"time"
"git.loyso.art/frx/kurious/internal/common/xcontext"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"

View File

@ -11,8 +11,8 @@ type inMemoryMapper struct {
courseThematicsByID map[string]string
learningTypeByID map[string]string
courseThematicsCountByID map[string]int
learningTypeCountByID map[string]int
stats map[string]domain.LearningTypeStat
courseThematicByLearningType map[string]string
totalCount int
}
@ -26,8 +26,8 @@ func NewMemoryMapper(courseThematics, learningType map[string]string) *inMemoryM
func (m *inMemoryMapper) CollectCounts(ctx context.Context, cr domain.CourseRepository) error {
const batchSize = 1000
m.courseThematicsCountByID = map[string]int{}
m.learningTypeCountByID = map[string]int{}
m.stats = map[string]domain.LearningTypeStat{}
m.courseThematicByLearningType = map[string]string{}
var nextPageToken string
for {
@ -42,8 +42,14 @@ func (m *inMemoryMapper) CollectCounts(ctx context.Context, cr domain.CourseRepo
}
m.totalCount += len(result.Courses)
for _, course := range result.Courses {
m.courseThematicsCountByID[course.ThematicID]++
m.learningTypeCountByID[course.LearningTypeID]++
stat, ok := m.stats[course.LearningTypeID]
stat.Count++
if !ok {
stat.CourseThematic = map[string]int{}
}
stat.CourseThematic[course.ThematicID]++
m.stats[course.LearningTypeID] = stat
m.courseThematicByLearningType[course.ThematicID] = course.LearningTypeID
}
if len(result.Courses) < batchSize {
break
@ -55,13 +61,36 @@ func (m *inMemoryMapper) CollectCounts(ctx context.Context, cr domain.CourseRepo
}
func (m *inMemoryMapper) GetCounts(byCourseThematic, byLearningType string) int {
if byCourseThematic != "" && byLearningType == "" {
byLearningType = m.courseThematicByLearningType[byCourseThematic]
}
if byLearningType != "" {
stat := m.stats[byLearningType]
if byCourseThematic != "" {
return m.courseThematicsCountByID[byCourseThematic]
} else if byLearningType != "" {
return m.learningTypeCountByID[byLearningType]
} else {
return stat.CourseThematic[byCourseThematic]
}
return stat.Count
}
return m.totalCount
}
func (m *inMemoryMapper) GetStats(copyMap bool) map[string]domain.LearningTypeStat {
if !copyMap {
return m.stats
}
out := make(map[string]domain.LearningTypeStat, len(m.stats))
for learningType, stats := range m.stats {
copiedStats := make(map[string]int, len(stats.CourseThematic))
for courseThematic, count := range stats.CourseThematic {
copiedStats[courseThematic] = count
}
out[learningType] = domain.LearningTypeStat{
Count: stats.Count,
CourseThematic: copiedStats,
}
}
return out
}
func (m *inMemoryMapper) CourseThematicNameByID(id string) string {

View File

@ -11,14 +11,19 @@ import (
"git.loyso.art/frx/kurious/internal/common/config"
"git.loyso.art/frx/kurious/internal/common/nullable"
"git.loyso.art/frx/kurious/internal/common/xcontext"
"git.loyso.art/frx/kurious/internal/kurious/domain"
"git.loyso.art/frx/kurious/migrations/sqlite"
"git.loyso.art/frx/kurious/pkg/xdefault"
"github.com/jmoiron/sqlx"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
_ "modernc.org/sqlite"
)
var sqliteTracer = otel.Tracer("sqlite")
type sqliteConnection struct {
db *sqlx.DB
shutdownTimeout time.Duration
@ -68,8 +73,20 @@ func (r *sqliteCourseRepository) List(
) (result domain.ListCoursesResult, err error) {
const queryTemplate = `SELECT %s from courses WHERE 1=1`
ctx, span := sqliteTracer.Start(ctx, "sqlite.list")
defer func() {
if err != nil {
span.RecordError(err)
}
span.End()
}()
if params.NextPageToken != "" && params.Offset > 0 {
panic("could not use next_page_token and offset at the same time")
}
query := fmt.Sprintf(queryTemplate, coursesFieldsStr)
args := make([]any, 0, 1)
args := make([]any, 0, 6)
if params.LearningType != "" {
args = append(args, params.LearningType)
query += " AND learning_type = ?"
@ -87,13 +104,20 @@ func (r *sqliteCourseRepository) List(
query += " AND id > ?"
}
query += " ORDER BY id ASC"
query += " ORDER BY course_thematic, learning_type, id ASC"
if params.Limit > 0 {
query += " LIMIT ?"
args = append(args, params.Limit)
}
if params.Offset > 0 {
query += " OFFSET ?"
args = append(args, params.Offset)
}
span.SetAttributes(
attribute.String("query", query),
)
scanF := func(s rowsScanner) (err error) {
var cdb sqliteCourseDB
err = s.StructScan(&cdb)
@ -114,6 +138,15 @@ func (r *sqliteCourseRepository) List(
result.NextPageToken = result.Courses[lastIDx].ID
}
result.Count, err = r.listCount(ctx, params)
if err != nil {
xcontext.LogWithWarnError(ctx, r.log, err, "unable to list count")
}
span.SetAttributes(
attribute.Int("items_count", len(result.Courses)),
attribute.Int("total_items", result.Count),
)
return result, nil
}
@ -223,6 +256,40 @@ func (r *sqliteCourseRepository) Delete(ctx context.Context, id string) error {
return errors.New("unimplemented")
}
func (r *sqliteCourseRepository) listCount(ctx context.Context, params domain.ListCoursesParams) (count int, err error) {
const queryTemplate = `SELECT COUNT(id) FROM courses WHERE 1=1`
ctx, span := sqliteTracer.Start(ctx, "sqlite.listCount")
defer func() {
if err != nil {
span.RecordError(err)
}
span.End()
}()
query := queryTemplate
args := make([]any, 0, 6)
if params.LearningType != "" {
args = append(args, params.LearningType)
query += " AND learning_type = ?"
}
if params.CourseThematic != "" {
args = append(args, params.CourseThematic)
query += " AND course_thematic = ?"
}
if params.OrganizationID != "" {
args = append(args, params.OrganizationID)
query += " AND organization_id = ?"
}
err = r.db.GetContext(ctx, &count, query, args...)
if err != nil {
return count, fmt.Errorf("sending query: %w", err)
}
return count, nil
}
type rowsScanner interface {
sqlx.ColScanner

View File

@ -1,6 +1,7 @@
package adapters
import (
"strconv"
"testing"
"time"
@ -74,3 +75,42 @@ func (s *sqliteCourseRepositorySuite) TestCreateCourse() {
s.Require().Equal(expcourse, gotCourse)
}
func (s *sqliteCourseRepositorySuite) TestListLimitOffset() {
const coursecount = 9
const listparts = 3
basecourse := domain.CreateCourseParams{
SourceType: domain.SourceTypeManual,
}
cr := s.connection.CourseRepository()
for i := 0; i < coursecount; i++ {
basecourse.ID = strconv.Itoa(i)
_, err := cr.Create(s.ctx, basecourse)
s.NoError(err)
}
params := domain.ListCoursesParams{
Limit: coursecount / listparts,
}
for i := 0; i < listparts; i++ {
result, err := cr.List(s.ctx, params)
s.NoError(err)
s.Len(result.Courses, listparts)
params.Offset += listparts
}
result, err := cr.List(s.ctx, params)
s.NoError(err)
s.Empty(result.Courses)
params.Offset = 0
for i := 0; i < listparts; i++ {
result, err := cr.List(s.ctx, params)
s.NoError(err)
s.Len(result.Courses, listparts)
params.NextPageToken = result.NextPageToken
}
}

View File

@ -19,6 +19,7 @@ type Queries struct {
ListCourses query.ListCourseHandler
ListLearningTypes query.ListLearningTypesHandler
ListCourseThematics query.ListCourseThematicsHandler
ListCourseStatistics query.ListCoursesStatsHandler
ListOrganzations query.ListOrganizationsHandler
GetOrganization query.GetOrganizationHandler

View File

@ -18,6 +18,7 @@ type ListCourse struct {
Keyword string
Limit int
Offset int
NextPageToken string
}
@ -41,11 +42,14 @@ func NewListCourseHandler(
}
func (h listCourseHandler) Handle(ctx context.Context, query ListCourse) (out domain.ListCoursesResult, err error) {
out.AvailableCoursesOfSub = map[string]int{}
const defaultBatchSize = 1_000
out.NextPageToken = query.NextPageToken
drainFull := query.Limit == 0
if !drainFull {
out.Courses = make([]domain.Course, 0, query.Limit)
} else {
query.Limit = defaultBatchSize
}
for {
result, err := h.repo.List(ctx, domain.ListCoursesParams{
@ -53,7 +57,7 @@ func (h listCourseHandler) Handle(ctx context.Context, query ListCourse) (out do
LearningType: query.LearningType,
OrganizationID: query.OrganizationID,
Limit: query.Limit,
Offset: query.Offset,
NextPageToken: out.NextPageToken,
})
if err != nil {
@ -69,19 +73,15 @@ func (h listCourseHandler) Handle(ctx context.Context, query ListCourse) (out do
out.Courses = append(out.Courses, result.Courses...)
out.NextPageToken = result.NextPageToken
out.Count = result.Count
if drainFull && len(result.Courses) > 0 && result.NextPageToken != "" {
if drainFull && len(result.Courses) == query.Limit {
query.Offset += query.Limit
continue
}
break
}
for _, course := range out.Courses {
if _, ok := out.AvailableCoursesOfSub[course.ThematicID]; !ok {
out.AvailableCoursesOfSub[course.ThematicID] = h.mapper.GetCounts(course.ThematicID, course.LearningTypeID)
}
}
return out, nil
}

View File

@ -0,0 +1,38 @@
package query
import (
"context"
"log/slog"
"git.loyso.art/frx/kurious/internal/common/decorator"
"git.loyso.art/frx/kurious/internal/kurious/domain"
)
type ListCoursesStats struct{}
type ListCoursesStatsResult struct{}
type ListCoursesStatsHandler decorator.QueryHandler[ListCoursesStats, domain.ListCoursesStatsResult]
type listCoursesStatsHandler struct {
mapper domain.CourseMapper
}
func NewListCoursesStatsHandler(
mapper domain.CourseMapper,
log *slog.Logger,
) ListCoursesStatsHandler {
h := listCoursesStatsHandler{
mapper: mapper,
}
return decorator.AddQueryDecorators(h, log)
}
func (h listCoursesStatsHandler) Handle(
ctx context.Context,
query ListCoursesStats,
) (out domain.ListCoursesStatsResult, err error) {
stats := h.mapper.GetStats(false)
out.StatsByLearningType = stats
return out, nil
}

View File

@ -8,4 +8,5 @@ type CourseMapper interface {
CollectCounts(context.Context, CourseRepository) error
GetCounts(byCourseThematic, byLearningType string) int
GetStats(copyMap bool) map[string]LearningTypeStat
}

View File

@ -14,6 +14,7 @@ type ListCoursesParams struct {
NextPageToken string
Limit int
Offset int
}
type CreateCourseParams struct {
@ -34,10 +35,19 @@ type CreateCourseParams struct {
StartsAt time.Time
}
type LearningTypeStat struct {
Count int
CourseThematic map[string]int
}
type ListCoursesStatsResult struct {
StatsByLearningType map[string]LearningTypeStat
}
type ListCoursesResult struct {
Courses []Course
AvailableCoursesOfSub map[string]int
NextPageToken string
Count int
}
type ListLearningTypeResult struct {

View File

@ -81,7 +81,7 @@ templ footer() {
</footer>
}
templ root(page PageKind, s stats) {
templ root(page PageKind, _ stats) {
<!DOCTYPE html>
<html lang="ru">
@head(string(page))

View File

@ -201,7 +201,7 @@ func footer() templ.Component {
})
}
func root(page PageKind, s stats) templ.Component {
func root(page PageKind, _ stats) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {

View File

@ -169,5 +169,6 @@ templ ListCourses(pageType PageKind, s stats, params ListCoursesParams) {
@listCoursesSectionHeader(params.FilterForm.BreadcrumbsParams)
@listCoursesSectionFilters(params.FilterForm)
@listCoursesLearning(params.Categories)
@pagination(params.Pagination)
}
}

View File

@ -750,6 +750,14 @@ func ListCourses(pageType PageKind, s stats, params ListCoursesParams) templ.Com
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = pagination(params.Pagination).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = io.Copy(templ_7745c5c3_W, templ_7745c5c3_Buffer)
}

View File

@ -6,6 +6,7 @@ type IndexCourseCategoryItem struct {
ID string
Name string
Description string
ExampleThemes []string
Count int
}
@ -18,6 +19,16 @@ templ courseItemCard(item IndexCourseCategoryItem) {
<h5 class="card-title">{ item.Name }</h5>
<hr/>
<p>{ item.Description }</p>
if len(item.ExampleThemes) > 0 {
<p>В данной категории вы можете найти курсы по темам:</p>
<ul>
for _, exampleItem := range item.ExampleThemes {
<li>
<span class="d-inline-block text-truncate col-8">{ exampleItem }</span>
</li>
}
</ul>
}
<div class="d-flex justify-content-between align-items-center">
<a
href={ templ.URL("/courses/" + item.ID) }
@ -34,7 +45,7 @@ templ courseItemCard(item IndexCourseCategoryItem) {
}
templ courseCategory(items []IndexCourseCategoryItem) {
<div class="container w-75">
<div class="container w-75 mb-4">
<div class="row g-4">
for _, item := range items {
<div class="col-12 col-md-8 col-lg-4">
@ -45,14 +56,57 @@ templ courseCategory(items []IndexCourseCategoryItem) {
</div>
}
type Pagination struct {
Page int
TotalPages int
BaseURL string
}
templ pagination(p Pagination) {
if p.Page > 0 {
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center">
<li
class={
"page-item",
templ.KV("disabled", p.Page > 0 && p.Page == 1),
}
>
<a href={templ.URL(p.BaseURL + "?page=" + strconv.Itoa(p.Page - 1))} class="page-link">Previous</a>
</li>
for i := max(p.Page-2, 1); i < min(p.TotalPages, 10); i++ {
<li
class={
"page-item",
templ.KV("active", p.Page == i),
}
>
<a href={templ.URL(p.BaseURL + "?page=" + strconv.Itoa(i))} class="page-link">{ strconv.Itoa(i) }</a>
</li>
}
<li
class={
"page-item",
templ.KV("disabled", p.Page == p.TotalPages),
}
>
<a href={templ.URL(p.BaseURL + "?page=" + strconv.Itoa(p.Page + 1))} class="page-link">Next</a>
</li>
</ul>
</nav>
}
}
type MainPageParams struct {
Breadcrumbs BreadcrumbsParams
Categories []IndexCourseCategoryItem
Pagination Pagination
}
templ MainPage(pageType PageKind, s stats, params MainPageParams) {
@root(pageType, s) {
@listCoursesSectionHeader(params.Breadcrumbs)
@courseCategory(params.Categories)
@pagination(params.Pagination)
}
}

View File

@ -16,6 +16,7 @@ type IndexCourseCategoryItem struct {
ID string
Name string
Description string
ExampleThemes []string
Count int
}
@ -42,7 +43,7 @@ func courseItemCard(item IndexCourseCategoryItem) templ.Component {
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(item.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/main.templ`, Line: 17, Col: 37}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/main.templ`, Line: 18, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
@ -55,18 +56,60 @@ func courseItemCard(item IndexCourseCategoryItem) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(item.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/main.templ`, Line: 19, Col: 24}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/main.templ`, Line: 20, Col: 24}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</p><div class=\"d-flex justify-content-between align-items-center\"><a href=\"")
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 templ.SafeURL = templ.URL("/courses/" + item.ID)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var4)))
if len(item.ExampleThemes) > 0 {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var4 := `В данной категории вы можете найти курсы по темам:`
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</p><ul>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, exampleItem := range item.ExampleThemes {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<li><span class=\"d-inline-block text-truncate col-8\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(exampleItem)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/main.templ`, Line: 26, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</span></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</ul>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"d-flex justify-content-between align-items-center\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 templ.SafeURL = templ.URL("/courses/" + item.ID)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var6)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -74,8 +117,8 @@ func courseItemCard(item IndexCourseCategoryItem) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var5 := `Open`
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
templ_7745c5c3_Var7 := `Open`
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -83,12 +126,12 @@ func courseItemCard(item IndexCourseCategoryItem) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(item.Count))
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(item.Count))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/main.templ`, Line: 28, Col: 31}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/main.templ`, Line: 39, Col: 31}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -96,8 +139,8 @@ func courseItemCard(item IndexCourseCategoryItem) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var7 := `items.`
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
templ_7745c5c3_Var9 := `items.`
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -120,12 +163,12 @@ func courseCategory(items []IndexCourseCategoryItem) templ.Component {
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var8 := templ.GetChildren(ctx)
if templ_7745c5c3_Var8 == nil {
templ_7745c5c3_Var8 = templ.NopComponent
templ_7745c5c3_Var10 := templ.GetChildren(ctx)
if templ_7745c5c3_Var10 == nil {
templ_7745c5c3_Var10 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"container w-75\"><div class=\"row g-4\">")
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"container w-75 mb-4\"><div class=\"row g-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -154,9 +197,162 @@ func courseCategory(items []IndexCourseCategoryItem) templ.Component {
})
}
type Pagination struct {
Page int
TotalPages int
BaseURL string
}
func pagination(p Pagination) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var11 := templ.GetChildren(ctx)
if templ_7745c5c3_Var11 == nil {
templ_7745c5c3_Var11 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
if p.Page > 0 {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<nav aria-label=\"Page navigation\"><ul class=\"pagination justify-content-center\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 = []any{
"page-item",
templ.KV("disabled", p.Page > 0 && p.Page == 1),
}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var12...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<li class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ.CSSClasses(templ_7745c5c3_Var12).String()))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 templ.SafeURL = templ.URL(p.BaseURL + "?page=" + strconv.Itoa(p.Page-1))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var13)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" class=\"page-link\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var14 := `Previous`
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</a></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for i := max(p.Page-2, 1); i < min(p.TotalPages, 10); i++ {
var templ_7745c5c3_Var15 = []any{
"page-item",
templ.KV("active", p.Page == i),
}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var15...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<li class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ.CSSClasses(templ_7745c5c3_Var15).String()))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 templ.SafeURL = templ.URL(p.BaseURL + "?page=" + strconv.Itoa(i))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var16)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" class=\"page-link\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(i))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/main.templ`, Line: 83, Col: 101}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</a></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
var templ_7745c5c3_Var18 = []any{
"page-item",
templ.KV("disabled", p.Page == p.TotalPages),
}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var18...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<li class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ.CSSClasses(templ_7745c5c3_Var18).String()))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 templ.SafeURL = templ.URL(p.BaseURL + "?page=" + strconv.Itoa(p.Page+1))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var19)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" class=\"page-link\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var20 := `Next`
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</a></li></ul></nav>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
type MainPageParams struct {
Breadcrumbs BreadcrumbsParams
Categories []IndexCourseCategoryItem
Pagination Pagination
}
func MainPage(pageType PageKind, s stats, params MainPageParams) templ.Component {
@ -167,12 +363,12 @@ func MainPage(pageType PageKind, s stats, params MainPageParams) templ.Component
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var9 := templ.GetChildren(ctx)
if templ_7745c5c3_Var9 == nil {
templ_7745c5c3_Var9 = templ.NopComponent
templ_7745c5c3_Var21 := templ.GetChildren(ctx)
if templ_7745c5c3_Var21 == nil {
templ_7745c5c3_Var21 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var10 := templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Var22 := templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
@ -190,12 +386,20 @@ func MainPage(pageType PageKind, s stats, params MainPageParams) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = pagination(params.Pagination).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = io.Copy(templ_7745c5c3_W, templ_7745c5c3_Buffer)
}
return templ_7745c5c3_Err
})
templ_7745c5c3_Err = root(pageType, s).Render(templ.WithChildren(ctx, templ_7745c5c3_Var10), templ_7745c5c3_Buffer)
templ_7745c5c3_Err = root(pageType, s).Render(templ.WithChildren(ctx, templ_7745c5c3_Var22), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}

View File

@ -96,6 +96,8 @@ type SubcategoryContainer struct {
type ListCoursesParams struct {
FilterForm FilterFormParams
Categories []CategoryContainer
Pagination Pagination
Items int
}
func GetOrFallback[T comparable](value T, fallback T) T {
@ -105,3 +107,25 @@ func GetOrFallback[T comparable](value T, fallback T) T {
}
return value
}
type ordered interface {
~int8 | ~int16 | ~int32 | ~int64 | ~int |
~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uint |
~float32 | float64 | ~string
}
func min[T ordered](lhs, rhs T) T {
if lhs < rhs {
return lhs
}
return rhs
}
func max[T ordered](lhs, rhs T) T {
if lhs > rhs {
return lhs
}
return rhs
}

View File

@ -2,11 +2,9 @@ package http
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"slices"
"strings"
"git.loyso.art/frx/kurious/internal/common/xslices"
"git.loyso.art/frx/kurious/internal/kurious/app/query"
@ -16,6 +14,7 @@ import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
@ -30,7 +29,7 @@ type courseTemplServer struct {
log *slog.Logger
}
func makeTemplListCoursesParams(counts map[string]int, in ...domain.Course) bootstrap.ListCoursesParams {
func makeTemplListCoursesParams(counts map[string]domain.LearningTypeStat, in ...domain.Course) bootstrap.ListCoursesParams {
coursesBySubcategory := make(map[string][]bootstrap.CourseInfo, len(in))
subcategoriesByCategories := make(map[string]map[string]struct{}, len(in))
categoryByID := make(map[string]bootstrap.CategoryBaseInfo, len(in))
@ -61,7 +60,7 @@ func makeTemplListCoursesParams(counts map[string]int, in ...domain.Course) boot
categoryByID[c.ThematicID] = bootstrap.CategoryBaseInfo{
ID: c.ThematicID,
Name: c.Thematic,
Count: counts[c.ThematicID],
Count: counts[c.LearningTypeID].CourseThematic[c.ThematicID],
}
}
})
@ -91,7 +90,7 @@ func (c courseTemplServer) List(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var span trace.Span
ctx, span = webtracer.Start(ctx, "list")
ctx, span = webtracer.Start(ctx, "http_server.list")
defer func() {
span.End()
}()
@ -106,17 +105,26 @@ func (c courseTemplServer) List(w http.ResponseWriter, r *http.Request) {
jsonParams, _ := json.Marshal(pathParams)
span.SetAttributes(paramsAttr.String(string(jsonParams)))
var offset int
if pathParams.Page > 0 {
offset = (pathParams.Page - 1) * pathParams.PerPage
}
listCoursesResult, err := c.app.Queries.ListCourses.Handle(ctx, query.ListCourse{
CourseThematic: pathParams.CourseThematic,
LearningType: pathParams.LearningType,
Limit: pathParams.PerPage,
NextPageToken: pathParams.NextPageToken,
Offset: offset,
})
if handleError(ctx, err, w, c.log, "unable to list courses") {
return
}
params := makeTemplListCoursesParams(listCoursesResult.AvailableCoursesOfSub, listCoursesResult.Courses...)
statsresult, err := c.app.Queries.ListCourseStatistics.Handle(ctx, query.ListCoursesStats{})
if handleError(ctx, err, w, c.log, "unable to load stats") {
return
}
params := makeTemplListCoursesParams(statsresult.StatsByLearningType, listCoursesResult.Courses...)
learningTypeResult, err := c.app.Queries.ListLearningTypes.Handle(ctx, query.ListLearningTypes{})
if handleError(ctx, err, w, c.log, "unable to list learning types") {
@ -156,12 +164,6 @@ func (c courseTemplServer) List(w http.ResponseWriter, r *http.Request) {
})
}
c.log.DebugContext(
ctx, "using bootstrap",
slog.Int("course_thematic", len(params.FilterForm.AvailableCourseThematics)),
slog.Int("learning_type", len(params.FilterForm.AvailableLearningTypes)),
)
params = bootstrap.ListCoursesParams{
FilterForm: bootstrap.FilterFormParams{
BreadcrumbsParams: bootstrap.BreadcrumbsParams{
@ -172,8 +174,22 @@ func (c courseTemplServer) List(w http.ResponseWriter, r *http.Request) {
AvailableCourseThematics: params.FilterForm.AvailableCourseThematics,
},
Categories: params.Categories,
Pagination: bootstrap.Pagination{
Page: pathParams.Page,
TotalPages: listCoursesResult.Count / pathParams.PerPage,
BaseURL: r.URL.Path,
},
}
c.log.DebugContext(
ctx, "params rendered",
slog.Int("course_thematic", len(params.FilterForm.AvailableCourseThematics)),
slog.Int("learning_type", len(params.FilterForm.AvailableLearningTypes)),
slog.Int("items", len(listCoursesResult.Courses)),
slog.Int("page", params.Pagination.Page),
slog.Int("total_pages", params.Pagination.TotalPages),
)
slices.SortFunc(params.Categories, func(lhs, rhs bootstrap.CategoryContainer) int {
if lhs.Count > rhs.Count {
return 1
@ -191,13 +207,15 @@ func (c courseTemplServer) List(w http.ResponseWriter, r *http.Request) {
if handleError(ctx, err, w, c.log, "unable to render list courses") {
return
}
span.SetStatus(codes.Ok, "request completed")
}
func (c courseTemplServer) Index(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var span trace.Span
ctx, span = webtracer.Start(ctx, "index")
ctx, span = webtracer.Start(ctx, "http_server.index")
defer func() {
span.End()
}()
@ -238,13 +256,7 @@ func (c courseTemplServer) Index(w http.ResponseWriter, r *http.Request) {
return in.Name
})
namesStr := strings.Join(names, ",")
category.Description = fmt.Sprintf(
"Here you can find courses"+
" such as %s",
namesStr,
)
category.ExampleThemes = names
params.Categories = append(params.Categories, category)
}
@ -265,4 +277,5 @@ func (c courseTemplServer) Index(w http.ResponseWriter, r *http.Request) {
if handleError(ctx, err, w, c.log, "rendeting template") {
return
}
span.SetStatus(codes.Ok, "request completed")
}

View File

@ -10,10 +10,10 @@ import (
"git.loyso.art/frx/kurious/internal/common/errors"
"git.loyso.art/frx/kurious/internal/common/xcontext"
"git.loyso.art/frx/kurious/internal/kurious/service"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"github.com/gorilla/mux"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
type Server struct {
@ -38,8 +38,8 @@ func handleError(ctx context.Context, err error, w http.ResponseWriter, log *slo
}
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, "error during handling request")
span.RecordError(err)
span.SetStatus(codes.Error, "error during handling request")
var errorString string
var code int
@ -66,10 +66,16 @@ func handleError(ctx context.Context, err error, w http.ResponseWriter, log *slo
type pagination struct {
NextPageToken string
PerPage int
Page int
}
func parsePaginationFromQuery(r *http.Request) (out pagination, err error) {
query := r.URL.Query()
if query.Has("next") && query.Has("page") {
return out, errors.NewValidationError("next", `could not be set together with "page"`)
}
out.NextPageToken = query.Get("next")
if query.Has("per_page") {
@ -80,6 +86,14 @@ func parsePaginationFromQuery(r *http.Request) (out pagination, err error) {
} else {
out.PerPage = 50
}
if query.Has("page") {
out.Page, err = strconv.Atoi(query.Get("page"))
if err != nil {
return out, errors.NewValidationError("page", "bad per_page value")
}
} else if !query.Has("next") {
out.Page = 1
}
return out, nil
}

View File

@ -85,6 +85,7 @@ func NewApplication(ctx context.Context, cfg ApplicationConfig, mapper domain.Co
ListCourses: query.NewListCourseHandler(courseadapter, mapper, log),
ListLearningTypes: query.NewListLearningTypesHandler(courseadapter, mapper, log),
ListCourseThematics: query.NewListCourseThematicsHandler(courseadapter, mapper, log),
ListCourseStatistics: query.NewListCoursesStatsHandler(mapper, log),
GetCourse: query.NewGetCourseHandler(courseadapter, mapper, log),
ListOrganzations: query.NewListOrganizationsHandler(organizationrepo, log),