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) coursesListLearningOnlyPath := makePathTemplate(xhttp.LearningTypePathParam)
coursesListFullPath := makePathTemplate(xhttp.LearningTypePathParam, xhttp.ThematicTypePathParam) coursesListFullPath := makePathTemplate(xhttp.LearningTypePathParam, xhttp.ThematicTypePathParam)
muxHandleFunc(coursesRouter, "/", coursesAPI.Index).Methods(http.MethodGet) muxHandleFunc(coursesRouter, "index", "/", coursesAPI.Index).Methods(http.MethodGet)
muxHandleFunc(coursesRouter, coursesListLearningOnlyPath, coursesAPI.List).Methods(http.MethodGet) muxHandleFunc(coursesRouter, "list_learning", coursesListLearningOnlyPath, coursesAPI.List).Methods(http.MethodGet)
muxHandleFunc(coursesRouter, coursesListFullPath, 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 { 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") reqidAttr := attribute.Key("http.request_id")
statusAttr := attribute.Key("http.status_code") statusAttr := attribute.Key("http.status_code")
payloadAttr := attribute.Key("http.payload_size") payloadAttr := attribute.Key("http.payload_size")
pathAttr := attribute.Key("http.template_path")
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -118,7 +119,16 @@ func middlewareTrace() mux.MiddlewareFunc {
reqid := xcontext.GetRequestID(ctx) reqid := xcontext.GetRequestID(ctx)
var span trace.Span 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() defer span.End()
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
@ -131,6 +141,8 @@ func middlewareTrace() mux.MiddlewareFunc {
) )
if statusCode > 399 { if statusCode > 399 {
span.SetStatus(codes.Error, "error during request") 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" "go.opentelemetry.io/otel/sdk/trace"
) )
var webtracer = otel.Tracer("kuriweb") var webtracer = otel.Tracer("kuriweb.http")
type shutdownFunc func(context.Context) error type shutdownFunc func(context.Context) error
@ -126,7 +126,7 @@ func newMeterProvider() (*metric.MeterProvider, error) {
return meterProvider, nil 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) 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> <p class="justify-content-center">Here you can find course for any taste</p>
</div> </div>
<div class="container w-75"> <div class="container w-75 mb-4">
<div class="row g-4"> <div class="row g-4">
@ -55,6 +55,11 @@
<hr/> <hr/>
<p>In this category you can find courses of types such as</p> <p>In this category you can find courses of types such as</p>
<p>web-development, backend development, frontend developent</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> <p>This category contains <span>128</span> courses.</p>
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<a href="#" class="btn btn-sm btn-outline-primary col-6"> <a href="#" class="btn btn-sm btn-outline-primary col-6">
@ -127,56 +132,19 @@
</div> </div>
<div class="row categories"> <nav aria-label="Page navigation example">
<div class="col"> <ul class="pagination justify-content-center">
<div class="block"> <li class="page-item disabled">
<ul> <a class="page-link">Previous</a>
<p>Category 1</p> </li>
<li> <li class="page-item active"><a class="page-link" href="#">1</a></li>
<div class="block">item</div> <li class="page-item"><a class="page-link" href="#">2</a></li>
</li> <li class="page-item"><a class="page-link" href="#">3</a></li>
<li> <li class="page-item">
<div class="block">item</div> <a class="page-link" href="#">Next</a>
</li> </li>
<li> </ul>
<div class="block">item</div> </nav>
</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>
</div> </div>
</body> </body>

View File

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

View File

@ -11,9 +11,9 @@ type inMemoryMapper struct {
courseThematicsByID map[string]string courseThematicsByID map[string]string
learningTypeByID map[string]string learningTypeByID map[string]string
courseThematicsCountByID map[string]int stats map[string]domain.LearningTypeStat
learningTypeCountByID map[string]int courseThematicByLearningType map[string]string
totalCount int totalCount int
} }
func NewMemoryMapper(courseThematics, learningType map[string]string) *inMemoryMapper { func NewMemoryMapper(courseThematics, learningType map[string]string) *inMemoryMapper {
@ -26,8 +26,8 @@ func NewMemoryMapper(courseThematics, learningType map[string]string) *inMemoryM
func (m *inMemoryMapper) CollectCounts(ctx context.Context, cr domain.CourseRepository) error { func (m *inMemoryMapper) CollectCounts(ctx context.Context, cr domain.CourseRepository) error {
const batchSize = 1000 const batchSize = 1000
m.courseThematicsCountByID = map[string]int{} m.stats = map[string]domain.LearningTypeStat{}
m.learningTypeCountByID = map[string]int{} m.courseThematicByLearningType = map[string]string{}
var nextPageToken string var nextPageToken string
for { for {
@ -42,8 +42,14 @@ func (m *inMemoryMapper) CollectCounts(ctx context.Context, cr domain.CourseRepo
} }
m.totalCount += len(result.Courses) m.totalCount += len(result.Courses)
for _, course := range result.Courses { for _, course := range result.Courses {
m.courseThematicsCountByID[course.ThematicID]++ stat, ok := m.stats[course.LearningTypeID]
m.learningTypeCountByID[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 { if len(result.Courses) < batchSize {
break break
@ -55,13 +61,36 @@ func (m *inMemoryMapper) CollectCounts(ctx context.Context, cr domain.CourseRepo
} }
func (m *inMemoryMapper) GetCounts(byCourseThematic, byLearningType string) int { func (m *inMemoryMapper) GetCounts(byCourseThematic, byLearningType string) int {
if byCourseThematic != "" { if byCourseThematic != "" && byLearningType == "" {
return m.courseThematicsCountByID[byCourseThematic] byLearningType = m.courseThematicByLearningType[byCourseThematic]
} else if byLearningType != "" {
return m.learningTypeCountByID[byLearningType]
} else {
return m.totalCount
} }
if byLearningType != "" {
stat := m.stats[byLearningType]
if byCourseThematic != "" {
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 { 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/config"
"git.loyso.art/frx/kurious/internal/common/nullable" "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/internal/kurious/domain"
"git.loyso.art/frx/kurious/migrations/sqlite" "git.loyso.art/frx/kurious/migrations/sqlite"
"git.loyso.art/frx/kurious/pkg/xdefault" "git.loyso.art/frx/kurious/pkg/xdefault"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
var sqliteTracer = otel.Tracer("sqlite")
type sqliteConnection struct { type sqliteConnection struct {
db *sqlx.DB db *sqlx.DB
shutdownTimeout time.Duration shutdownTimeout time.Duration
@ -68,8 +73,20 @@ func (r *sqliteCourseRepository) List(
) (result domain.ListCoursesResult, err error) { ) (result domain.ListCoursesResult, err error) {
const queryTemplate = `SELECT %s from courses WHERE 1=1` 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) query := fmt.Sprintf(queryTemplate, coursesFieldsStr)
args := make([]any, 0, 1) args := make([]any, 0, 6)
if params.LearningType != "" { if params.LearningType != "" {
args = append(args, params.LearningType) args = append(args, params.LearningType)
query += " AND learning_type = ?" query += " AND learning_type = ?"
@ -87,13 +104,20 @@ func (r *sqliteCourseRepository) List(
query += " AND id > ?" query += " AND id > ?"
} }
query += " ORDER BY id ASC" query += " ORDER BY course_thematic, learning_type, id ASC"
if params.Limit > 0 { if params.Limit > 0 {
query += " LIMIT ?" query += " LIMIT ?"
args = append(args, params.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) { scanF := func(s rowsScanner) (err error) {
var cdb sqliteCourseDB var cdb sqliteCourseDB
err = s.StructScan(&cdb) err = s.StructScan(&cdb)
@ -114,6 +138,15 @@ func (r *sqliteCourseRepository) List(
result.NextPageToken = result.Courses[lastIDx].ID 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 return result, nil
} }
@ -223,6 +256,40 @@ func (r *sqliteCourseRepository) Delete(ctx context.Context, id string) error {
return errors.New("unimplemented") 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 { type rowsScanner interface {
sqlx.ColScanner sqlx.ColScanner

View File

@ -1,6 +1,7 @@
package adapters package adapters
import ( import (
"strconv"
"testing" "testing"
"time" "time"
@ -74,3 +75,42 @@ func (s *sqliteCourseRepositorySuite) TestCreateCourse() {
s.Require().Equal(expcourse, gotCourse) 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

@ -15,10 +15,11 @@ type Commands struct {
} }
type Queries struct { type Queries struct {
GetCourse query.GetCourseHandler GetCourse query.GetCourseHandler
ListCourses query.ListCourseHandler ListCourses query.ListCourseHandler
ListLearningTypes query.ListLearningTypesHandler ListLearningTypes query.ListLearningTypesHandler
ListCourseThematics query.ListCourseThematicsHandler ListCourseThematics query.ListCourseThematicsHandler
ListCourseStatistics query.ListCoursesStatsHandler
ListOrganzations query.ListOrganizationsHandler ListOrganzations query.ListOrganizationsHandler
GetOrganization query.GetOrganizationHandler GetOrganization query.GetOrganizationHandler

View File

@ -18,6 +18,7 @@ type ListCourse struct {
Keyword string Keyword string
Limit int Limit int
Offset int
NextPageToken string NextPageToken string
} }
@ -41,11 +42,14 @@ func NewListCourseHandler(
} }
func (h listCourseHandler) Handle(ctx context.Context, query ListCourse) (out domain.ListCoursesResult, err error) { 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 out.NextPageToken = query.NextPageToken
drainFull := query.Limit == 0 drainFull := query.Limit == 0
if !drainFull { if !drainFull {
out.Courses = make([]domain.Course, 0, query.Limit) out.Courses = make([]domain.Course, 0, query.Limit)
} else {
query.Limit = defaultBatchSize
} }
for { for {
result, err := h.repo.List(ctx, domain.ListCoursesParams{ result, err := h.repo.List(ctx, domain.ListCoursesParams{
@ -53,8 +57,8 @@ func (h listCourseHandler) Handle(ctx context.Context, query ListCourse) (out do
LearningType: query.LearningType, LearningType: query.LearningType,
OrganizationID: query.OrganizationID, OrganizationID: query.OrganizationID,
Limit: query.Limit, Limit: query.Limit,
Offset: query.Offset,
NextPageToken: out.NextPageToken, NextPageToken: out.NextPageToken,
}) })
if err != nil { if err != nil {
return out, fmt.Errorf("listing courses: %w", err) return out, fmt.Errorf("listing courses: %w", err)
@ -69,19 +73,15 @@ func (h listCourseHandler) Handle(ctx context.Context, query ListCourse) (out do
out.Courses = append(out.Courses, result.Courses...) out.Courses = append(out.Courses, result.Courses...)
out.NextPageToken = result.NextPageToken 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 continue
} }
break 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 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 CollectCounts(context.Context, CourseRepository) error
GetCounts(byCourseThematic, byLearningType string) int GetCounts(byCourseThematic, byLearningType string) int
GetStats(copyMap bool) map[string]LearningTypeStat
} }

View File

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

View File

@ -81,7 +81,7 @@ templ footer() {
</footer> </footer>
} }
templ root(page PageKind, s stats) { templ root(page PageKind, _ stats) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ru"> <html lang="ru">
@head(string(page)) @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) { 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) templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer { if !templ_7745c5c3_IsBuffer {

View File

@ -169,5 +169,6 @@ templ ListCourses(pageType PageKind, s stats, params ListCoursesParams) {
@listCoursesSectionHeader(params.FilterForm.BreadcrumbsParams) @listCoursesSectionHeader(params.FilterForm.BreadcrumbsParams)
@listCoursesSectionFilters(params.FilterForm) @listCoursesSectionFilters(params.FilterForm)
@listCoursesLearning(params.Categories) @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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 { if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = io.Copy(templ_7745c5c3_W, templ_7745c5c3_Buffer) _, templ_7745c5c3_Err = io.Copy(templ_7745c5c3_W, templ_7745c5c3_Buffer)
} }

View File

@ -3,10 +3,11 @@ package bootstrap
import "strconv" import "strconv"
type IndexCourseCategoryItem struct { type IndexCourseCategoryItem struct {
ID string ID string
Name string Name string
Description string Description string
Count int ExampleThemes []string
Count int
} }
// courseItemCard is a card that renders a single course thematic item // courseItemCard is a card that renders a single course thematic item
@ -18,6 +19,16 @@ templ courseItemCard(item IndexCourseCategoryItem) {
<h5 class="card-title">{ item.Name }</h5> <h5 class="card-title">{ item.Name }</h5>
<hr/> <hr/>
<p>{ item.Description }</p> <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"> <div class="d-flex justify-content-between align-items-center">
<a <a
href={ templ.URL("/courses/" + item.ID) } href={ templ.URL("/courses/" + item.ID) }
@ -34,7 +45,7 @@ templ courseItemCard(item IndexCourseCategoryItem) {
} }
templ courseCategory(items []IndexCourseCategoryItem) { templ courseCategory(items []IndexCourseCategoryItem) {
<div class="container w-75"> <div class="container w-75 mb-4">
<div class="row g-4"> <div class="row g-4">
for _, item := range items { for _, item := range items {
<div class="col-12 col-md-8 col-lg-4"> <div class="col-12 col-md-8 col-lg-4">
@ -45,14 +56,57 @@ templ courseCategory(items []IndexCourseCategoryItem) {
</div> </div>
} }
type MainPageParams struct{ 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 Breadcrumbs BreadcrumbsParams
Categories []IndexCourseCategoryItem Categories []IndexCourseCategoryItem
Pagination Pagination
} }
templ MainPage(pageType PageKind, s stats, params MainPageParams) { templ MainPage(pageType PageKind, s stats, params MainPageParams) {
@root(pageType, s) { @root(pageType, s) {
@listCoursesSectionHeader(params.Breadcrumbs) @listCoursesSectionHeader(params.Breadcrumbs)
@courseCategory(params.Categories) @courseCategory(params.Categories)
@pagination(params.Pagination)
} }
} }

View File

@ -13,10 +13,11 @@ import "bytes"
import "strconv" import "strconv"
type IndexCourseCategoryItem struct { type IndexCourseCategoryItem struct {
ID string ID string
Name string Name string
Description string Description string
Count int ExampleThemes []string
Count int
} }
// courseItemCard is a card that renders a single course thematic item // courseItemCard is a card that renders a single course thematic item
@ -42,7 +43,7 @@ func courseItemCard(item IndexCourseCategoryItem) templ.Component {
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(item.Name) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(item.Name)
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -55,18 +56,60 @@ func courseItemCard(item IndexCourseCategoryItem) templ.Component {
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(item.Description) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(item.Description)
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var4 templ.SafeURL = templ.URL("/courses/" + item.ID) if len(item.ExampleThemes) > 0 {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var4))) _, 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@ -74,8 +117,8 @@ func courseItemCard(item IndexCourseCategoryItem) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Var5 := `Open` templ_7745c5c3_Var7 := `Open`
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@ -83,12 +126,12 @@ func courseItemCard(item IndexCourseCategoryItem) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(item.Count)) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(item.Count))
if templ_7745c5c3_Err != nil { 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@ -96,8 +139,8 @@ func courseItemCard(item IndexCourseCategoryItem) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Var7 := `items.` templ_7745c5c3_Var9 := `items.`
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@ -120,12 +163,12 @@ func courseCategory(items []IndexCourseCategoryItem) templ.Component {
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var8 := templ.GetChildren(ctx) templ_7745c5c3_Var10 := templ.GetChildren(ctx)
if templ_7745c5c3_Var8 == nil { if templ_7745c5c3_Var10 == nil {
templ_7745c5c3_Var8 = templ.NopComponent templ_7745c5c3_Var10 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 { type MainPageParams struct {
Breadcrumbs BreadcrumbsParams Breadcrumbs BreadcrumbsParams
Categories []IndexCourseCategoryItem Categories []IndexCourseCategoryItem
Pagination Pagination
} }
func MainPage(pageType PageKind, s stats, params MainPageParams) templ.Component { 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) defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var9 := templ.GetChildren(ctx) templ_7745c5c3_Var21 := templ.GetChildren(ctx)
if templ_7745c5c3_Var9 == nil { if templ_7745c5c3_Var21 == nil {
templ_7745c5c3_Var9 = templ.NopComponent templ_7745c5c3_Var21 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) 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) templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer { if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer() templ_7745c5c3_Buffer = templ.GetBuffer()
@ -190,12 +386,20 @@ func MainPage(pageType PageKind, s stats, params MainPageParams) templ.Component
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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 { if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = io.Copy(templ_7745c5c3_W, templ_7745c5c3_Buffer) _, templ_7745c5c3_Err = io.Copy(templ_7745c5c3_W, templ_7745c5c3_Buffer)
} }
return templ_7745c5c3_Err 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }

View File

@ -96,6 +96,8 @@ type SubcategoryContainer struct {
type ListCoursesParams struct { type ListCoursesParams struct {
FilterForm FilterFormParams FilterForm FilterFormParams
Categories []CategoryContainer Categories []CategoryContainer
Pagination Pagination
Items int
} }
func GetOrFallback[T comparable](value T, fallback T) T { func GetOrFallback[T comparable](value T, fallback T) T {
@ -105,3 +107,25 @@ func GetOrFallback[T comparable](value T, fallback T) T {
} }
return value 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 ( import (
"encoding/json" "encoding/json"
"fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"slices" "slices"
"strings"
"git.loyso.art/frx/kurious/internal/common/xslices" "git.loyso.art/frx/kurious/internal/common/xslices"
"git.loyso.art/frx/kurious/internal/kurious/app/query" "git.loyso.art/frx/kurious/internal/kurious/app/query"
@ -16,6 +14,7 @@ import (
"go.opentelemetry.io/otel" "go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace" "go.opentelemetry.io/otel/trace"
) )
@ -30,7 +29,7 @@ type courseTemplServer struct {
log *slog.Logger 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)) coursesBySubcategory := make(map[string][]bootstrap.CourseInfo, len(in))
subcategoriesByCategories := make(map[string]map[string]struct{}, len(in)) subcategoriesByCategories := make(map[string]map[string]struct{}, len(in))
categoryByID := make(map[string]bootstrap.CategoryBaseInfo, 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{ categoryByID[c.ThematicID] = bootstrap.CategoryBaseInfo{
ID: c.ThematicID, ID: c.ThematicID,
Name: c.Thematic, 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() ctx := r.Context()
var span trace.Span var span trace.Span
ctx, span = webtracer.Start(ctx, "list") ctx, span = webtracer.Start(ctx, "http_server.list")
defer func() { defer func() {
span.End() span.End()
}() }()
@ -106,17 +105,26 @@ func (c courseTemplServer) List(w http.ResponseWriter, r *http.Request) {
jsonParams, _ := json.Marshal(pathParams) jsonParams, _ := json.Marshal(pathParams)
span.SetAttributes(paramsAttr.String(string(jsonParams))) 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{ listCoursesResult, err := c.app.Queries.ListCourses.Handle(ctx, query.ListCourse{
CourseThematic: pathParams.CourseThematic, CourseThematic: pathParams.CourseThematic,
LearningType: pathParams.LearningType, LearningType: pathParams.LearningType,
Limit: pathParams.PerPage, Limit: pathParams.PerPage,
NextPageToken: pathParams.NextPageToken, NextPageToken: pathParams.NextPageToken,
Offset: offset,
}) })
if handleError(ctx, err, w, c.log, "unable to list courses") { if handleError(ctx, err, w, c.log, "unable to list courses") {
return 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{}) learningTypeResult, err := c.app.Queries.ListLearningTypes.Handle(ctx, query.ListLearningTypes{})
if handleError(ctx, err, w, c.log, "unable to list learning types") { 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{ params = bootstrap.ListCoursesParams{
FilterForm: bootstrap.FilterFormParams{ FilterForm: bootstrap.FilterFormParams{
BreadcrumbsParams: bootstrap.BreadcrumbsParams{ BreadcrumbsParams: bootstrap.BreadcrumbsParams{
@ -172,8 +174,22 @@ func (c courseTemplServer) List(w http.ResponseWriter, r *http.Request) {
AvailableCourseThematics: params.FilterForm.AvailableCourseThematics, AvailableCourseThematics: params.FilterForm.AvailableCourseThematics,
}, },
Categories: params.Categories, 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 { slices.SortFunc(params.Categories, func(lhs, rhs bootstrap.CategoryContainer) int {
if lhs.Count > rhs.Count { if lhs.Count > rhs.Count {
return 1 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") { if handleError(ctx, err, w, c.log, "unable to render list courses") {
return return
} }
span.SetStatus(codes.Ok, "request completed")
} }
func (c courseTemplServer) Index(w http.ResponseWriter, r *http.Request) { func (c courseTemplServer) Index(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
var span trace.Span var span trace.Span
ctx, span = webtracer.Start(ctx, "index") ctx, span = webtracer.Start(ctx, "http_server.index")
defer func() { defer func() {
span.End() span.End()
}() }()
@ -238,13 +256,7 @@ func (c courseTemplServer) Index(w http.ResponseWriter, r *http.Request) {
return in.Name return in.Name
}) })
namesStr := strings.Join(names, ",") category.ExampleThemes = names
category.Description = fmt.Sprintf(
"Here you can find courses"+
" such as %s",
namesStr,
)
params.Categories = append(params.Categories, category) 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") { if handleError(ctx, err, w, c.log, "rendeting template") {
return 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/errors"
"git.loyso.art/frx/kurious/internal/common/xcontext" "git.loyso.art/frx/kurious/internal/common/xcontext"
"git.loyso.art/frx/kurious/internal/kurious/service" "git.loyso.art/frx/kurious/internal/kurious/service"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
) )
type Server struct { type Server struct {
@ -38,8 +38,8 @@ func handleError(ctx context.Context, err error, w http.ResponseWriter, log *slo
} }
span := trace.SpanFromContext(ctx) span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, "error during handling request")
span.RecordError(err) span.RecordError(err)
span.SetStatus(codes.Error, "error during handling request")
var errorString string var errorString string
var code int var code int
@ -66,10 +66,16 @@ func handleError(ctx context.Context, err error, w http.ResponseWriter, log *slo
type pagination struct { type pagination struct {
NextPageToken string NextPageToken string
PerPage int PerPage int
Page int
} }
func parsePaginationFromQuery(r *http.Request) (out pagination, err error) { func parsePaginationFromQuery(r *http.Request) (out pagination, err error) {
query := r.URL.Query() 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") out.NextPageToken = query.Get("next")
if query.Has("per_page") { if query.Has("per_page") {
@ -80,6 +86,14 @@ func parsePaginationFromQuery(r *http.Request) (out pagination, err error) {
} else { } else {
out.PerPage = 50 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 return out, nil
} }

View File

@ -82,10 +82,11 @@ func NewApplication(ctx context.Context, cfg ApplicationConfig, mapper domain.Co
InsertOrganization: command.NewCreateOrganizationHandler(organizationrepo, log), InsertOrganization: command.NewCreateOrganizationHandler(organizationrepo, log),
}, },
Queries: app.Queries{ Queries: app.Queries{
ListCourses: query.NewListCourseHandler(courseadapter, mapper, log), ListCourses: query.NewListCourseHandler(courseadapter, mapper, log),
ListLearningTypes: query.NewListLearningTypesHandler(courseadapter, mapper, log), ListLearningTypes: query.NewListLearningTypesHandler(courseadapter, mapper, log),
ListCourseThematics: query.NewListCourseThematicsHandler(courseadapter, mapper, log), ListCourseThematics: query.NewListCourseThematicsHandler(courseadapter, mapper, log),
GetCourse: query.NewGetCourseHandler(courseadapter, mapper, log), ListCourseStatistics: query.NewListCoursesStatsHandler(mapper, log),
GetCourse: query.NewGetCourseHandler(courseadapter, mapper, log),
ListOrganzations: query.NewListOrganizationsHandler(organizationrepo, log), ListOrganzations: query.NewListOrganizationsHandler(organizationrepo, log),
GetOrganization: query.NewGetOrganizationHandler(organizationrepo, log), GetOrganization: query.NewGetOrganizationHandler(organizationrepo, log),