Compare commits

...

10 Commits

61 changed files with 3629 additions and 377 deletions

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
./assets/kurious binary

1
.task/checksum/generate Normal file
View File

@ -0,0 +1 @@
cf55887b91f81f789d59205c41f8368

View File

@ -10,7 +10,7 @@ vars:
GIT_VERSION: GIT_VERSION:
sh: git tag | sort -r --version-sort | head -n1 sh: git tag | sort -r --version-sort | head -n1
BUILD_TIME: BUILD_TIME:
sh: TZ=UTC date --iso-8601=seconds sh: TZ=UTC date -u +"%Y-%m-%dT%H:%M:%SZ"
LDFLAGS: LDFLAGS:
sh: echo '-X "{{.PROJECT}}.buildTime={{.BUILD_TIME}}" -X "{{.PROJECT}}.commit={{.GIT_COMMIT}}" -X "{{.PROJECT}}.version={{.GIT_VERSION}}"' sh: echo '-X "{{.PROJECT}}.buildTime={{.BUILD_TIME}}" -X "{{.PROJECT}}.commit={{.GIT_COMMIT}}" -X "{{.PROJECT}}.version={{.GIT_VERSION}}"'
@ -18,15 +18,31 @@ tasks:
install_tools: install_tools:
cmds: cmds:
- go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.55.2 - go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.55.2
- go install github.com/a-h/templ/cmd/templ@v0.2.513
generate:
cmds:
- "$GOBIN/templ generate"
sources:
- "internal/kurious/ports/http/templ/*.templ"
generates:
- "internal/kurious/ports/http/templ/*.go"
check: check:
cmds: cmds:
- "$GOBIN/golangci-lint run ./..." - "$GOBIN/golangci-lint run ./..."
deps:
- generate
test: test:
cmds: cmds:
- go test ./internal/... - go test ./internal/...
deps:
- generate
build_web:
cmds:
- go build -o $GOBIN/kuriousweb -v -ldflags '{{.LDFLAGS}}' cmd/kuriweb/*.go
deps: [check, test]
build_background: build_background:
cmds: cmds:
- go build -o $GOBIN/sravnibackground -v -ldflags '{{.LDFLAGS}}' cmd/background/*.go - go build -o $GOBIN/kuriousbg -v -ldflags '{{.LDFLAGS}}' cmd/background/*.go
deps: [check, test] deps: [check, test]
build_dev_cli: build_dev_cli:
cmds: cmds:

6
assets/kurious/about.txt Normal file
View File

@ -0,0 +1,6 @@
This favicon was generated using the following font:
- Font Title: Lemon
- Font Author: Copyright 2011 The Lemon Project Authors (https://github.com/etunni/lemon) with Reserved Font Name "Lemon"
- Font Source: http://fonts.gstatic.com/s/lemon/v17/HI_EiYEVKqRMq0jBSZXAQ4-d.ttf
- Font License: SIL Open Font License, 1.1 (http://scripts.sil.org/OFL))

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

13
assets/kurious/embed.go Normal file
View File

@ -0,0 +1,13 @@
package kurious
import (
"embed"
"net/http"
)
//go:embed *
var root embed.FS
func AsHTTPFileHandler() http.Handler {
return http.FileServer(http.FS(root))
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 797 B

BIN
assets/kurious/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,2 @@
User-agent:
Disallow: /

View File

@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

View File

@ -0,0 +1,6 @@
.btn.btn-primary {
color: white;
background-color: black;
border: none;
border-radius: 4px;
}

View File

@ -13,6 +13,7 @@ import (
"git.loyso.art/frx/kurious/internal/common/config" "git.loyso.art/frx/kurious/internal/common/config"
"git.loyso.art/frx/kurious/internal/common/xcontext" "git.loyso.art/frx/kurious/internal/common/xcontext"
"git.loyso.art/frx/kurious/internal/common/xlog" "git.loyso.art/frx/kurious/internal/common/xlog"
"git.loyso.art/frx/kurious/internal/kurious/adapters"
"git.loyso.art/frx/kurious/internal/kurious/ports" "git.loyso.art/frx/kurious/internal/kurious/ports"
"git.loyso.art/frx/kurious/internal/kurious/service" "git.loyso.art/frx/kurious/internal/kurious/service"
) )
@ -43,19 +44,39 @@ func app(ctx context.Context) error {
log := config.NewSLogger(cfg.Log) log := config.NewSLogger(cfg.Log)
app, err := service.NewApplication(ctx, service.ApplicationConfig{
LogConfig: cfg.Log,
YDB: cfg.YDB,
})
if err != nil {
return fmt.Errorf("making new application: %w", err)
}
sravniClient, err := sravni.NewClient(ctx, log, cfg.DebugHTTP) sravniClient, err := sravni.NewClient(ctx, log, cfg.DebugHTTP)
if err != nil { if err != nil {
return fmt.Errorf("making sravni client: %w", err) return fmt.Errorf("making sravni client: %w", err)
} }
mainPageState, err := sravniClient.GetMainPageState()
if err != nil {
return fmt.Errorf("getting main page state: %w", err)
}
dictionaries := mainPageState.Props.InitialReduxState.Dictionaries.Data
dictFieldsAsMap := func(fields ...sravni.Field) map[string]string {
out := make(map[string]string, len(fields))
for _, field := range fields {
out[field.Value] = field.Name
}
return out
}
courseThematcisMapped := dictFieldsAsMap(dictionaries.CourseThematics.Fields...)
learningTypeMapped := dictFieldsAsMap(dictionaries.LearningType.Fields...)
mapper := adapters.NewMemoryMapper(courseThematcisMapped, learningTypeMapped)
app, err := service.NewApplication(ctx, service.ApplicationConfig{
LogConfig: cfg.Log,
YDB: cfg.YDB,
}, mapper)
if err != nil {
return fmt.Errorf("making new application: %w", err)
}
bgProcess := ports.NewBackgroundProcess(ctx, log) bgProcess := ports.NewBackgroundProcess(ctx, log)
err = bgProcess.RegisterSyncSravniHandler(ctx, app, sravniClient, cfg.SyncSravniCron) err = bgProcess.RegisterSyncSravniHandler(ctx, app, sravniClient, cfg.SyncSravniCron)
if err != nil { if err != nil {
@ -72,13 +93,16 @@ func app(ctx context.Context) error {
defer xcontext.LogInfo(ctx, log, "finished bprocess") defer xcontext.LogInfo(ctx, log, "finished bprocess")
bgProcess.Run() bgProcess.Run()
return nil return nil
}) })
eg.Go(func() error { eg.Go(func() error {
xcontext.LogInfo(ctx, log, "running cancelation waiter") xcontext.LogInfo(ctx, log, "running cancelation waiter")
defer xcontext.LogInfo(ctx, log, "finished cancelation waiter") defer xcontext.LogInfo(ctx, log, "finished cancelation waiter")
<-egctx.Done() <-egctx.Done()
sdctx, sdcancel := context.WithTimeout(context.Background(), time.Second*15) sdctx, sdcancel := context.WithTimeout(context.Background(), time.Second*15)
defer sdcancel() defer sdcancel()

View File

@ -9,7 +9,7 @@ import (
"git.loyso.art/frx/kurious/internal/common/client/sravni" "git.loyso.art/frx/kurious/internal/common/client/sravni"
"git.loyso.art/frx/kurious/internal/common/errors" "git.loyso.art/frx/kurious/internal/common/errors"
"git.loyso.art/frx/kurious/internal/common/xslice" "git.loyso.art/frx/kurious/internal/common/xslices"
"github.com/teris-io/cli" "github.com/teris-io/cli"
) )
@ -188,7 +188,7 @@ func (a *productsFilterCountAction) parse(args []string, options map[string]stri
filterNotEmpty := func(value string) bool { filterNotEmpty := func(value string) bool {
return value != "" return value != ""
} }
a.params.courseThematic = xslice.Filter( a.params.courseThematic = xslices.Filter(
strings.Split(options[courseThematicOptName], ","), strings.Split(options[courseThematicOptName], ","),
filterNotEmpty, filterNotEmpty,
) )

39
cmd/kuriweb/config.go Normal file
View File

@ -0,0 +1,39 @@
package main
import (
"encoding/json"
"fmt"
"os"
"git.loyso.art/frx/kurious/internal/common/config"
)
type Config struct {
Log config.Log `json:"log"`
YDB config.YDB `json:"ydb"`
HTTP config.HTTP `json:"http"`
}
func readFromFile(path string, defaultConfigF func() Config) (Config, error) {
out := defaultConfigF()
payload, err := os.ReadFile(path)
if err != nil {
return out, fmt.Errorf("opening file: %w", err)
}
err = json.Unmarshal(payload, &out)
if err != nil {
return out, fmt.Errorf("decoding as json: %w", err)
}
return out, nil
}
func defaultConfig() Config {
return Config{
Log: config.Log{
Level: config.LogLevelInfo,
Format: config.LogFormatText,
},
}
}

144
cmd/kuriweb/http.go Normal file
View File

@ -0,0 +1,144 @@
package main
import (
"log/slog"
"net/http"
"strings"
"time"
"git.loyso.art/frx/kurious/assets/kurious"
"git.loyso.art/frx/kurious/internal/common/config"
"git.loyso.art/frx/kurious/internal/common/generator"
"git.loyso.art/frx/kurious/internal/common/xcontext"
xhttp "git.loyso.art/frx/kurious/internal/kurious/ports/http"
"github.com/gorilla/mux"
)
func makePathTemplate(params ...string) string {
var sb strings.Builder
for _, param := range params {
sb.Grow(len(param) + 3)
sb.WriteRune('/')
sb.WriteRune('{')
sb.WriteString(param)
sb.WriteRune('}')
}
return sb.String()
}
func setupHTTPWithTempl(srv xhttp.Server, router *mux.Router, log *slog.Logger) {
coursesRouter := router.PathPrefix("/courses").Subrouter().StrictSlash(true)
coursesAPI := srv.CoursesByTempl()
coursesRouter.HandleFunc("/", coursesAPI.List).Methods(http.MethodGet)
coursesListLearningOnlyPath := makePathTemplate(xhttp.LearningTypePathParam)
coursesRouter.HandleFunc(coursesListLearningOnlyPath, coursesAPI.List).Methods(http.MethodGet)
coursesListFullPath := makePathTemplate(xhttp.LearningTypePathParam, xhttp.ThematicTypePathParam)
coursesRouter.HandleFunc(coursesListFullPath, coursesAPI.List).Methods(http.MethodGet)
}
func setupHTTPWithGoTemplates(srv xhttp.Server, router *mux.Router, log *slog.Logger) {
coursesAPI := srv.Courses()
coursesRouter := router.PathPrefix("/courses").Subrouter().StrictSlash(true)
coursesRouter.HandleFunc("/", coursesAPI.List).Methods(http.MethodGet)
coursesListLearningOnlyPath := makePathTemplate(xhttp.LearningTypePathParam)
coursesRouter.HandleFunc(coursesListLearningOnlyPath, coursesAPI.List).Methods(http.MethodGet)
coursesListFullPath := makePathTemplate(xhttp.LearningTypePathParam, xhttp.ThematicTypePathParam)
coursesRouter.HandleFunc(coursesListFullPath, coursesAPI.List).Methods(http.MethodGet)
courseRouter := router.PathPrefix("/course").PathPrefix("/{course_id}").Subrouter()
courseRouter.HandleFunc("/", coursesAPI.Get).Methods(http.MethodGet)
courseRouter.HandleFunc("/short", coursesAPI.GetShort).Methods(http.MethodGet)
courseRouter.HandleFunc("/editdesc", coursesAPI.RenderEditDescription).Methods(http.MethodGet)
courseRouter.HandleFunc("/description", coursesAPI.UpdateCourseDescription).Methods(http.MethodPut)
}
func setupHTTP(cfg config.HTTP, srv xhttp.Server, log *slog.Logger) *http.Server {
router := mux.NewRouter()
router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
router.Use(mux.CORSMethodMiddleware(router))
router.Use(middlewareLogger(log, cfg.Engine))
if cfg.Engine == "templ" {
setupHTTPWithTempl(srv, router, log)
} else {
setupHTTPWithGoTemplates(srv, router, log)
}
if cfg.MountLive {
fs := http.FileServer(http.Dir("./assets/kurious/static/"))
router.PathPrefix("/static/").Handler(http.StripPrefix("/static/", fs)).Methods(http.MethodGet)
registerFile := func(filepath string) {
if !strings.HasPrefix(filepath, "/") {
filepath = "/" + filepath
}
relativePath := "./assets/kurious" + filepath
router.HandleFunc(filepath, func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, relativePath)
}).Methods(http.MethodGet)
}
for _, file := range []string{
"robots.txt",
"android-chrome-192x192.png",
"android-chrome-512x512.png",
"apple-touch-icon.png",
"favicon-16x16.png",
"favicon-32x32.png",
"favicon.ico",
"site.webmanifest",
} {
registerFile(file)
}
} else {
fs := kurious.AsHTTPFileHandler()
router.PathPrefix("/").Handler(fs).Methods(http.MethodGet)
}
return &http.Server{
Addr: cfg.ListenAddr,
Handler: router,
}
}
func middlewareLogger(log *slog.Logger, engine string) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
requestID := r.Header.Get("x-request-id")
if requestID == "" {
requestID = generator.RandomInt64ID()
}
ctx = xcontext.WithLogFields(
ctx,
slog.String("request_id", requestID),
slog.String("engine", engine),
)
xcontext.LogInfo(
ctx, log, "incoming request",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
)
start := time.Now()
next.ServeHTTP(w, r.WithContext(ctx))
elapsed := time.Since(start).Truncate(time.Millisecond)
xcontext.LogInfo(
ctx, log, "request processed",
slog.Duration("elapsed", elapsed),
)
})
}
}

120
cmd/kuriweb/main.go Normal file
View File

@ -0,0 +1,120 @@
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"time"
"git.loyso.art/frx/kurious/internal/common/client/sravni"
"git.loyso.art/frx/kurious/internal/common/config"
"git.loyso.art/frx/kurious/internal/common/xcontext"
"git.loyso.art/frx/kurious/internal/kurious/adapters"
xhttp "git.loyso.art/frx/kurious/internal/kurious/ports/http"
"git.loyso.art/frx/kurious/internal/kurious/service"
"golang.org/x/sync/errgroup"
)
func main() {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
err := app(ctx)
if err != nil {
println(err.Error())
os.Exit(1)
}
}
func app(ctx context.Context) error {
var cfgpath string
if len(os.Args) > 1 {
cfgpath = os.Args[1]
} else {
cfgpath = "config.json"
}
cfg, err := readFromFile(cfgpath, defaultConfig)
if err != nil {
return fmt.Errorf("reading config from file: %w", err)
}
log := config.NewSLogger(cfg.Log)
sravniClient, err := sravni.NewClient(ctx, log, false)
if err != nil {
return fmt.Errorf("unable to make new sravni client: %w", err)
}
mainPageState, err := sravniClient.GetMainPageState()
if err != nil {
return fmt.Errorf("getting main page state: %w", err)
}
dictionaries := mainPageState.Props.InitialReduxState.Dictionaries.Data
dictFieldsAsMap := func(fields ...sravni.Field) map[string]string {
out := make(map[string]string, len(fields))
for _, field := range fields {
out[field.Value] = field.Name
}
return out
}
courseThematcisMapped := dictFieldsAsMap(dictionaries.CourseThematics.Fields...)
learningTypeMapped := dictFieldsAsMap(dictionaries.LearningType.Fields...)
mapper := adapters.NewMemoryMapper(courseThematcisMapped, learningTypeMapped)
app, err := service.NewApplication(ctx, service.ApplicationConfig{
LogConfig: cfg.Log,
YDB: cfg.YDB,
}, mapper)
if err != nil {
return fmt.Errorf("making new application: %w", err)
}
httpAPI := xhttp.NewServer(app, log.With(slog.String("component", "http")))
httpServer := setupHTTP(cfg.HTTP, httpAPI, log)
eg, egctx := errgroup.WithContext(ctx)
eg.Go(func() error {
xcontext.LogInfo(
ctx, log, "serving http",
slog.String("addr", httpServer.Addr),
)
if err := httpServer.ListenAndServe(); err != nil {
if !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("listening http: %w", err)
}
}
return nil
})
eg.Go(func() error {
<-egctx.Done()
xcontext.LogInfo(ctx, log, "trying to shutdown http")
sdctx, sdcancel := context.WithTimeout(context.Background(), time.Second*10)
defer sdcancel()
err := httpServer.Shutdown(sdctx)
if err != nil {
return fmt.Errorf("shutting down the server: %w", err)
}
xcontext.LogInfo(ctx, log, "server closed successfuly")
return nil
})
return eg.Wait()
}

19
go.mod
View File

@ -4,34 +4,31 @@ go 1.21
require ( require (
github.com/go-resty/resty/v2 v2.10.0 github.com/go-resty/resty/v2 v2.10.0
github.com/stretchr/testify v1.8.4 github.com/gorilla/mux v1.8.1
github.com/robfig/cron/v3 v3.0.0
github.com/teris-io/cli v1.0.1 github.com/teris-io/cli v1.0.1
github.com/ydb-platform/ydb-go-sdk/v3 v3.54.2
github.com/ydb-platform/ydb-go-yc v0.12.1
golang.org/x/net v0.18.0 golang.org/x/net v0.18.0
golang.org/x/sync v0.5.0
golang.org/x/time v0.5.0
) )
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/a-h/templ v0.2.513 // indirect
github.com/go-chi/chi/v5 v5.0.10 // indirect
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect github.com/golang/protobuf v1.5.3 // indirect
github.com/google/uuid v1.4.0 // indirect github.com/google/uuid v1.4.0 // indirect
github.com/jonboulle/clockwork v0.4.0 // indirect github.com/jonboulle/clockwork v0.4.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/stretchr/testify v1.8.4 // indirect
github.com/robfig/cron/v3 v3.0.0 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/yandex-cloud/go-genproto v0.0.0-20231120081503-a21e9fe75162 // indirect github.com/yandex-cloud/go-genproto v0.0.0-20231120081503-a21e9fe75162 // indirect
github.com/ydb-platform/ydb-go-genproto v0.0.0-20231012155159-f85a672542fd // indirect github.com/ydb-platform/ydb-go-genproto v0.0.0-20231012155159-f85a672542fd // indirect
github.com/ydb-platform/ydb-go-sdk/v3 v3.54.2 // indirect
github.com/ydb-platform/ydb-go-yc v0.12.1 // indirect
github.com/ydb-platform/ydb-go-yc-metadata v0.6.1 // indirect github.com/ydb-platform/ydb-go-yc-metadata v0.6.1 // indirect
golang.org/x/sync v0.5.0 // indirect
golang.org/x/sys v0.14.0 // indirect golang.org/x/sys v0.14.0 // indirect
golang.org/x/text v0.14.0 // indirect golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.5.0 // indirect
google.golang.org/genproto v0.0.0-20231120223509-83a465c0220f // indirect google.golang.org/genproto v0.0.0-20231120223509-83a465c0220f // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f // indirect google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f // indirect
google.golang.org/grpc v1.59.0 // indirect google.golang.org/grpc v1.59.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

13
go.sum
View File

@ -515,6 +515,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk= github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/a-h/templ v0.2.513 h1:ZmwGAOx4NYllnHy+FTpusc4+c5msoMpPIYX0Oy3dNqw=
github.com/a-h/templ v0.2.513/go.mod h1:9gZxTLtRzM3gQxO8jr09Na0v8/jfliS97S9W5SScanM=
github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY= github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY=
github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk= github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk=
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
@ -572,8 +574,6 @@ github.com/envoyproxy/protoc-gen-validate v0.10.0/go.mod h1:DRjgyB0I43LtJapqN6Ni
github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-fonts/dejavu v0.1.0/go.mod h1:4Wt4I4OU2Nq9asgDCteaAaWZOV24E+0/Pwo0gppep4g= github.com/go-fonts/dejavu v0.1.0/go.mod h1:4Wt4I4OU2Nq9asgDCteaAaWZOV24E+0/Pwo0gppep4g=
github.com/go-fonts/latin-modern v0.2.0/go.mod h1:rQVLdDMK+mK1xscDwsqM5J8U2jrRa3T0ecnM9pNujks= github.com/go-fonts/latin-modern v0.2.0/go.mod h1:rQVLdDMK+mK1xscDwsqM5J8U2jrRa3T0ecnM9pNujks=
github.com/go-fonts/liberation v0.1.1/go.mod h1:K6qoJYypsmfVjWg8KOVDQhLc8UDgIK2HYqyqAO9z7GY= github.com/go-fonts/liberation v0.1.1/go.mod h1:K6qoJYypsmfVjWg8KOVDQhLc8UDgIK2HYqyqAO9z7GY=
@ -589,7 +589,6 @@ github.com/go-pdf/fpdf v0.6.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhO
github.com/go-resty/resty/v2 v2.10.0 h1:Qla4W/+TMmv0fOeeRqzEpXPLfTUnR5HZ1+lGs+CkiCo= github.com/go-resty/resty/v2 v2.10.0 h1:Qla4W/+TMmv0fOeeRqzEpXPLfTUnR5HZ1+lGs+CkiCo=
github.com/go-resty/resty/v2 v2.10.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A= github.com/go-resty/resty/v2 v2.10.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A=
github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v4 v4.4.1 h1:pC5DB52sCeK48Wlb9oPcdhnjkz1TKt1D/P7WKJ0kUcQ=
github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
@ -609,6 +608,7 @@ github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@ -648,6 +648,7 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
@ -691,6 +692,8 @@ github.com/googleapis/gax-go/v2 v2.6.0/go.mod h1:1mjbznJAPHFpesgE5ucqfYEscaz5kMd
github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8= github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8=
github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4= github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4=
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks= github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3/go.mod h1:o//XUCC/F+yRGJoPO/VU0GSB0f8Nhgmxx0VIRUvaC0w= github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3/go.mod h1:o//XUCC/F+yRGJoPO/VU0GSB0f8Nhgmxx0VIRUvaC0w=
@ -756,7 +759,6 @@ github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z
github.com/spf13/afero v1.9.2/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y= github.com/spf13/afero v1.9.2/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@ -1092,7 +1094,6 @@ golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxb
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20220922220347-f3bd1da661af/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20220922220347-f3bd1da661af/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.1.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.1.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
@ -1427,10 +1428,8 @@ google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqw
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

View File

@ -11,7 +11,7 @@ import (
"time" "time"
"git.loyso.art/frx/kurious/internal/common/errors" "git.loyso.art/frx/kurious/internal/common/errors"
"git.loyso.art/frx/kurious/pkg/slices" "git.loyso.art/frx/kurious/internal/common/xslices"
"git.loyso.art/frx/kurious/pkg/xdefault" "git.loyso.art/frx/kurious/pkg/xdefault"
"github.com/go-resty/resty/v2" "github.com/go-resty/resty/v2"
@ -52,8 +52,8 @@ func NewClient(ctx context.Context, log *slog.Logger, debug bool) (c *client, er
return nil, err return nil, err
} }
getQuerySet := func(fields []field) querySet { getQuerySet := func(fields []Field) querySet {
items := slices.Map(fields, func(f field) string { items := xslices.Map(fields, func(f Field) string {
return f.Value return f.Value
}) })

View File

@ -39,7 +39,7 @@ type ReduxMetadata struct {
} `json:"data"` } `json:"data"`
} }
type field struct { type Field struct {
Name string `json:"name"` Name string `json:"name"`
Value string `json:"value"` Value string `json:"value"`
} }
@ -51,7 +51,7 @@ type ReduxDictionaryContainer struct {
UserID string `json:"userId"` UserID string `json:"userId"`
Created time.Time `json:"created"` Created time.Time `json:"created"`
Updated time.Time `json:"updated"` Updated time.Time `json:"updated"`
Fields []field `json:"fields"` Fields []Field `json:"fields"`
} }
type ReduxDictionaries struct { type ReduxDictionaries struct {

View File

@ -2,4 +2,6 @@ package config
type HTTP struct { type HTTP struct {
ListenAddr string `json:"listen_addr"` ListenAddr string `json:"listen_addr"`
MountLive bool `json:"mount_live"`
Engine string `json:"engine"`
} }

View File

@ -27,6 +27,7 @@ type YDB struct {
DSN string DSN string
Auth YCAuth Auth YCAuth
ShutdownDuration time.Duration ShutdownDuration time.Duration
DebugYDB bool
} }
func (ydb *YDB) UnmarshalJSON(data []byte) error { func (ydb *YDB) UnmarshalJSON(data []byte) error {

View File

@ -19,19 +19,19 @@ func WithLogFields(ctx context.Context, fields ...slog.Attr) context.Context {
} }
func LogDebug(ctx context.Context, log *slog.Logger, msg string, attrs ...slog.Attr) { func LogDebug(ctx context.Context, log *slog.Logger, msg string, attrs ...slog.Attr) {
log.LogAttrs(ctx, slog.LevelDebug, msg, append(attrs, getLogFields(ctx)...)...) log.LogAttrs(ctx, slog.LevelDebug, msg, append(getLogFields(ctx), attrs...)...)
} }
func LogInfo(ctx context.Context, log *slog.Logger, msg string, attrs ...slog.Attr) { func LogInfo(ctx context.Context, log *slog.Logger, msg string, attrs ...slog.Attr) {
log.LogAttrs(ctx, slog.LevelInfo, msg, append(attrs, getLogFields(ctx)...)...) log.LogAttrs(ctx, slog.LevelInfo, msg, append(getLogFields(ctx), attrs...)...)
} }
func LogWarn(ctx context.Context, log *slog.Logger, msg string, attrs ...slog.Attr) { func LogWarn(ctx context.Context, log *slog.Logger, msg string, attrs ...slog.Attr) {
log.LogAttrs(ctx, slog.LevelWarn, msg, append(attrs, getLogFields(ctx)...)...) log.LogAttrs(ctx, slog.LevelWarn, msg, append(getLogFields(ctx), attrs...)...)
} }
func LogError(ctx context.Context, log *slog.Logger, msg string, attrs ...slog.Attr) { func LogError(ctx context.Context, log *slog.Logger, msg string, attrs ...slog.Attr) {
log.LogAttrs(ctx, slog.LevelError, msg, append(attrs, getLogFields(ctx)...)...) log.LogAttrs(ctx, slog.LevelError, msg, append(getLogFields(ctx), attrs...)...)
} }
func LogWithWarnError(ctx context.Context, log *slog.Logger, err error, msg string, attrs ...slog.Attr) { func LogWithWarnError(ctx context.Context, log *slog.Logger, err error, msg string, attrs ...slog.Attr) {

View File

@ -1,4 +1,4 @@
package xslice package xslices
func Filter[T any](values []T, f func(T) bool) []T { func Filter[T any](values []T, f func(T) bool) []T {
out := make([]T, 0, len(values)) out := make([]T, 0, len(values))

View File

@ -1,9 +1,9 @@
package xslice_test package xslices_test
import ( import (
"testing" "testing"
"git.loyso.art/frx/kurious/internal/common/xslice" "git.loyso.art/frx/kurious/internal/common/xslices"
) )
func TestFilterInplace(t *testing.T) { func TestFilterInplace(t *testing.T) {
@ -43,7 +43,7 @@ func TestFilterInplace(t *testing.T) {
for _, tc := range tt { for _, tc := range tt {
tc := tc tc := tc
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
gotLen := xslice.FilterInplace(tc.in, tc.check) gotLen := xslices.FilterInplace(tc.in, tc.check)
if gotLen != tc.expLen { if gotLen != tc.expLen {
t.Errorf("exp %d got %d", tc.expLen, gotLen) t.Errorf("exp %d got %d", tc.expLen, gotLen)
} }

View File

@ -1,4 +1,4 @@
package xslice package xslices
func ForEach[T any](items []T, f func(T)) { func ForEach[T any](items []T, f func(T)) {
for _, item := range items { for _, item := range items {

View File

@ -1,4 +1,4 @@
package xslice package xslices
func Map[T, U any](in []T, f func(T) U) []U { func Map[T, U any](in []T, f func(T) U) []U {
out := make([]U, len(in)) out := make([]U, len(in))

View File

@ -0,0 +1,21 @@
package adapters
type inMemoryMapper struct {
courseThematicsByID map[string]string
learningTypeByID map[string]string
}
func NewMemoryMapper(courseThematics, learningType map[string]string) inMemoryMapper {
return inMemoryMapper{
courseThematicsByID: courseThematics,
learningTypeByID: learningType,
}
}
func (m inMemoryMapper) CourseThematicNameByID(id string) string {
return m.courseThematicsByID[id]
}
func (m inMemoryMapper) LearningTypeNameByID(id string) string {
return m.learningTypeByID[id]
}

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"log/slog" "log/slog"
"os"
"path" "path"
"strings" "strings"
"text/template" "text/template"
@ -17,13 +18,38 @@ import (
"git.loyso.art/frx/kurious/pkg/xdefault" "git.loyso.art/frx/kurious/pkg/xdefault"
"github.com/ydb-platform/ydb-go-sdk/v3" "github.com/ydb-platform/ydb-go-sdk/v3"
ydblog "github.com/ydb-platform/ydb-go-sdk/v3/log"
"github.com/ydb-platform/ydb-go-sdk/v3/table" "github.com/ydb-platform/ydb-go-sdk/v3/table"
"github.com/ydb-platform/ydb-go-sdk/v3/table/options" "github.com/ydb-platform/ydb-go-sdk/v3/table/options"
"github.com/ydb-platform/ydb-go-sdk/v3/table/result/named" "github.com/ydb-platform/ydb-go-sdk/v3/table/result/named"
"github.com/ydb-platform/ydb-go-sdk/v3/table/types" "github.com/ydb-platform/ydb-go-sdk/v3/table/types"
"github.com/ydb-platform/ydb-go-sdk/v3/trace"
yc "github.com/ydb-platform/ydb-go-yc" yc "github.com/ydb-platform/ydb-go-yc"
) )
var coursesFields = []string{
"id",
"external_id",
"source_type",
"source_name",
"course_thematic",
"learning_type",
"organization_id",
"origin_link",
"image_link",
"name",
"description",
"full_price",
"discount",
"duration",
"starts_at",
"created_at",
"updated_at",
"deleted_at",
}
var coursesFieldsStr = strings.Join(coursesFields, ",")
const ( const (
defaultShutdownTimeout = time.Second * 10 defaultShutdownTimeout = time.Second * 10
) )
@ -36,7 +62,7 @@ type YDBConnection struct {
} }
func NewYDBConnection(ctx context.Context, cfg config.YDB, log *slog.Logger) (*YDBConnection, error) { func NewYDBConnection(ctx context.Context, cfg config.YDB, log *slog.Logger) (*YDBConnection, error) {
opts := make([]ydb.Option, 0, 2) opts := make([]ydb.Option, 0, 3)
switch auth := cfg.Auth.(type) { switch auth := cfg.Auth.(type) {
case config.YCAuthIAMToken: case config.YCAuthIAMToken:
opts = append(opts, ydb.WithAccessTokenCredentials(auth.Token)) opts = append(opts, ydb.WithAccessTokenCredentials(auth.Token))
@ -46,6 +72,12 @@ func NewYDBConnection(ctx context.Context, cfg config.YDB, log *slog.Logger) (*Y
yc.WithServiceAccountKeyFileCredentials(auth.Path), yc.WithServiceAccountKeyFileCredentials(auth.Path),
) )
} }
if cfg.DebugYDB {
opts = append(opts,
ydb.WithLogger(ydblog.Default(os.Stdout, ydblog.WithMinLevel(ydblog.DEBUG)), trace.DetailsAll),
)
}
db, err := ydb.Open( db, err := ydb.Open(
ctx, ctx,
cfg.DSN, cfg.DSN,
@ -54,6 +86,14 @@ func NewYDBConnection(ctx context.Context, cfg config.YDB, log *slog.Logger) (*Y
if err != nil { if err != nil {
return nil, fmt.Errorf("opening connection: %w", err) return nil, fmt.Errorf("opening connection: %w", err)
} }
endpoints, err := db.Discovery().Discover(ctx)
if err != nil {
return nil, fmt.Errorf("discovering endpoints: %w", err)
}
for _, endpoint := range endpoints {
xcontext.LogInfo(ctx, log, "discovered endpoint", slog.String("value", endpoint.Address()))
}
return &YDBConnection{ return &YDBConnection{
Driver: db, Driver: db,
@ -87,59 +127,31 @@ func (r *ydbCourseRepository) List(
) (result domain.ListCoursesResult, err error) { ) (result domain.ListCoursesResult, err error) {
const limit = 1000 const limit = 1000
const queryName = "list" const queryName = "list"
// const query = `
// DECLARE $limit AS Int32;
// DECLARE $id AS Text;
// SELECT
// id,
// external_id,
// source_type,
// source_name,
// course_thematic,
// learning_type,
// organization_id,
// origin_link,
// image_link,
// name,
// description,
// full_price,
// discount,
// duration,
// starts_at,
// created_at,
// updated_at,
// deleted_at
// FROM
// courses
// WHERE
// id > $id
// ORDER BY id
// LIMIT $limit;`
//
const fields = `id, external_id, source_type, source_name, course_thematic, learning_type, organization_id, origin_link, image_link, name, description, full_price, discount, duration, starts_at, created_at, updated_at, deleted_at`
if params.Limit == 0 { if params.Limit == 0 {
params.Limit = limit params.Limit = limit
} }
qtParams := queryTemplateParams{ qtParams := queryTemplateParams{
Fields: fields, Fields: coursesFieldsStr,
Table: "courses", Table: "courses",
Suffix: "ORDER BY id\nLIMIT $limit", Suffix: "ORDER BY learning_type,course_thematic,id\nLIMIT $limit",
Declares: []queryTemplateDeclaration{{ Declares: []queryTemplateDeclaration{
Name: "limit", {
Type: "Int32", Name: "limit",
}, { Type: "Int32",
Name: "id", },
Type: "Text", {
}}, Name: "id",
Type: "Text",
},
},
Conditions: []string{ Conditions: []string{
"id > $id", "id > $id",
}, },
} }
options := make([]table.ParameterOption, 0, 4) opts := make([]table.ParameterOption, 0, 4)
appendParams := func(name string, value string) { appendTextParam := func(name string, value string) {
if value == "" { if value == "" {
return return
} }
@ -151,18 +163,23 @@ func (r *ydbCourseRepository) List(
} }
qtParams.Declares = append(qtParams.Declares, d) qtParams.Declares = append(qtParams.Declares, d)
qtParams.Conditions = append(qtParams.Conditions, d.Name+"="+d.Arg()) qtParams.Conditions = append(qtParams.Conditions, d.Name+"="+d.Arg())
options = append(options, table.ValueParam(d.Arg(), ydbvalue)) opts = append(opts, table.ValueParam(d.Arg(), ydbvalue))
} }
appendParams("course_thematic", params.CourseThematic) appendTextParam("course_thematic", params.CourseThematic)
appendParams("learning_type", params.LearningType) appendTextParam("learning_type", params.LearningType)
var sb strings.Builder opts = append(
err = template.Must(template.New("").Parse(queryTemplateSelect)).Execute(&sb, qtParams) opts,
table.ValueParam("$id", types.TextValue(params.NextPageToken)),
table.ValueParam("$limit", types.Int32Value(int32(params.Limit))),
)
query, err := qtParams.render()
if err != nil { if err != nil {
return result, fmt.Errorf("executing template: %w", err) return result, fmt.Errorf("rendering query params: %w", err)
} }
query := sb.String() xcontext.LogInfo(ctx, r.log, "query prepared", slog.String("query", query), slog.String("args", tableParamOptsToString(opts...)))
courses := make([]domain.Course, 0, 1_000) courses := make([]domain.Course, 0, 1_000)
readTx := table.TxControl( readTx := table.TxControl(
@ -171,12 +188,13 @@ func (r *ydbCourseRepository) List(
), ),
table.CommitTx(), table.CommitTx(),
) )
err = r.db.Table().Do( err = r.db.Table().Do(
ctx, ctx,
func(ctx context.Context, s table.Session) error { func(ctx context.Context, s table.Session) error {
start := time.Now() start := time.Now()
defer func() { defer func() {
since := time.Since(start) since := time.Since(start).Truncate(time.Millisecond)
xcontext.LogInfo( xcontext.LogInfo(
ctx, r.log, ctx, r.log,
"executed query", "executed query",
@ -185,53 +203,227 @@ func (r *ydbCourseRepository) List(
) )
}() }()
var lastKnownID string queryParams := table.NewQueryParameters(opts...)
for {
queryParams := table.NewQueryParameters(
table.ValueParam("$limit", types.Int32Value(limit)),
table.ValueParam("$id", types.TextValue(lastKnownID)),
)
_, res, err := s.Execute(
ctx, readTx, query, queryParams,
options.WithCollectStatsModeBasic(),
)
if err != nil {
return fmt.Errorf("executing: %w", err)
}
if !res.NextResultSet(ctx) || !res.HasNextRow() { _, res, err := s.Execute(
break ctx, readTx, query, queryParams,
} options.WithCollectStatsModeBasic(),
)
for res.NextRow() { if err != nil {
var cdb courseDB return fmt.Errorf("executing: %w", err)
err = res.ScanNamed(cdb.getNamedValues()...)
if err != nil {
return fmt.Errorf("scanning row: %w", err)
}
courses = append(courses, mapCourseDB(cdb))
}
if err = res.Err(); err != nil {
return err
}
lastKnownID = courses[len(courses)-1].ID
} }
if !res.NextResultSet(ctx) || !res.HasNextRow() {
return nil
}
for res.NextRow() {
var cdb courseDB
err = res.ScanNamed(cdb.getNamedValues()...)
if err != nil {
return fmt.Errorf("scanning row: %w", err)
}
courses = append(courses, mapCourseDB(cdb))
}
if err = res.Err(); err != nil {
return err
}
result.NextPageToken = courses[len(courses)-1].ID
xcontext.LogDebug(ctx, r.log, "scanned rows", slog.Int("count", len(courses)))
return nil return nil
}, },
table.WithIdempotent()) table.WithIdempotent())
if err != nil { if err != nil {
return nil, err return domain.ListCoursesResult{}, err
} }
return courses, err result.Courses = courses
return result, err
} }
func (r *ydbCourseRepository) Get(ctx context.Context, id string) (course domain.Course, err error) { func (r *ydbCourseRepository) ListLearningTypes(
const queryName = "get" ctx context.Context,
) (result domain.ListLearningTypeResult, err error) {
const queryName = "list_learning_type"
const querySelect = `SELECT DISTINCT learning_type FROM courses;`
readTx := table.TxControl(
table.BeginTx(
table.WithOnlineReadOnly(),
),
table.CommitTx(),
)
err = r.db.Table().Do(
ctx,
func(ctx context.Context, s table.Session) error {
start := time.Now()
defer func() {
since := time.Since(start).Truncate(time.Millisecond)
xcontext.LogInfo(
ctx, r.log,
"executed query",
slog.String("name", queryName),
slog.Duration("elapsed", since),
)
}()
_, res, err := s.Execute(
ctx, readTx, querySelect, table.NewQueryParameters(),
options.WithCollectStatsModeNone(),
)
if err != nil {
return fmt.Errorf("executing query: %w", err)
}
if !res.NextResultSet(ctx) || !res.HasNextRow() {
return nil
}
for res.NextRow() {
var learningTypeID string
if err = res.Scan(&learningTypeID); err != nil {
return fmt.Errorf("scanning row: %w", err)
}
result.LearningTypeIDs = append(result.LearningTypeIDs, learningTypeID)
}
if err = res.Err(); err != nil {
return err
}
xcontext.LogDebug(ctx, r.log, "scanned rows", slog.Int("count", len(result.LearningTypeIDs)))
return nil
},
table.WithIdempotent(),
)
if err != nil {
return result, err
}
return result, nil
}
func (r *ydbCourseRepository) ListCourseThematics(
ctx context.Context,
params domain.ListCourseThematicsParams,
) (result domain.ListCourseThematicsResult, err error) {
const queryName = "list_course_thematics"
qtParams := queryTemplateParams{
Fields: "DISTINCT course_thematic",
Table: "courses",
Declares: []queryTemplateDeclaration{},
Conditions: []string{},
}
learningTypeValue := types.TextValue(params.LearningTypeID)
d := queryTemplateDeclaration{
Name: "learning_type",
Type: learningTypeValue.Type().String(),
}
qtParams.Declares = append(qtParams.Declares, d)
qtParams.Conditions = append(qtParams.Conditions, d.Name+"="+d.Arg())
opts := []table.ParameterOption{
table.ValueParam(d.Arg(), learningTypeValue),
}
query, err := qtParams.render()
if err != nil {
return result, fmt.Errorf("rendering query params: %w", err)
}
readTx := table.TxControl(
table.BeginTx(
table.WithOnlineReadOnly(),
),
table.CommitTx(),
)
err = r.db.Table().Do(
ctx,
func(ctx context.Context, s table.Session) error {
start := time.Now()
defer func() {
since := time.Since(start).Truncate(time.Millisecond)
xcontext.LogInfo(
ctx, r.log,
"executed query",
slog.String("name", queryName),
slog.Duration("elapsed", since),
)
}()
_, res, err := s.Execute(
ctx, readTx, query, table.NewQueryParameters(opts...),
options.WithCollectStatsModeNone(),
)
if err != nil {
return fmt.Errorf("executing query: %w", err)
}
if !res.NextResultSet(ctx) || !res.HasNextRow() {
return nil
}
for res.NextRow() {
var courseThematicID string
if err = res.Scan(&courseThematicID); err != nil {
return fmt.Errorf("scanning row: %w", err)
}
result.CourseThematicIDs = append(result.CourseThematicIDs, courseThematicID)
}
if err = res.Err(); err != nil {
return err
}
xcontext.LogDebug(ctx, r.log, "scanned rows", slog.Int("count", len(result.CourseThematicIDs)))
return nil
},
table.WithIdempotent(),
)
if err != nil {
return result, err
}
return result, nil
}
func (r *ydbCourseRepository) Get(
ctx context.Context,
id string,
) (course domain.Course, err error) {
const queryName = "get"
const querySelect = `DECLARE $id AS Text;
SELECT
id,
external_id,
source_type,
source_name,
course_thematic,
learning_type,
organization_id,
origin_link,
image_link,
name,
description,
full_price,
discount,
duration,
starts_at,
created_at,
updated_at,
deleted_at
FROM
courses
WHERE
id = $id;`
courses := make([]domain.Course, 0, 1)
readTx := table.TxControl( readTx := table.TxControl(
table.BeginTx( table.BeginTx(
table.WithOnlineReadOnly(), table.WithOnlineReadOnly(),
@ -255,73 +447,79 @@ func (r *ydbCourseRepository) Get(ctx context.Context, id string) (course domain
_, res, err := s.Execute( _, res, err := s.Execute(
ctx, ctx,
readTx, readTx,
` querySelect,
DECLARE $id AS Text;
SELECT
id,
external_id,
source_type,
source_name,
course_thematic,
learning_type,
organization_id,
origin_link,
image_link,
name,
description,
full_price,
discount,
duration,
starts_at,
created_at,
updated_at,
deleted_at
FROM
courses
WHERE
id = $id;
`,
table.NewQueryParameters( table.NewQueryParameters(
table.ValueParam("$id", types.TextValue(id)), table.ValueParam("$id", types.TextValue(id)),
), ),
options.WithCollectStatsModeBasic(), options.WithCollectStatsModeBasic(),
) )
if err != nil { if err != nil {
return fmt.Errorf("executing: %w", err) return fmt.Errorf("executing query: %w", err)
} }
for res.NextResultSet(ctx) { if !res.NextResultSet(ctx) || !res.HasNextRow() {
for res.NextRow() { return errors.ErrNotFound
var cdb courseDB }
_ = res.ScanNamed(cdb.getNamedValues()...)
courses = append(courses, mapCourseDB(cdb)) for res.NextRow() {
var cdb courseDB
err = res.ScanNamed(cdb.getNamedValues()...)
if err != nil {
return fmt.Errorf("scanning row: %w", err)
} }
course = mapCourseDB(cdb)
} }
if err = res.Err(); err != nil { if err = res.Err(); err != nil {
return err return err
} }
stats := res.Stats()
xcontext.LogInfo(
ctx, r.log, "query stats",
slog.String("ast", stats.QueryAST()),
slog.String("plan", stats.QueryPlan()),
slog.Duration("total_cpu_time", stats.TotalCPUTime()),
slog.Duration("total_duration", stats.TotalDuration()),
slog.Duration("process_cpu_time", stats.ProcessCPUTime()),
)
return nil return nil
}, },
table.WithIdempotent()) table.WithIdempotent(),
if err != nil { )
return domain.Course{}, err return course, err
}
if len(courses) == 0 {
return course, errors.ErrNotFound
}
return courses[0], err
} }
func (r *ydbCourseRepository) GetByExternalID(ctx context.Context, id string) (domain.Course, error) { func (r *ydbCourseRepository) GetByExternalID(ctx context.Context, id string) (domain.Course, error) {
return domain.Course{}, nil return domain.Course{}, nil
} }
func createCourseParamsAsStruct(params domain.CreateCourseParams) types.Value { type updateCourseParams struct {
st := mapSourceTypeFromDomain(params.SourceType) domain.CreateCourseParams
CreatedAt time.Time
DeletedAt nullable.Value[time.Time]
}
func updateCourseParamsAsStruct(params updateCourseParams) types.Value {
opts := createCourseParamsAsStructValues(params.CreateCourseParams)
now := time.Now() now := time.Now()
return types.StructValue( return types.StructValue(
append(
opts[:len(opts)-3],
types.StructFieldValue("created_at", types.DatetimeValueFromTime(params.CreatedAt)),
types.StructFieldValue("updated_at", types.DatetimeValueFromTime(now)),
types.StructFieldValue("deleted_at", types.NullableDatetimeValue(nil)),
)...,
)
}
func createCourseParamsAsStructValues(params domain.CreateCourseParams) []types.StructValueOption {
st := mapSourceTypeFromDomain(params.SourceType)
now := time.Now()
return []types.StructValueOption{
types.StructFieldValue("id", types.TextValue(params.ID)), types.StructFieldValue("id", types.TextValue(params.ID)),
types.StructFieldValue("name", types.TextValue(params.Name)), types.StructFieldValue("name", types.TextValue(params.Name)),
types.StructFieldValue("source_type", types.TextValue(st)), types.StructFieldValue("source_type", types.TextValue(st)),
@ -340,11 +538,16 @@ func createCourseParamsAsStruct(params domain.CreateCourseParams) types.Value {
types.StructFieldValue("created_at", types.DatetimeValueFromTime(now)), types.StructFieldValue("created_at", types.DatetimeValueFromTime(now)),
types.StructFieldValue("updated_at", types.DatetimeValueFromTime(now)), types.StructFieldValue("updated_at", types.DatetimeValueFromTime(now)),
types.StructFieldValue("deleted_at", types.NullableDatetimeValue(nil)), types.StructFieldValue("deleted_at", types.NullableDatetimeValue(nil)),
}
}
func createCourseParamsAsStruct(params domain.CreateCourseParams) types.Value {
return types.StructValue(
createCourseParamsAsStructValues(params)...,
) )
} }
func (r *ydbCourseRepository) CreateBatch(ctx context.Context, params ...domain.CreateCourseParams) error { func (r *ydbCourseRepository) CreateBatch(ctx context.Context, params ...domain.CreateCourseParams) error {
// -- PRAGMA TablePathPrefix("courses");
const upsertQuery = `DECLARE $courseData AS List<Struct< const upsertQuery = `DECLARE $courseData AS List<Struct<
id: Text, id: Text,
external_id: Optional<Text>, external_id: Optional<Text>,
@ -423,6 +626,100 @@ func (r *ydbCourseRepository) Delete(ctx context.Context, id string) error {
return nil return nil
} }
func (r *ydbCourseRepository) UpdateCourseDescription(ctx context.Context, id, description string) error {
course, err := r.Get(ctx, id)
if err != nil {
return fmt.Errorf("getting course: %w", err)
}
params := updateCourseParams{
CreateCourseParams: domain.CreateCourseParams{
ID: course.ID,
ExternalID: course.ExternalID,
Name: course.Name,
SourceType: course.SourceType,
SourceName: course.SourceName,
CourseThematic: course.Thematic,
LearningType: course.LearningType,
OrganizationID: course.OrganizationID,
OriginLink: course.OriginLink,
ImageLink: course.ImageLink,
Description: description,
FullPrice: course.FullPrice,
Discount: course.Discount,
Duration: course.Duration,
StartsAt: course.StartsAt,
},
CreatedAt: course.CreatedAt,
DeletedAt: course.DeletedAt,
}
updateStruct := updateCourseParamsAsStruct(params)
const upsertQuery = `DECLARE $courseData AS List<Struct<
id: Text,
external_id: Optional<Text>,
name: Text,
source_type: Text,
source_name: Optional<Text>,
course_thematic: Text,
learning_type: Text,
organization_id: Text,
origin_link: Text,
image_link: Text,
description: Text,
full_price: Double,
discount: Double,
duration: Interval,
starts_at: Datetime,
created_at: Datetime,
updated_at: Datetime,
deleted_at: Optional<Datetime>>>;
REPLACE INTO
courses
SELECT
id,
external_id,
name,
source_type,
source_name,
course_thematic,
learning_type,
organization_id,
origin_link,
image_link,
description,
full_price,
discount,
duration,
starts_at,
created_at,
updated_at,
deleted_at
FROM AS_TABLE($courseData);`
writeTx := table.TxControl(
table.BeginTx(
table.WithSerializableReadWrite(),
),
table.CommitTx(),
)
err = r.db.Table().Do(ctx, func(ctx context.Context, s table.Session) error {
queryParams := table.NewQueryParameters(
table.ValueParam("$courseData", types.ListValue(updateStruct)),
)
_, _, err := s.Execute(ctx, writeTx, upsertQuery, queryParams)
if err != nil {
return fmt.Errorf("executing query: %w", err)
}
return nil
})
return err
}
func (r *ydbCourseRepository) CreateCourseTable(ctx context.Context) error { func (r *ydbCourseRepository) CreateCourseTable(ctx context.Context) error {
return r.db.Table().Do(ctx, func(ctx context.Context, s table.Session) error { return r.db.Table().Do(ctx, func(ctx context.Context, s table.Session) error {
return s.CreateTable( return s.CreateTable(
@ -537,8 +834,8 @@ func mapCourseDB(cdb courseDB) domain.Course {
Name: cdb.Name, Name: cdb.Name,
SourceType: st, SourceType: st,
SourceName: nullable.NewValuePtr(cdb.SourceName), SourceName: nullable.NewValuePtr(cdb.SourceName),
Thematic: cdb.CourseThematic, ThematicID: cdb.CourseThematic,
LearningType: cdb.LearningType, LearningTypeID: cdb.LearningType,
OrganizationID: cdb.OrganizationID, OrganizationID: cdb.OrganizationID,
OriginLink: cdb.OriginLink, OriginLink: cdb.OriginLink,
ImageLink: cdb.ImageLink, ImageLink: cdb.ImageLink,
@ -579,9 +876,27 @@ type queryTemplateParams struct {
Suffix string Suffix string
} }
const queryTemplateSelect = ` func (p queryTemplateParams) render() (string, error) {
{{ range .Declares }}DECLARE ${{.Name}} AS {{.Type}}\n{{end}} var sb strings.Builder
sb.Grow(len(queryTemplateSelect) * 3)
err := querySelect.Execute(&sb, p)
return sb.String(), err
}
const queryTemplateSelect = `{{ range .Declares }}DECLARE ${{.Name}} AS {{.Type}};{{end}}
SELECT {{.Fields}} SELECT {{.Fields}}
FROM {{.Table}} FROM {{.Table}}
WHERE {{ range .Conditions }}{{.}}\n{{end}} WHERE 1=1 {{ range .Conditions }} AND {{.}} {{ end }}
{{.Suffix}}` {{.Suffix}}`
var querySelect = template.Must(template.New("").Parse(queryTemplateSelect))
func tableParamOptsToString(in ...table.ParameterOption) string {
var sb strings.Builder
for _, opt := range in {
sb.WriteString(opt.Name() + ":" + opt.Value().Yql() + ";")
// sb.WriteString(opt.Name() + " (" + opt.Value().Type().String() + "); ")
}
return sb.String()
}

View File

@ -6,14 +6,17 @@ import (
) )
type Commands struct { type Commands struct {
InsertCourses command.CreateCoursesHandler InsertCourses command.CreateCoursesHandler
InsertCourse command.CreateCourseHandler InsertCourse command.CreateCourseHandler
DeleteCourse command.DeleteCourseHandler DeleteCourse command.DeleteCourseHandler
UpdateCourseDescription command.UpdateCourseDescriptionHandler
} }
type Queries struct { type Queries struct {
GetCourse query.GetCourseHandler GetCourse query.GetCourseHandler
ListCourses query.ListCourseHandler ListCourses query.ListCourseHandler
ListLearningTypes query.ListLearningTypesHandler
ListCourseThematics query.ListCourseThematicsHandler
} }
type Application struct { type Application struct {

View File

@ -8,7 +8,7 @@ import (
"git.loyso.art/frx/kurious/internal/common/decorator" "git.loyso.art/frx/kurious/internal/common/decorator"
"git.loyso.art/frx/kurious/internal/common/nullable" "git.loyso.art/frx/kurious/internal/common/nullable"
"git.loyso.art/frx/kurious/internal/common/xslice" "git.loyso.art/frx/kurious/internal/common/xslices"
"git.loyso.art/frx/kurious/internal/kurious/domain" "git.loyso.art/frx/kurious/internal/kurious/domain"
) )
@ -78,7 +78,7 @@ func NewCreateCoursesHandler(
} }
func (h createCoursesHandler) Handle(ctx context.Context, cmd CreateCourses) error { func (h createCoursesHandler) Handle(ctx context.Context, cmd CreateCourses) error {
params := xslice.Map(cmd.Courses, func(in CreateCourse) (out domain.CreateCourseParams) { params := xslices.Map(cmd.Courses, func(in CreateCourse) (out domain.CreateCourseParams) {
return domain.CreateCourseParams(in) return domain.CreateCourseParams(in)
}) })
err := h.repo.CreateBatch(ctx, params...) err := h.repo.CreateBatch(ctx, params...)

View File

@ -0,0 +1,35 @@
package command
import (
"context"
"log/slog"
"git.loyso.art/frx/kurious/internal/common/decorator"
"git.loyso.art/frx/kurious/internal/kurious/domain"
)
type UpdateCourseDescription struct {
ID string
Description string
}
type UpdateCourseDescriptionHandler decorator.CommandHandler[UpdateCourseDescription]
type updateCourseDescriptionHandler struct {
repo domain.CourseRepository
}
func NewUpdateCourseDescriptionHandler(
repo domain.CourseRepository,
log *slog.Logger,
) UpdateCourseDescriptionHandler {
h := updateCourseDescriptionHandler{
repo: repo,
}
return decorator.ApplyCommandDecorators(h, log)
}
func (h updateCourseDescriptionHandler) Handle(ctx context.Context, cmd UpdateCourseDescription) error {
return h.repo.UpdateCourseDescription(ctx, cmd.ID, cmd.Description)
}

View File

@ -16,15 +16,18 @@ type GetCourse struct {
type GetCourseHandler decorator.QueryHandler[GetCourse, domain.Course] type GetCourseHandler decorator.QueryHandler[GetCourse, domain.Course]
type getCourseHandler struct { type getCourseHandler struct {
repo domain.CourseRepository repo domain.CourseRepository
mapper domain.CourseMapper
} }
func NewGetCourseHandler( func NewGetCourseHandler(
repo domain.CourseRepository, repo domain.CourseRepository,
mapper domain.CourseMapper,
log *slog.Logger, log *slog.Logger,
) GetCourseHandler { ) GetCourseHandler {
h := getCourseHandler{ h := getCourseHandler{
repo: repo, repo: repo,
mapper: mapper,
} }
return decorator.AddQueryDecorators(h, log) return decorator.AddQueryDecorators(h, log)
} }
@ -35,5 +38,8 @@ func (h getCourseHandler) Handle(ctx context.Context, query GetCourse) (domain.C
return domain.Course{}, fmt.Errorf("getting course: %w", err) return domain.Course{}, fmt.Errorf("getting course: %w", err)
} }
course.LearningType = h.mapper.LearningTypeNameByID(course.LearningTypeID)
course.Thematic = h.mapper.CourseThematicNameByID(course.ThematicID)
return course, nil return course, nil
} }

View File

@ -6,6 +6,7 @@ import (
"log/slog" "log/slog"
"git.loyso.art/frx/kurious/internal/common/decorator" "git.loyso.art/frx/kurious/internal/common/decorator"
"git.loyso.art/frx/kurious/internal/common/xslices"
"git.loyso.art/frx/kurious/internal/kurious/domain" "git.loyso.art/frx/kurious/internal/kurious/domain"
) )
@ -16,37 +17,64 @@ type ListCourse struct {
OrganizationID string OrganizationID string
Keyword string Keyword string
Limit int Limit int
Offset int NextPageToken string
} }
type ListCourseHandler decorator.QueryHandler[ListCourse, []domain.Course] type ListCourseHandler decorator.QueryHandler[ListCourse, domain.ListCoursesResult]
type listCourseHandler struct { type listCourseHandler struct {
repo domain.CourseRepository repo domain.CourseRepository
mapper domain.CourseMapper
} }
func NewListCourseHandler( func NewListCourseHandler(
repo domain.CourseRepository, repo domain.CourseRepository,
mapper domain.CourseMapper,
log *slog.Logger, log *slog.Logger,
) ListCourseHandler { ) ListCourseHandler {
h := listCourseHandler{ h := listCourseHandler{
repo: repo, repo: repo,
mapper: mapper,
} }
return decorator.AddQueryDecorators(h, log) return decorator.AddQueryDecorators(h, log)
} }
func (h listCourseHandler) Handle(ctx context.Context, query ListCourse) ([]domain.Course, error) { func (h listCourseHandler) Handle(ctx context.Context, query ListCourse) (out domain.ListCoursesResult, err error) {
courses, err := h.repo.List(ctx, domain.ListCoursesParams{ out.NextPageToken = query.NextPageToken
CourseThematic: query.CourseThematic, drainFull := query.Limit == 0
LearningType: query.LearningType, if !drainFull {
OrganizationID: query.OrganizationID, out.Courses = make([]domain.Course, 0, query.Limit)
Limit: query.Limit, }
Offset: query.Offset, for {
}) result, err := h.repo.List(ctx, domain.ListCoursesParams{
if err != nil { CourseThematic: query.CourseThematic,
return nil, fmt.Errorf("listing courses: %w", err) LearningType: query.LearningType,
OrganizationID: query.OrganizationID,
Limit: query.Limit,
NextPageToken: out.NextPageToken,
})
if err != nil {
return out, fmt.Errorf("listing courses: %w", err)
}
result.Courses = xslices.Map(result.Courses, func(in domain.Course) domain.Course {
in.LearningType = h.mapper.LearningTypeNameByID(in.LearningTypeID)
in.Thematic = h.mapper.CourseThematicNameByID(in.ThematicID)
return in
})
out.Courses = append(out.Courses, result.Courses...)
out.NextPageToken = result.NextPageToken
if drainFull && len(result.Courses) > 0 && result.NextPageToken != "" {
continue
}
break
} }
return courses, nil return out, nil
} }

View File

@ -0,0 +1,62 @@
package query
import (
"context"
"fmt"
"log/slog"
"git.loyso.art/frx/kurious/internal/common/decorator"
"git.loyso.art/frx/kurious/internal/kurious/domain"
)
type ListCourseThematics struct {
LearningTypeID string
}
type CourseThematic struct {
ID string
Name string
}
type ListCourseThematicsResult struct {
CourseThematics []CourseThematic
}
type ListCourseThematicsHandler decorator.QueryHandler[ListCourseThematics, ListCourseThematicsResult]
type listCourseThematicsHandler struct {
repo domain.CourseRepository
mapper domain.CourseMapper
}
func NewListCourseThematicsHandler(
repo domain.CourseRepository,
mapper domain.CourseMapper,
log *slog.Logger,
) ListCourseThematicsHandler {
h := listCourseThematicsHandler{
repo: repo,
mapper: mapper,
}
return decorator.AddQueryDecorators(h, log)
}
func (h listCourseThematicsHandler) Handle(ctx context.Context, query ListCourseThematics) (out ListCourseThematicsResult, err error) {
result, err := h.repo.ListCourseThematics(ctx, domain.ListCourseThematicsParams{
LearningTypeID: query.LearningTypeID,
})
if err != nil {
return out, fmt.Errorf("listing course thematics from repo: %w", err)
}
out.CourseThematics = make([]CourseThematic, 0, len(result.CourseThematicIDs))
for _, ct := range result.CourseThematicIDs {
var item CourseThematic
item.ID = ct
item.Name = h.mapper.CourseThematicNameByID(ct)
out.CourseThematics = append(out.CourseThematics, item)
}
return out, nil
}

View File

@ -0,0 +1,58 @@
package query
import (
"context"
"fmt"
"log/slog"
"git.loyso.art/frx/kurious/internal/common/decorator"
"git.loyso.art/frx/kurious/internal/kurious/domain"
)
type ListLearningTypes struct{}
type LearningType struct {
ID string
Name string
}
type ListLearningTypesResult struct {
LearningTypes []LearningType
}
type ListLearningTypesHandler decorator.QueryHandler[ListLearningTypes, ListLearningTypesResult]
type listLearningTypesHandler struct {
repo domain.CourseRepository
mapper domain.CourseMapper
}
func NewListLearningTypesHandler(
repo domain.CourseRepository,
mapper domain.CourseMapper,
log *slog.Logger,
) ListLearningTypesHandler {
h := listLearningTypesHandler{
repo: repo,
mapper: mapper,
}
return decorator.AddQueryDecorators(h, log)
}
func (h listLearningTypesHandler) Handle(ctx context.Context, query ListLearningTypes) (out ListLearningTypesResult, err error) {
result, err := h.repo.ListLearningTypes(ctx)
if err != nil {
return out, fmt.Errorf("listing learning types from repo: %w", err)
}
out.LearningTypes = make([]LearningType, 0, len(result.LearningTypeIDs))
for _, lt := range result.LearningTypeIDs {
var item LearningType
item.ID = lt
item.Name = h.mapper.LearningTypeNameByID(lt)
out.LearningTypes = append(out.LearningTypes, item)
}
return out, nil
}

View File

@ -27,9 +27,13 @@ type Course struct {
// FullPrice is a course full price without discount. // FullPrice is a course full price without discount.
FullPrice float64 FullPrice float64
// Discount for the course. // Discount for the course.
Discount float64 Discount float64
Thematic string
LearningType string Thematic string
ThematicID string
LearningType string
LearningTypeID string
// Duration for the course. It will be splitted in values like: // Duration for the course. It will be splitted in values like:
// full month / full day / full hour. // full month / full day / full hour.

View File

@ -0,0 +1,6 @@
package domain
type CourseMapper interface {
CourseThematicNameByID(string) string
LearningTypeNameByID(string) string
}

View File

@ -14,7 +14,6 @@ type ListCoursesParams struct {
NextPageToken string NextPageToken string
Limit int Limit int
Offset int
} }
type CreateCourseParams struct { type CreateCourseParams struct {
@ -40,10 +39,24 @@ type ListCoursesResult struct {
NextPageToken string NextPageToken string
} }
type ListLearningTypeResult struct {
LearningTypeIDs []string
}
type ListCourseThematicsParams struct {
LearningTypeID string
}
type ListCourseThematicsResult struct {
CourseThematicIDs []string
}
//go:generate mockery --name CourseRepository //go:generate mockery --name CourseRepository
type CourseRepository interface { type CourseRepository interface {
// List courses by specifid parameters. // List courses by specifid parameters.
List(ctx context.Context, params ListCoursesParams) (ListCoursesResult, error) List(context.Context, ListCoursesParams) (ListCoursesResult, error)
ListLearningTypes(context.Context) (ListLearningTypeResult, error)
ListCourseThematics(context.Context, ListCourseThematicsParams) (ListCourseThematicsResult, error)
// Get course by id. // Get course by id.
// Should return ErrNotFound in case course not found. // Should return ErrNotFound in case course not found.
Get(ctx context.Context, id string) (Course, error) Get(ctx context.Context, id string) (Course, error)
@ -57,6 +70,9 @@ type CourseRepository interface {
Create(context.Context, CreateCourseParams) (Course, error) Create(context.Context, CreateCourseParams) (Course, error)
// Delete course by id. // Delete course by id.
Delete(ctx context.Context, id string) error Delete(ctx context.Context, id string) error
// UpdateCourseDescription is a temporary method to udpate description field
UpdateCourseDescription(ctx context.Context, id string, description string) error
} }
type CreateOrganizationParams struct { type CreateOrganizationParams struct {

View File

@ -14,7 +14,7 @@ import (
"git.loyso.art/frx/kurious/internal/common/generator" "git.loyso.art/frx/kurious/internal/common/generator"
"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/common/xcontext"
"git.loyso.art/frx/kurious/internal/common/xslice" "git.loyso.art/frx/kurious/internal/common/xslices"
"git.loyso.art/frx/kurious/internal/kurious/app/command" "git.loyso.art/frx/kurious/internal/kurious/app/command"
"git.loyso.art/frx/kurious/internal/kurious/app/query" "git.loyso.art/frx/kurious/internal/kurious/app/query"
"git.loyso.art/frx/kurious/internal/kurious/domain" "git.loyso.art/frx/kurious/internal/kurious/domain"
@ -126,7 +126,7 @@ func (h *syncSravniHandler) Handle(ctx context.Context) (err error) {
return fmt.Errorf("loading educational products: %w", err) return fmt.Errorf("loading educational products: %w", err)
} }
xslice.ForEach(buffer, func(c sravni.Course) { xslices.ForEach(buffer, func(c sravni.Course) {
c.Learningtype = []string{learningType.Value} c.Learningtype = []string{learningType.Value}
c.CourseThematics = []string{courseThematic} c.CourseThematics = []string{courseThematic}
courses = append(courses, c) courses = append(courses, c)
@ -155,7 +155,7 @@ func (h *syncSravniHandler) Handle(ctx context.Context) (err error) {
continue continue
} }
xslice.ForEach(courses, func(c sravni.Course) { xslices.ForEach(courses, func(c sravni.Course) {
h.knownExternalIDs[c.ID] = struct{}{} h.knownExternalIDs[c.ID] = struct{}{}
}) })
@ -212,7 +212,7 @@ func (h *syncSravniHandler) loadEducationalProducts(ctx context.Context, learnin
} }
func (h *syncSravniHandler) filterByCache(courses []sravni.Course) (toInsert []sravni.Course) { func (h *syncSravniHandler) filterByCache(courses []sravni.Course) (toInsert []sravni.Course) {
toCut := xslice.FilterInplace(courses, xslice.Not(h.isCached)) toCut := xslices.FilterInplace(courses, xslices.Not(h.isCached))
return courses[:toCut] return courses[:toCut]
} }
@ -222,7 +222,7 @@ func (h *syncSravniHandler) isCached(course sravni.Course) bool {
} }
func (h *syncSravniHandler) insertValues(ctx context.Context, courses []sravni.Course) error { func (h *syncSravniHandler) insertValues(ctx context.Context, courses []sravni.Course) error {
courseParams := xslice.Map(courses, courseAsCreateCourseParams) courseParams := xslices.Map(courses, courseAsCreateCourseParams)
err := h.svc.Commands.InsertCourses.Handle(ctx, command.CreateCourses{ err := h.svc.Commands.InsertCourses.Handle(ctx, command.CreateCourses{
Courses: courseParams, Courses: courseParams,
}) })
@ -240,14 +240,16 @@ func (h *syncSravniHandler) fillCaches(ctx context.Context) error {
return nil return nil
} }
courses, err := h.svc.Queries.ListCourses.Handle(ctx, query.ListCourse{}) result, err := h.svc.Queries.ListCourses.Handle(ctx, query.ListCourse{})
if err != nil { if err != nil {
return fmt.Errorf("listing courses: %w", err) return fmt.Errorf("listing courses: %w", err)
} }
courses := result.Courses
h.knownExternalIDs = make(map[string]struct{}, len(courses)) h.knownExternalIDs = make(map[string]struct{}, len(courses))
xslice.ForEach(courses, func(c domain.Course) { xslices.ForEach(courses, func(c domain.Course) {
if !c.ExternalID.Valid() { if !c.ExternalID.Valid() {
return return
} }

View File

@ -1,49 +1,36 @@
package http package http
import ( import (
"html/template" "encoding/json"
"log/slog" "log/slog"
"net/http" "net/http"
"sort"
"strconv" "strconv"
"git.loyso.art/frx/kurious/internal/common/errors" "git.loyso.art/frx/kurious/internal/common/errors"
"git.loyso.art/frx/kurious/internal/common/xslice" "git.loyso.art/frx/kurious/internal/common/xcontext"
"git.loyso.art/frx/kurious/internal/kurious/app" "git.loyso.art/frx/kurious/internal/common/xslices"
"git.loyso.art/frx/kurious/internal/kurious/app/command"
"git.loyso.art/frx/kurious/internal/kurious/app/query" "git.loyso.art/frx/kurious/internal/kurious/app/query"
"git.loyso.art/frx/kurious/internal/kurious/domain" "git.loyso.art/frx/kurious/internal/kurious/domain"
"git.loyso.art/frx/kurious/internal/kurious/service"
"github.com/gorilla/mux"
) )
type courseServer struct { type courseServer struct {
app app.Application app service.Application
log *slog.Logger log *slog.Logger
} }
type pagination struct { type pagination struct {
page int nextPageToken string
perPage int perPage int
}
func (p pagination) LimitOffset() (limit, offset int) {
if p.page < 0 {
p.page = 0
}
if p.perPage <= 0 {
p.perPage = defaultPerPage
}
return p.perPage, p.page * p.perPage
} }
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()
out.nextPageToken = query.Get("next")
if query.Has("page") {
out.page, err = strconv.Atoi(query.Get("page"))
if err != nil {
return out, errors.NewValidationError("page", "bad page value")
}
}
if query.Has("per_page") { if query.Has("per_page") {
out.perPage, err = strconv.Atoi(query.Get("per_page")) out.perPage, err = strconv.Atoi(query.Get("per_page"))
@ -57,15 +44,20 @@ func parsePaginationFromQuery(r *http.Request) (out pagination, err error) {
return out, nil return out, nil
} }
const (
LearningTypePathParam = "learning_type"
ThematicTypePathParam = "thematic_type"
)
func parseListCoursesParams(r *http.Request) (out listCoursesParams, err error) { func parseListCoursesParams(r *http.Request) (out listCoursesParams, err error) {
out.pagination, err = parsePaginationFromQuery(r) out.pagination, err = parsePaginationFromQuery(r)
if err != nil { if err != nil {
return out, err return out, err
} }
query := r.URL.Query() vars := mux.Vars(r)
out.learningType = query.Get("category") out.learningType = vars[LearningTypePathParam]
out.courseThematic = query.Get("type") out.courseThematic = vars[ThematicTypePathParam]
return out, nil return out, nil
} }
@ -77,40 +69,274 @@ type listCoursesParams struct {
learningType string learningType string
} }
type templateCourse domain.Course type baseInfo struct {
ID string
Name string
Description string
}
func mapDomainCourseToTemplate(in ...domain.Course) []templateCourse { type categoryInfo struct {
return xslice.Map(in, func(v domain.Course) templateCourse { baseInfo
return templateCourse(v)
Subcategories []subcategoryInfo
}
type subcategoryInfo struct {
baseInfo
Courses []domain.Course
}
type IDNamePair struct {
ID string
Name string
IsActive bool
}
type listCoursesTemplateParams struct {
Categories []categoryInfo
NextPageToken string
AvailableLearningTypes []IDNamePair
AvailableCourseThematics []IDNamePair
ActiveLearningType string
LearningTypeName string
ActiveCourseThematic string
CourseThematicName string
}
func mapDomainCourseToTemplate(in ...domain.Course) listCoursesTemplateParams {
coursesBySubcategory := make(map[string][]domain.Course, len(in))
subcategoriesByCategories := make(map[string]map[string]struct{}, len(in))
categoryByID := make(map[string]baseInfo, len(in))
xslices.ForEach(in, func(c domain.Course) {
coursesBySubcategory[c.ThematicID] = append(coursesBySubcategory[c.ThematicID], c)
if _, ok := subcategoriesByCategories[c.LearningTypeID]; !ok {
subcategoriesByCategories[c.LearningTypeID] = map[string]struct{}{}
}
subcategoriesByCategories[c.LearningTypeID][c.ThematicID] = struct{}{}
if _, ok := categoryByID[c.LearningTypeID]; !ok {
categoryByID[c.LearningTypeID] = baseInfo{
ID: c.LearningTypeID,
Name: c.LearningType,
}
}
if _, ok := categoryByID[c.ThematicID]; !ok {
categoryByID[c.ThematicID] = baseInfo{
ID: c.ThematicID,
Name: c.Thematic,
}
}
}) })
var out listCoursesTemplateParams
for category, subcategoryMap := range subcategoriesByCategories {
outCategory := categoryInfo{
baseInfo: categoryByID[category],
}
for subcategory := range subcategoryMap {
outSubCategory := subcategoryInfo{
baseInfo: categoryByID[subcategory],
Courses: coursesBySubcategory[subcategory],
}
outCategory.Subcategories = append(outCategory.Subcategories, outSubCategory)
}
sort.Slice(outCategory.Subcategories, func(i, j int) bool {
return outCategory.Subcategories[i].ID < outCategory.Subcategories[j].ID
})
out.Categories = append(out.Categories, outCategory)
}
sort.Slice(out.Categories, func(i, j int) bool {
return out.Categories[i].ID < out.Categories[j].ID
})
return out
} }
func (c courseServer) List(w http.ResponseWriter, r *http.Request) { func (c courseServer) List(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
params, err := parseListCoursesParams(r) params, err := parseListCoursesParams(r)
if err != nil { if handleError(ctx, err, w, c.log, "unable to parse list courses params") {
handleError(ctx, err, w, c.log, "unable to parse list courses params")
return return
} }
limit, offset := params.LimitOffset() result, err := c.app.Queries.ListCourses.Handle(ctx, query.ListCourse{
courses, err := c.app.Queries.ListCourses.Handle(ctx, query.ListCourse{
CourseThematic: params.courseThematic, CourseThematic: params.courseThematic,
LearningType: params.learningType, LearningType: params.learningType,
Limit: limit, Limit: params.perPage,
Offset: offset, NextPageToken: params.nextPageToken,
}) })
if err != nil { if handleError(ctx, err, w, c.log, "unable to list courses") {
handleError(ctx, err, w, c.log, "unable to list courses") return
} }
courses := result.Courses
templateCourses := mapDomainCourseToTemplate(courses...) templateCourses := mapDomainCourseToTemplate(courses...)
templateCourses.NextPageToken = result.NextPageToken
err = template.Must(template.ParseFiles("templates/list.tmpl")).Execute(w, templateCourses) learningTypeList, err := c.app.Queries.ListLearningTypes.Handle(ctx, query.ListLearningTypes{})
if handleError(ctx, err, w, c.log, "unable to list learning types") {
return
}
templateCourses.AvailableLearningTypes = xslices.Map(learningTypeList.LearningTypes, func(in query.LearningType) IDNamePair {
if in.ID == params.learningType {
templateCourses.LearningTypeName = in.Name
}
return IDNamePair{
ID: in.ID,
Name: in.Name,
IsActive: in.ID == params.learningType,
}
})
templateCourses.ActiveLearningType = params.learningType
templateCourses.ActiveCourseThematic = params.courseThematic
if params.learningType != "" {
courseThematicsResult, err := c.app.Queries.ListCourseThematics.Handle(ctx, query.ListCourseThematics{
LearningTypeID: params.learningType,
})
if handleError(ctx, err, w, c.log, "unable to list course thematics") {
return
}
templateCourses.AvailableCourseThematics = xslices.Map(courseThematicsResult.CourseThematics, func(in query.CourseThematic) IDNamePair {
if in.ID == params.courseThematic {
templateCourses.CourseThematicName = in.Name
}
return IDNamePair{
ID: in.ID,
Name: in.Name,
IsActive: in.ID == params.courseThematic,
}
})
}
err = getCoreTemplate(ctx, c.log).ExecuteTemplate(w, "courses", templateCourses)
if handleError(ctx, err, w, c.log, "unable to execute template") {
return
}
}
func (c courseServer) Get(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := mux.Vars(r)["course_id"]
course, err := c.app.Queries.GetCourse.Handle(ctx, query.GetCourse{
ID: id,
})
if handleError(ctx, err, w, c.log, "unable to get course") {
return
}
payload, err := json.MarshalIndent(course, "", " ")
if handleError(ctx, err, w, c.log, "unable to marshal json") {
return
}
w.Header().Set("content-type", "application/json")
w.Header().Set("content-length", strconv.Itoa(len(payload)))
_, err = w.Write([]byte(payload))
if err != nil { if err != nil {
handleError(ctx, err, w, c.log, "unable to execute template") xcontext.LogWithWarnError(ctx, c.log, err, "unable to write a message")
}
}
func (c courseServer) GetShort(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := mux.Vars(r)["course_id"]
course, err := c.app.Queries.GetCourse.Handle(ctx, query.GetCourse{
ID: id,
})
if handleError(ctx, err, w, c.log, "unable to get course") {
return
}
err = getCoreTemplate(ctx, c.log).ExecuteTemplate(w, "course_info", course)
if handleError(ctx, err, w, c.log, "unable to execute template") {
return
}
}
func (c courseServer) RenderEditDescription(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := mux.Vars(r)["course_id"]
course, err := c.app.Queries.GetCourse.Handle(ctx, query.GetCourse{
ID: id,
})
if handleError(ctx, err, w, c.log, "unable to get course") {
return
}
err = getCoreTemplate(ctx, c.log).ExecuteTemplate(w, "edit_description", course)
if handleError(ctx, err, w, c.log, "unable to execute template") {
return
}
}
func (c courseServer) UpdateCourseDescription(w http.ResponseWriter, r *http.Request) {
type requestModel struct {
ID string `json:"-"`
Text string `json:"description"`
}
ctx := r.Context()
var req requestModel
req.ID = mux.Vars(r)["course_id"]
err := json.NewDecoder(r.Body).Decode(&req)
if handleError(ctx, err, w, c.log, "unable to read body") {
return
}
err = c.app.Commands.UpdateCourseDescription.Handle(ctx, command.UpdateCourseDescription{
ID: req.ID,
Description: req.Text,
})
if handleError(ctx, err, w, c.log, "unable to update course description") {
return
}
course, err := c.app.Queries.GetCourse.Handle(ctx, query.GetCourse{
ID: req.ID,
})
if handleError(ctx, err, w, c.log, "unable to get course") {
return
}
err = getCoreTemplate(ctx, c.log).ExecuteTemplate(w, "course_info", course)
if handleError(ctx, err, w, c.log, "unable to execute template") {
return
}
}
func (c courseServer) UdpateDescription(w http.ResponseWriter, r *http.Request) {
type requestModel struct {
ID string `json:"id"`
Text string `json:"text"`
}
ctx := r.Context()
var req requestModel
err := json.NewDecoder(r.Body).Decode(&req)
if handleError(ctx, err, w, c.log, "unable to read body") {
return
}
err = c.app.Commands.UpdateCourseDescription.Handle(ctx, command.UpdateCourseDescription{
ID: req.ID,
Description: req.Text,
})
if handleError(ctx, err, w, c.log, "unable to update course description") {
return return
} }

View File

@ -0,0 +1,60 @@
package http
import (
"strconv"
"strings"
"testing"
"time"
"git.loyso.art/frx/kurious/internal/kurious/domain"
)
var courses = func() []domain.Course {
out := make([]domain.Course, 0)
out = append(out, makeBatchCourses("prog", []string{"go", "rust"}, 4)...)
out = append(out, makeBatchCourses("front", []string{"js", "html"}, 4)...)
return out
}()
func makeBatchCourses(lt string, cts []string, num int) []domain.Course {
out := make([]domain.Course, 0, len(cts)*num)
for _, ct := range cts {
for i := 0; i < num; i++ {
name := strings.Join([]string{
lt, ct,
strconv.Itoa(i),
}, ".")
out = append(out, makeCourse(lt, ct, name))
}
}
return out
}
func makeCourse(lt, ct, name string) domain.Course {
return domain.Course{
LearningType: lt,
Thematic: ct,
Name: name,
ID: lt + ct + name,
FullPrice: 123,
Duration: time.Second * 100,
StartsAt: time.Now(),
}
}
func TestRenderTemplate(t *testing.T) {
t.SkipNow()
result := mapDomainCourseToTemplate(courses...)
t.Logf("%#v", result)
var out strings.Builder
err := listTemplateParsed.ExecuteTemplate(&out, "courses", result)
if err != nil {
t.Fatalf("executing: %v", err)
}
t.Log(out.String())
t.Fail()
}

View File

@ -0,0 +1,139 @@
package http
import (
"log/slog"
"net/http"
"git.loyso.art/frx/kurious/internal/common/xslices"
"git.loyso.art/frx/kurious/internal/kurious/app/query"
"git.loyso.art/frx/kurious/internal/kurious/domain"
xtempl "git.loyso.art/frx/kurious/internal/kurious/ports/http/templ"
"git.loyso.art/frx/kurious/internal/kurious/service"
)
type courseTemplServer struct {
app service.Application
log *slog.Logger
}
func makeTemplListCoursesParams(in ...domain.Course) xtempl.ListCoursesParams {
coursesBySubcategory := make(map[string][]xtempl.CourseInfo, len(in))
subcategoriesByCategories := make(map[string]map[string]struct{}, len(in))
categoryByID := make(map[string]xtempl.CategoryBaseInfo, len(in))
xslices.ForEach(in, func(c domain.Course) {
courseInfo := xtempl.CourseInfo{
ID: c.ID,
Name: c.Name,
FullPrice: int(c.FullPrice),
ImageLink: c.ImageLink,
OriginLink: c.OriginLink,
}
coursesBySubcategory[c.ThematicID] = append(coursesBySubcategory[c.ThematicID], courseInfo)
if _, ok := subcategoriesByCategories[c.LearningTypeID]; !ok {
subcategoriesByCategories[c.LearningTypeID] = map[string]struct{}{}
}
subcategoriesByCategories[c.LearningTypeID][c.ThematicID] = struct{}{}
if _, ok := categoryByID[c.LearningTypeID]; !ok {
categoryByID[c.LearningTypeID] = xtempl.CategoryBaseInfo{
ID: c.LearningTypeID,
Name: c.LearningType,
}
}
if _, ok := categoryByID[c.ThematicID]; !ok {
categoryByID[c.ThematicID] = xtempl.CategoryBaseInfo{
ID: c.ThematicID,
Name: c.Thematic,
}
}
})
var out xtempl.ListCoursesParams
for categoryID, subcategoriesID := range subcategoriesByCategories {
outCategory := xtempl.CategoryContainer{
CategoryBaseInfo: categoryByID[categoryID],
}
for subcategoryID := range subcategoriesID {
outSubcategory := xtempl.SubcategoryContainer{
CategoryBaseInfo: categoryByID[subcategoryID],
Courses: coursesBySubcategory[subcategoryID],
}
outCategory.Subcategories = append(outCategory.Subcategories, outSubcategory)
}
out.Categories = append(out.Categories, outCategory)
}
return out
}
func (c courseTemplServer) List(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
stats := xtempl.MakeNewStats(10_240, 2_560_000, 1800)
pathParams, err := parseListCoursesParams(r)
if handleError(ctx, err, w, c.log, "unable to parse list courses params") {
return
}
listCoursesResult, err := c.app.Queries.ListCourses.Handle(ctx, query.ListCourse{
CourseThematic: pathParams.courseThematic,
LearningType: pathParams.learningType,
Limit: pathParams.perPage,
NextPageToken: pathParams.nextPageToken,
})
if handleError(ctx, err, w, c.log, "unable to list courses") {
return
}
params := makeTemplListCoursesParams(listCoursesResult.Courses...)
learningTypeResult, err := c.app.Queries.ListLearningTypes.Handle(ctx, query.ListLearningTypes{})
if handleError(ctx, err, w, c.log, "unable to list learning types") {
return
}
params.FilterForm.AvailableLearningTypes = xslices.Map(learningTypeResult.LearningTypes, func(in query.LearningType) xtempl.Category {
outcategory := xtempl.Category{
ID: in.ID,
Name: in.Name,
}
if in.ID == pathParams.learningType {
params.FilterForm.BreadcrumbsParams.ActiveLearningType = outcategory
}
return outcategory
})
if pathParams.learningType != "" {
courseThematicsResult, err := c.app.Queries.ListCourseThematics.Handle(ctx, query.ListCourseThematics{
LearningTypeID: pathParams.learningType,
})
if handleError(ctx, err, w, c.log, "unab;e to list course thematics") {
return
}
params.FilterForm.AvailableCourseThematics = xslices.Map(courseThematicsResult.CourseThematics, func(in query.CourseThematic) xtempl.Category {
outcategory := xtempl.Category{
ID: in.ID,
Name: in.Name,
}
if pathParams.courseThematic == in.ID {
params.FilterForm.BreadcrumbsParams.ActiveCourseThematic = outcategory
}
return outcategory
})
}
err = xtempl.ListCourses(stats, params).Render(ctx, w)
if handleError(ctx, err, w, c.log, "unable to render list courses") {
return
}
}

View File

@ -0,0 +1,192 @@
package http
import (
"context"
"html/template"
"io/fs"
"log/slog"
"os"
"path"
"git.loyso.art/frx/kurious/internal/common/xcontext"
"git.loyso.art/frx/kurious/internal/common/xslices"
)
const baseTemplatePath = "./internal/kurious/ports/http/templates/"
func must[T any](t T, err error) T {
if err != nil {
panic(err.Error())
}
return t
}
func scanFiles() []string {
entries := xslices.Map(
must(os.ReadDir(baseTemplatePath)),
func(v fs.DirEntry) string {
return path.Join(baseTemplatePath, v.Name())
},
)
return entries
}
func getCoreTemplate(ctx context.Context, log *slog.Logger) *template.Template {
filenames := scanFiles()
out, err := template.New("courses").ParseFiles(filenames...)
if err != nil {
xcontext.LogWithWarnError(ctx, log, err, "unable to parse template")
return listTemplateParsed
}
return out
}
var listTemplateParsed = template.Must(
template.New("courses").
Parse(listTemplate),
)
const listTemplate = `{{define "courses"}}
<!DOCTYPE html>
<html>
<head>
<title>Courses</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
}
header {
background-color: #333;
color: white;
padding: 10px;
}
header h1 {
margin: 0;
}
nav ul {
list-style-type: none;
margin: 0;
padding: 0;
}
nav li {
display: inline;
margin-right: 10px;
}
nav a {
color: white;
text-decoration: none;
}
h1, h2, h3 {
margin-top: 0;
}
p {
margin-bottom: 10px;
}
.main-course {
background-color: #3B4252;
color: #E5E9F0;
text-align: center;
}
.sub-course {
background-color: #4C566A;
color: #ECEFF4;
}
.course-plate {
background-color: #f2f2f2;
border: 1px solid #ddd;
border-radius: 5px;
padding: 10px;
margin-bottom: 10px;
}
.course-plate a {
color: #333;
text-decoration: none;
}
.course-plate a:hover {
text-decoration: underline;
}
.editable-text {
cursor: pointer;
}
.editable-text.editing {
border: 1px solid #000;
padding: 5px;
width: 100%;
}
</style>
</head>
<body>
<header>
<h1>Courses</h1>
<nav>
<ul>
<li><a href="/">Main page</a></li>
<li><a href="/about">About us</a></li>
<li><a href="/help">Help</a></li>
</ul>
</nav>
</header>
{{range $category := .Categories}}
<h2 class="main-course">Category {{$category.Name}}</h2>
<p> Course Description: {{$category.Description}}</p>
{{range $subcategory := $category.Subcategories}}
<div>
<h2 class="sub-course"> Subcategory: {{$subcategory.Name}}</h2>
<p>Subcategory Description: {{$subcategory.Description}}</p>
{{range $course := $subcategory.Courses}}
<div class="course-plate">
<h3><a href="/courses/{{$course.ID}}">{{$course.Name}}</a></h3>
<p>Description: <div id="editable-text-{{$course.ID}}" class="editable-text" contenteditable=false>{{or $course.Description "..."}}</div></p>
<p>Full price: {{$course.FullPrice}}</p>
<p>Discount: {{$course.Discount}}</p>
<p>Thematic: {{$course.Thematic}}</p>
<p>Learning type: {{$course.LearningType}}</p>
<p>Duration: {{$course.Duration}}</p>
<p>Starts at: {{$course.StartsAt}}</p>
</div>
</div>
{{end}}
{{end}}
{{end}}
<button onclick="window.location.href='/courses/?next={{.NextPageToken}}'">Next Page</button>
<script>
const editableTexts = document.querySelectorAll('.editable-text');
let isEditing = false;
editableTexts.forEach(function(editableText) {
editableText.addEventListener('click', function() {
if (!isEditing) {
editableText.contentEditable = 'true';
editableText.className += ' editing';
isEditing = true;
}
});
editableText.addEventListener('keydown', function(event) {
if (event.key === 'Enter') {
event.preventDefault();
const text = editableText.innerText;
const id = editableText.id.replace('editable-text-', ''); // Extract the ID from the element's ID
// Send a POST request with JSON data
const xhr = new XMLHttpRequest();
xhr.open('POST', '/updatedesc', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.send(JSON.stringify({ text, id }));
editableText.contentEditable = 'false';
editableText.className = 'editable-text';
isEditing = false;
}
});
});
</script>
</body>
</html>
{{end}}`

View File

@ -8,30 +8,32 @@ 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/app" "git.loyso.art/frx/kurious/internal/kurious/service"
)
const (
defaultPerPage = 50
) )
type Server struct { type Server struct {
app app.Application app service.Application
log *slog.Logger
} }
func NewServer(app app.Application) Server { func NewServer(app service.Application, log *slog.Logger) Server {
return Server{} return Server{
} app: app,
log: log,
func (s Server) Courses() courseServer {
return courseServer{
app: s.app,
} }
} }
func handleError(ctx context.Context, err error, w http.ResponseWriter, log *slog.Logger, msg string) { func (s Server) Courses() courseServer {
return courseServer(s)
}
func (s Server) CoursesByTempl() courseTemplServer {
return courseTemplServer(s)
}
func handleError(ctx context.Context, err error, w http.ResponseWriter, log *slog.Logger, msg string) bool {
if err == nil { if err == nil {
return return false
} }
var errorString string var errorString string
@ -52,4 +54,6 @@ func handleError(ctx context.Context, err error, w http.ResponseWriter, log *slo
xcontext.LogWithWarnError(ctx, log, err, msg, slog.Int("status_code", code), slog.String("response", errorString)) xcontext.LogWithWarnError(ctx, log, err, msg, slog.Int("status_code", code), slog.String("response", errorString))
http.Error(w, errorString, code) http.Error(w, errorString, code)
return true
} }

View File

@ -0,0 +1,22 @@
package templ
templ button(title string, attributes templ.Attributes) {
<button class="button" { attributes... }>{ title }</button>
}
templ buttonRedirect(id, title string, linkTo string) {
<button
class="button"
id={ "origin-link-" + id }
>
{ title }
</button>
@onclickRedirect("origin-link-" + id, linkTo)
}
script onclickRedirect(id, to string) {
document.getElementById(id).onclick = () => {
location.href = to
}
}

View File

@ -0,0 +1,116 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.2.513
package templ
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import "context"
import "io"
import "bytes"
func button(title string, attributes templ.Attributes) 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_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<button class=\"button\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderAttributes(ctx, templ_7745c5c3_Buffer, attributes)
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
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/templ/common.templ`, Line: 3, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</button>")
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
})
}
func buttonRedirect(id, title string, linkTo string) 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_Var3 := templ.GetChildren(ctx)
if templ_7745c5c3_Var3 == nil {
templ_7745c5c3_Var3 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<button class=\"button\" id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString("origin-link-" + id))
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
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/templ/common.templ`, Line: 11, Col: 8}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = onclickRedirect("origin-link-"+id, linkTo).Render(ctx, templ_7745c5c3_Buffer)
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
})
}
func onclickRedirect(id, to string) templ.ComponentScript {
return templ.ComponentScript{
Name: `__templ_onclickRedirect_47ae`,
Function: `function __templ_onclickRedirect_47ae(id, to){document.getElementById(id).onclick = () => {
location.href = to
}}`,
Call: templ.SafeScript(`__templ_onclickRedirect_47ae`, id, to),
CallInline: templ.SafeScriptInline(`__templ_onclickRedirect_47ae`, id, to),
}
}

View File

@ -0,0 +1,62 @@
package templ
templ head() {
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Courses Aggregator</title>
<script src="https://unpkg.com/htmx.org@1.8.0"></script>
<script src="https://unpkg.com/htmx.org/dist/ext/json-enc.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css"/>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png"/>
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png"/>
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png"/>
<link rel="manifest" href="/site.webmanifest"/>
</head>
}
templ navigation() {
<nav class="navbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
Courses
</div>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
<div id="navbarBasicExample" class="navbar-menu">
<div class="navbar-start">
<a class="navbar-item">
Home
</a>
<a class="navbar-item">
Find
</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">
More
</a>
<div class="navbar-dropdown">
<a class="navbar-item">
About
</a>
<a class="navbar-item">
Contact
</a>
<hr class="navbar-divider"/>
<a class="navbar-item">
Report an issue
</a>
</div>
</div>
</div>
</div>
</nav>
}
templ footer() {
<footer>
Here will be a footer
</footer>
}

View File

@ -0,0 +1,182 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.2.513
package templ
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import "context"
import "io"
import "bytes"
func head() 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_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var2 := `Courses Aggregator`
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</title><script src=\"https://unpkg.com/htmx.org@1.8.0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var3 := ``
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</script><script src=\"https://unpkg.com/htmx.org/dist/ext/json-enc.js\">")
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("</script><link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css\"><link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/apple-touch-icon.png\"><link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"/favicon-32x32.png\"><link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"/favicon-16x16.png\"><link rel=\"manifest\" href=\"/site.webmanifest\"></head>")
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
})
}
func navigation() 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_Var5 := templ.GetChildren(ctx)
if templ_7745c5c3_Var5 == nil {
templ_7745c5c3_Var5 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<nav class=\"navbar\" role=\"navigation\" aria-label=\"main navigation\"><div class=\"navbar-brand\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var6 := `Courses`
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div><a role=\"button\" class=\"navbar-burger\" aria-label=\"menu\" aria-expanded=\"false\"><span aria-hidden=\"true\"></span> <span aria-hidden=\"true\"></span> <span aria-hidden=\"true\"></span></a><div id=\"navbarBasicExample\" class=\"navbar-menu\"><div class=\"navbar-start\"><a class=\"navbar-item\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var7 := `Home`
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</a> <a class=\"navbar-item\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var8 := `Find`
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</a><div class=\"navbar-item has-dropdown is-hoverable\"><a class=\"navbar-link\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var9 := `More`
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</a><div class=\"navbar-dropdown\"><a class=\"navbar-item\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var10 := `About`
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</a> <a class=\"navbar-item\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var11 := `Contact`
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</a><hr class=\"navbar-divider\"><a class=\"navbar-item\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var12 := `Report an issue`
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</a></div></div></div></div></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
})
}
func footer() 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_Var13 := templ.GetChildren(ctx)
if templ_7745c5c3_Var13 == nil {
templ_7745c5c3_Var13 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<footer>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var14 := `Here will be a footer`
_, 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("</footer>")
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
})
}

View File

@ -0,0 +1,209 @@
package templ
import "strconv"
import "fmt"
script breadcrumbsLoad() {
const formFilterOnSubmit = event => {
event.preventDefault();
const lt = document.getElementById('learning-type-filter');
const ct = document.getElementById('course-thematic-filter');
const prefix = (lt !== null && lt.value !== '') ? `/courses/${lt.value}` : `/courses`;
const out = (ct !== null && ct.value !== '') ? `${prefix}/${ct.value}` : prefix;
document.location.assign(out);
return false;
};
document.addEventListener('DOMContentLoaded', () => {
const ff = document.getElementById('filter-form');
if (ff === null) return;
ff.addEventListener('submit', formFilterOnSubmit);
});
}
templ breadcrumbItem(enabled bool, link string, isLink bool, title string) {
if enabled {
<li>
<a
if !isEmpty(link) {
href={ templ.SafeURL("/courses" + link) }
itemprop="url"
}
>
<span
itemprop="title"
if isEmpty(link) {
itemprop="url"
}
>
{ title }
</span>
</a>
</li>
}
}
templ listCourseHeader(params FilterFormParams) {
<div class="container block">
@breadcrumb(params.BreadcrumbsParams)
@filterForm(params)
</div>
}
templ breadcrumb(params BreadcrumbsParams) {
<nav
class="breadcrumb"
aria-label="breadcrumbs"
itemprop="breadcrumb"
itemtype="https://schema.org/BreadcrumbList"
itemscope
>
<ul>
@breadcrumbItem(true, "/courses", !isEmpty(params.ActiveLearningType.ID), "Курсы")
@breadcrumbItem(!params.ActiveLearningType.Empty(), "/" + params.ActiveLearningType.ID, !params.ActiveCourseThematic.Empty(), params.ActiveLearningType.Name)
@breadcrumbItem(!params.ActiveCourseThematic.Empty(), "", false, params.ActiveCourseThematic.Name)
</ul>
</nav>
}
templ filterForm(params FilterFormParams) {
<form id="filter-form" class="columns">
<div class="select">
<select id="learning-type-filter" name="learning_type">
<option value="">All learnings</option>
for _, item := range params.AvailableLearningTypes {
<option
value={ item.ID }
if item.ID == params.ActiveLearningType.ID {
selected
}
>
{ item.Name }
</option>
}
</select>
</div>
if !params.ActiveLearningType.Empty() {
<div class="select">
<select id="course-thematic-filter" name="course_thematic">
<option value="">All course thematics</option>
for _, item := range params.AvailableCourseThematics {
<option
value={ item.ID }
if item.ID == params.ActiveCourseThematic.ID {
selected
}
>
{ item.Name }
</option>
}
</select>
</div>
}
@button("goto", templ.Attributes{"id": "go-to-filter"})
</form>
}
templ listCoursesContainer(categories []CategoryContainer) {
<div id="category-course-list" class="container">
for _, category := range categories {
<div class="box">
<div class="title is-3">
<a href={ templ.URL("/courses/" + category.ID) }>{ category.Name }</a>
</div>
<div class="subtitle is-6">
This category contains a lot of interesing courses. Check them out!
</div>
for _, subcategory := range category.Subcategories {
<div class="box">
<div class="title is-4">
<a href={ templ.URL(fmt.Sprintf("/courses/%s/%s", category.ID, subcategory.ID)) }>
{ subcategory.Name }
</a>
<div class="columns is-multiline">
for _, course := range subcategory.Courses {
@courseInfoElement(course)
}
</div>
</div>
</div>
}
</div>
}
</div>
}
templ ListCourses(s stats, params ListCoursesParams) {
@root(s) {
@listCourseHeader(params.FilterForm)
@listCoursesContainer(params.Categories)
<div id="course-info"></div>
}
}
templ root(s stats) {
<!DOCTYPE html>
<html>
@head()
<body>
@navigation()
<nav class="level">
<div class="level-item has-text-centered">
<div>
<p class="heading">Courses</p>
<p class="title">{ s.CoursesCount }</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="heading">Clients</p>
<p class="title">{ s.ClientsCount }</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="heading">Categories</p>
<p class="title">{ s.CategoriesCount }</p>
</div>
</div>
</nav>
<div class="section">
{ children... }
</div>
@footer()
@breadcrumbsLoad()
</body>
</html>
}
templ courseInfoElement(params CourseInfo) {
<article class="column is-one-quarter" hx-target="this" hx-swap="outerHTML">
<div class="card">
<div class="card-image">
<figure class="image">
<img src={ params.ImageLink }/>
</figure>
</div>
<div class="card-content">
<div class="media-content">
<p class="title is-5">{ params.Name }</p>
<p class="subtitle is-8">oh well</p>
</div>
<div class="content">
if params.FullPrice > 0 {
<p>{ strconv.Itoa(params.FullPrice) } руб.</p>
} else {
<p>Бесплатно</p>
}
</div>
@buttonRedirect(params.ID, "Show course", params.OriginLink)
</div>
</div>
</article>
}

View File

@ -0,0 +1,714 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.2.513
package templ
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import "context"
import "io"
import "bytes"
import "strconv"
import "fmt"
func breadcrumbsLoad() templ.ComponentScript {
return templ.ComponentScript{
Name: `__templ_breadcrumbsLoad_9a1d`,
Function: `function __templ_breadcrumbsLoad_9a1d(){const formFilterOnSubmit = event => {
event.preventDefault();
const lt = document.getElementById('learning-type-filter');
const ct = document.getElementById('course-thematic-filter');
const prefix = (lt !== null && lt.value !== '') ? ` + "`" + `/courses/${lt.value}` + "`" + ` : ` + "`" + `/courses` + "`" + `;
const out = (ct !== null && ct.value !== '') ? ` + "`" + `${prefix}/${ct.value}` + "`" + ` : prefix;
document.location.assign(out);
return false;
};
document.addEventListener('DOMContentLoaded', () => {
const ff = document.getElementById('filter-form');
if (ff === null) return;
ff.addEventListener('submit', formFilterOnSubmit);
});}`,
Call: templ.SafeScript(`__templ_breadcrumbsLoad_9a1d`),
CallInline: templ.SafeScriptInline(`__templ_breadcrumbsLoad_9a1d`),
}
}
func breadcrumbItem(enabled bool, link string, isLink bool, title string) 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_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
if enabled {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<li><a")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !isEmpty(link) {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 templ.SafeURL = templ.SafeURL("/courses" + link)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var2)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" itemprop=\"url\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("><span itemprop=\"title\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if isEmpty(link) {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" itemprop=\"url\"")
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
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/templ/list.templ`, Line: 41, Col: 12}
}
_, 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("</span></a></li>")
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
})
}
func listCourseHeader(params FilterFormParams) 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_Var4 := templ.GetChildren(ctx)
if templ_7745c5c3_Var4 == nil {
templ_7745c5c3_Var4 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"container block\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = breadcrumb(params.BreadcrumbsParams).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = filterForm(params).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div>")
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
})
}
func breadcrumb(params BreadcrumbsParams) 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_Var5 := templ.GetChildren(ctx)
if templ_7745c5c3_Var5 == nil {
templ_7745c5c3_Var5 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<nav class=\"breadcrumb\" aria-label=\"breadcrumbs\" itemprop=\"breadcrumb\" itemtype=\"https://schema.org/BreadcrumbList\" itemscope><ul>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = breadcrumbItem(true, "/courses", !isEmpty(params.ActiveLearningType.ID), "Курсы").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = breadcrumbItem(!params.ActiveLearningType.Empty(), "/"+params.ActiveLearningType.ID, !params.ActiveCourseThematic.Empty(), params.ActiveLearningType.Name).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = breadcrumbItem(!params.ActiveCourseThematic.Empty(), "", false, params.ActiveCourseThematic.Name).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</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
})
}
func filterForm(params FilterFormParams) 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_Var6 := templ.GetChildren(ctx)
if templ_7745c5c3_Var6 == nil {
templ_7745c5c3_Var6 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<form id=\"filter-form\" class=\"columns\"><div class=\"select\"><select id=\"learning-type-filter\" name=\"learning_type\"><option value=\"\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var7 := `All learnings`
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</option> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, item := range params.AvailableLearningTypes {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(item.ID))
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
}
if item.ID == params.ActiveLearningType.ID {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" selected")
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
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/templ/list.templ`, Line: 83, Col: 23}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</select></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !params.ActiveLearningType.Empty() {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"select\"><select id=\"course-thematic-filter\" name=\"course_thematic\"><option value=\"\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var9 := `All course thematics`
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</option> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, item := range params.AvailableCourseThematics {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(item.ID))
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
}
if item.ID == params.ActiveCourseThematic.ID {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" selected")
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
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/templ/list.templ`, Line: 99, Col: 25}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</select></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = button("goto", templ.Attributes{"id": "go-to-filter"}).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</form>")
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
})
}
func listCoursesContainer(categories []CategoryContainer) 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)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div id=\"category-course-list\" class=\"container\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, category := range categories {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"box\"><div class=\"title is-3\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 templ.SafeURL = templ.URL("/courses/" + category.ID)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var12)))
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
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(category.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/templ/list.templ`, Line: 114, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</a></div><div class=\"subtitle is-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var14 := `This category contains a lot of interesing courses. Check them out!`
_, 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("</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, subcategory := range category.Subcategories {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"box\"><div class=\"title is-4\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 templ.SafeURL = templ.URL(fmt.Sprintf("/courses/%s/%s", category.ID, subcategory.ID))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var15)))
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
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(subcategory.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/templ/list.templ`, Line: 123, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</a><div class=\"columns is-multiline\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, course := range subcategory.Courses {
templ_7745c5c3_Err = courseInfoElement(course).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div>")
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
})
}
func ListCourses(s stats, params ListCoursesParams) 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_Var17 := templ.GetChildren(ctx)
if templ_7745c5c3_Var17 == nil {
templ_7745c5c3_Var17 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var18 := 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)
}
templ_7745c5c3_Err = listCourseHeader(params.FilterForm).Render(ctx, templ_7745c5c3_Buffer)
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 = listCoursesContainer(params.Categories).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" <div id=\"course-info\"></div>")
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(s).Render(templ.WithChildren(ctx, templ_7745c5c3_Var18), templ_7745c5c3_Buffer)
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
})
}
func root(s 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 {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var19 := templ.GetChildren(ctx)
if templ_7745c5c3_Var19 == nil {
templ_7745c5c3_Var19 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<!doctype html><html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = head().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<body>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = navigation().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<nav class=\"level\"><div class=\"level-item has-text-centered\"><div><p class=\"heading\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var20 := `Courses`
_, 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("</p><p class=\"title\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(s.CoursesCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/templ/list.templ`, Line: 158, Col: 39}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</p></div></div><div class=\"level-item has-text-centered\"><div><p class=\"heading\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var22 := `Clients`
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</p><p class=\"title\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(s.ClientsCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/templ/list.templ`, Line: 164, Col: 39}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</p></div></div><div class=\"level-item has-text-centered\"><div><p class=\"heading\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var24 := `Categories`
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</p><p class=\"title\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(s.CategoriesCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/templ/list.templ`, Line: 170, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</p></div></div></nav><div class=\"section\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ_7745c5c3_Var19.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = footer().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = breadcrumbsLoad().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</body></html>")
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
})
}
func courseInfoElement(params CourseInfo) 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_Var26 := templ.GetChildren(ctx)
if templ_7745c5c3_Var26 == nil {
templ_7745c5c3_Var26 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<article class=\"column is-one-quarter\" hx-target=\"this\" hx-swap=\"outerHTML\"><div class=\"card\"><div class=\"card-image\"><figure class=\"image\"><img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(params.ImageLink))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"></figure></div><div class=\"card-content\"><div class=\"media-content\"><p class=\"title is-5\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(params.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/templ/list.templ`, Line: 193, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</p><p class=\"subtitle is-8\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var28 := `oh well`
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</p></div><div class=\"content\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if params.FullPrice > 0 {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(params.FullPrice))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/templ/list.templ`, Line: 198, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
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_Var30 := `руб.`
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var31 := `Бесплатно`
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = buttonRedirect(params.ID, "Show course", params.OriginLink).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div></div></article>")
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
})
}

View File

@ -0,0 +1,95 @@
package templ
import (
"strconv"
"strings"
)
func getCompactedValue(value int) string {
var (
myValue float64
dim string
)
switch {
case value/1e6 > 0:
cutted := value / 1e3
myValue, dim = float64(cutted)/1e3, "m"
case value/1e3 > 0:
myValue, dim = float64(value/1e3), "k"
default:
myValue, dim = float64(value), ""
}
return strings.TrimSuffix(strconv.FormatFloat(myValue, 'f', 3, 32), ".000") + dim
}
func MakeNewStats(courses, clients, categories int) stats {
return stats{
CoursesCount: getCompactedValue(courses),
ClientsCount: getCompactedValue(clients),
CategoriesCount: getCompactedValue(categories),
}
}
type stats struct {
CoursesCount string
ClientsCount string
CategoriesCount string
}
type Category struct {
ID string
Name string
}
func (c Category) Empty() bool {
return c == (Category{})
}
type BreadcrumbsParams struct {
ActiveLearningType Category
ActiveCourseThematic Category
}
type FilterFormParams struct {
BreadcrumbsParams
AvailableLearningTypes []Category
AvailableCourseThematics []Category
}
func isEmpty(s string) bool {
return s == ""
}
type CourseInfo struct {
ID string
Name string
FullPrice int
ImageLink string
OriginLink string
}
type CategoryBaseInfo struct {
ID string
Name string
Description string
}
type CategoryContainer struct {
CategoryBaseInfo
Subcategories []SubcategoryContainer
}
type SubcategoryContainer struct {
CategoryBaseInfo
Courses []CourseInfo
}
type ListCoursesParams struct {
FilterForm FilterFormParams
Categories []CategoryContainer
}

View File

@ -0,0 +1,57 @@
package templ
import "testing"
func TestGetCompactedValue(t *testing.T) {
var tt = []struct {
name string
in int
exp string
}{
{
name: "less than 1k",
in: 666,
exp: "666",
},
{
name: "exactly 1k",
in: 1000,
exp: "1k",
},
{
name: "some thousands",
in: 12345,
exp: "12k",
},
{
name: "more thousands",
in: 123456,
exp: "123k",
},
{
name: "million",
in: 1e6,
exp: "1m",
},
{
name: "some millions",
in: 2e6,
exp: "2m",
},
{
name: "more complex value",
in: 1.2346e6,
exp: "1.234m",
},
}
for _, tc := range tt {
tc := tc
t.Run(tc.name, func(t *testing.T) {
got := getCompactedValue(tc.in)
if tc.exp != got {
t.Errorf("exp=%s got=%s", tc.exp, got)
}
})
}
}

View File

@ -0,0 +1,73 @@
{{ define "html_head" }}
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Courses Aggregator</title>
<script src="https://unpkg.com/htmx.org@1.8.0"></script>
<script src="https://unpkg.com/htmx.org/dist/ext/json-enc.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
</head>
{{ end }}
{{ define "header" }}
<nav class="navbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
Courses
</div>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
<div id="navbarBasicExample" class="navbar-menu">
<div class="navbar-start">
<a class="navbar-item">
Home
</a>
<a class="navbar-item">
Find
</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">
More
</a>
<div class="navbar-dropdown">
<a class="navbar-item">
About
</a>
<a class="navbar-item">
Contact
</a>
<hr class="navbar-divider">
<a class="navbar-item">
Report an issue
</a>
</div>
</div>
</div>
</div>
</nav>
{{ end }}
{{ define "footer" }}
<footer>
Here will be footer
</footer>
{{ end }}

View File

@ -0,0 +1,81 @@
{{ define "course_info" }}
<article class="column is-one-quarter" hx-target="this" hx-swap="outerHTML">
<div class="card">
<div class="card-image">
<figure class="image">
<img src="{{ .ImageLink }}">
</figure>
</div>
<div class="card-content">
<div class="media-content">
<p class="title is-5" onclick="location.href='{{ .OriginLink }}'">{{ .Name }}</p>
<p class="subtitle is-8">{{ .Description }}</p>
</div>
<div class="content">
{{ if .FullPrice }}
<p>{{ .FullPrice }} rub.</p>
{{ else }}
<p>Бесплатно</p>
{{ end }}
</div>
<button class="button" onclick="location.href='{{ .OriginLink }}'">
Show Course
</button>
<button class="button" hx-get="/courses/{{ .ID }}/editdesc">
Edit description
</button>
<!-- <button class="button" hx-get="/courses/{{ .ID }}/" hx-target="#course-info" hx-swap="innerHTML">
View course
</button> -->
</div>
</div>
</article>
{{ end }}
{{ define "edit_description" }}
<form
hx-ext="json-enc"
hx-put="/courses/{{ .ID }}/description"
hx-target="this"
hx-swap="outerHTML"
>
<fieldset disabled>
<div class="field">
<label class="label">Name</label>
<div class="control">
<input class="input" type="text" placeholder="Text input" value="{{ .Name }}">
</div>
</fieldset>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Description</label>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<textarea class="textarea" name="description" placeholder="Description">{{ .Description }}</textarea>
</div>
</div>
</div>
</div>
<div>
<p>Full price: {{ .FullPrice }}</p>
</div>
<div class="field is-grouped">
<p class="control">
<button class="button is-primary is-link" hx-include="[name='description']">
Submit
</button>
</p>
<p class="control">
<button class="button is-light" hx-get="/courses/{{ .ID }}/short">
Cancel
</button>
</p>
</div>
</form>
{{ end }}

View File

@ -1,86 +1,185 @@
{{define "courses"}} {{define "courses"}}
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> {{ template "html_head" . }}
<title>Courses</title>
<style> <body>
body { {{ template "header" . }}
font-family: Arial, sans-serif;
margin: 0; <nav class="level">
padding: 0; <div class="level-item has-text-centered">
} <div>
header { <p class="heading">Courses</p>
background-color: #333; <p class="title">10k</p>
color: white; </div>
padding: 10px; </div>
} <div class="level-item has-text-centered">
header h1 { <div>
margin: 0; <p class="heading">Clients</p>
} <p class="title">1m</p>
nav ul { </div>
list-style-type: none; </div>
margin: 0; <div class="level-item has-text-centered">
padding: 0; <div>
} <p class="heading">Categories</p>
nav li { <p class="title">1,024</p>
display: inline; </div>
margin-right: 10px; </div>
} <div class="level-item has-text-centered">
nav a { <div>
color: white; <p class="heading">Likes</p>
text-decoration: none; <p class="title">Over 9m</p>
} </div>
h1, h2, h3 { </div>
margin-top: 0; </nav>
}
p { <div class="section">
margin-bottom: 10px; <div class="container block">
} <nav class="breadcrumb" aria-label="breadcrumbs" itemprop="breadcrumb" itemscope itemtype="https://schema.org/BreadcrumbList">
.course-plate {
background-color: #f2f2f2; <ul>
border: 1px solid #ddd; <li>
border-radius: 5px; {{ if .LearningTypeName }}
padding: 10px; <a href="/courses" itemprop="url">
margin-bottom: 10px; <span itemprop="title">
text-align: center; Курсы
} </span>
.course-plate a { </a>
color: #333; {{ else }}
text-decoration: none; <a>
} <span itemprop="title" itemprop="url">
.course-plate a:hover { Курсы
text-decoration: underline; </span>
} </a>
</style> {{ end }}
</head> </li>
<body>
<header> {{ if .LearningTypeName }}
<h1>My Product</h1> <li>
<nav> {{ if .CourseThematicName }}
<ul> <a href="/courses/{{.ActiveLearningType}}" itemprop="url">
<li><a href="/">Main page</a></li> <span itemprop="title">
<li><a href="/about">About us</a></li> {{ .LearningTypeName }}
<li><a href="/help">Help</a></li> </span>
</ul> </a>
</nav> {{ else }}
</header> <a>
<h1>Courses</h1> <span itemprop="title" itemprop="url">
{{range $category, $courses := .Courses}} {{ .LearningTypeName }}
<h2>{{$category}}</h2> </span>
<p>{{$category.Description}}</p> </a>
{{range $course := $courses}} {{ end }}
<div class="course-plate"> </li>
<h3><a href="/courses/{{$course.ID}}">{{$course.Name}}</a></h3> {{ end }}
<p>{{$course.Description}}</p>
<p>Full price: {{$course.FullPrice}}</p> {{ if .CourseThematicName }}
<p>Discount: {{$course.Discount}}</p> <li>
<p>Thematic: {{$course.Thematic}}</p> <a>
<p>Learning type: {{$course.LearningType}}</p> <span itemprop="title" itemprop="url">
<p>Duration: {{$course.Duration}}</p> {{ .CourseThematicName }}
<p>Starts at: {{$course.StartsAt}}</p> </span>
</a>
</li>
{{ end }}
</ul>
</nav>
<form id="filter-form" class="columns">
<div class="select">
<select id="learning-type-filter" name="learning_type">
<option value="">Все направления</option>
{{ range $t := .AvailableLearningTypes }}
<option value="{{$t.ID}}" {{ if eq $t.ID $.ActiveLearningType }}selected{{ end }}>{{ $t.Name }}</option>
{{ end }}
</select>
</div>
{{ if .LearningTypeName }}
<div class="select">
<select id="course-thematic-filter" name="course_thematic">
<option value="">Все темы</option>
{{ range $t := .AvailableCourseThematics }}
<option value="{{$t.ID}}" {{ if eq $t.ID $.ActiveCourseThematic }}selected{{ end }}>{{ $t.Name }}</option>
{{ end }}
</select>
</div>
{{ end }}
<button id="go-to-filter" class="button">Перейти</button>
</form>
</div>
<div id="category-course-list" class="container">
{{ range $category := .Categories }}
<div class="box">
<div class="title is-3">
<a href="/courses/{{ $category.ID }}">
{{ $category.Name }}
</a>
</div> </div>
{{end}}
{{end}} <div class="subtitle is-6">
</body> Some description about the learning category {{ $category.Description }}
</html> </div>
{{ range $subcategory := $category.Subcategories }}
<div class="box">
<div class="title is-4">
<a href="/courses/{{ $category.ID }}/{{ $subcategory.ID }}">
{{ $subcategory.Name }}
</a>
</div>
<div class="subtitle is-6">Some description about course thematics {{ $subcategory.Description }}</div>
<div class="columns is-multiline">
{{ range $course := $subcategory.Courses }}
{{ template "course_info" $course }}
{{ end }}
</div>
</div>
{{ end }}
</div>
{{ end }}
</div>
<div id="course-info"></div>
</div>
</div>
<nav class="pagination is-centered" role="navigation" aria-label="pagination">
<a class="pagination-previous">Previous</a>
<a class="pagination-next" href="/courses/?next={{ .NextPageToken }}&per_page=50">Next page</a>
</nav>
{{ template "footer" . }}
<script>
const formFilterOnSubmit = event => {
event.preventDefault();
const lt = document.getElementById('learning-type-filter')
const ct = document.getElementById('course-thematic-filter');
let out = '/courses';
if (lt != null && lt.value != '') {
out += '/' + lt.value;
}
if (ct != null && ct.value != '') {
out += '/' + ct.value;
}
document.location.assign(out);
return false;
};
document.addEventListener('DOMContentLoaded', () => {
const ff = document.getElementById('filter-form');
ff.addEventListener('submit', formFilterOnSubmit);
})
</script>
</body>
</html>
{{end}} {{end}}

View File

@ -11,6 +11,7 @@ import (
"git.loyso.art/frx/kurious/internal/kurious/app" "git.loyso.art/frx/kurious/internal/kurious/app"
"git.loyso.art/frx/kurious/internal/kurious/app/command" "git.loyso.art/frx/kurious/internal/kurious/app/command"
"git.loyso.art/frx/kurious/internal/kurious/app/query" "git.loyso.art/frx/kurious/internal/kurious/app/query"
"git.loyso.art/frx/kurious/internal/kurious/domain"
) )
type ApplicationConfig struct { type ApplicationConfig struct {
@ -25,7 +26,7 @@ type Application struct {
closers []io.Closer closers []io.Closer
} }
func NewApplication(ctx context.Context, cfg ApplicationConfig) (Application, error) { func NewApplication(ctx context.Context, cfg ApplicationConfig, mapper domain.CourseMapper) (Application, error) {
log := config.NewSLogger(cfg.LogConfig) log := config.NewSLogger(cfg.LogConfig)
ydbConnection, err := adapters.NewYDBConnection(ctx, cfg.YDB, log.With(slog.String("db", "ydb"))) ydbConnection, err := adapters.NewYDBConnection(ctx, cfg.YDB, log.With(slog.String("db", "ydb")))
if err != nil { if err != nil {
@ -36,13 +37,16 @@ func NewApplication(ctx context.Context, cfg ApplicationConfig) (Application, er
application := app.Application{ application := app.Application{
Commands: app.Commands{ Commands: app.Commands{
InsertCourses: command.NewCreateCoursesHandler(courseadapter, log), InsertCourses: command.NewCreateCoursesHandler(courseadapter, log),
InsertCourse: command.NewCreateCourseHandler(courseadapter, log), InsertCourse: command.NewCreateCourseHandler(courseadapter, log),
DeleteCourse: command.NewDeleteCourseHandler(courseadapter, log), DeleteCourse: command.NewDeleteCourseHandler(courseadapter, log),
UpdateCourseDescription: command.NewUpdateCourseDescriptionHandler(courseadapter, log),
}, },
Queries: app.Queries{ Queries: app.Queries{
GetCourse: query.NewGetCourseHandler(courseadapter, log), ListCourses: query.NewListCourseHandler(courseadapter, mapper, log),
ListCourses: query.NewListCourseHandler(courseadapter, log), ListLearningTypes: query.NewListLearningTypesHandler(courseadapter, mapper, log),
ListCourseThematics: query.NewListCourseThematicsHandler(courseadapter, mapper, log),
GetCourse: query.NewGetCourseHandler(courseadapter, mapper, log),
}, },
} }

View File

@ -1,11 +0,0 @@
package slices
// Map slice from one type to another one.
func Map[S any, E any](s []S, f func(S) E) []E {
out := make([]E, len(s))
for i := range s {
out[i] = f(s[i])
}
return out
}