add more style
This commit is contained in:
6
assets/kurious/static/style.css
Normal file
6
assets/kurious/static/style.css
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
.btn.btn-primary {
|
||||||
|
color: white;
|
||||||
|
background-color: black;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
@ -100,7 +100,18 @@ func setupHTTP(cfg config.HTTP, srv xhttp.Server, log *slog.Logger) *http.Server
|
|||||||
router.HandleFunc("/updatedesc", coursesAPI.UdpateDescription).Methods(http.MethodPost)
|
router.HandleFunc("/updatedesc", coursesAPI.UdpateDescription).Methods(http.MethodPost)
|
||||||
coursesRouter := router.PathPrefix("/courses").Subrouter()
|
coursesRouter := router.PathPrefix("/courses").Subrouter()
|
||||||
coursesRouter.HandleFunc("/", coursesAPI.List).Methods(http.MethodGet)
|
coursesRouter.HandleFunc("/", coursesAPI.List).Methods(http.MethodGet)
|
||||||
coursesRouter.HandleFunc("/{course_id}", coursesAPI.Get).Methods(http.MethodGet)
|
|
||||||
|
courseRouter := coursesRouter.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)
|
||||||
|
|
||||||
|
if cfg.MountLive {
|
||||||
|
fs := http.FileServer(http.Dir("./assets/kurious/static/"))
|
||||||
|
router.PathPrefix("/static/").Handler(http.StripPrefix("/static/", fs))
|
||||||
|
}
|
||||||
|
|
||||||
return &http.Server{
|
return &http.Server{
|
||||||
Addr: cfg.ListenAddr,
|
Addr: cfg.ListenAddr,
|
||||||
|
|||||||
@ -2,4 +2,5 @@ package config
|
|||||||
|
|
||||||
type HTTP struct {
|
type HTTP struct {
|
||||||
ListenAddr string `json:"listen_addr"`
|
ListenAddr string `json:"listen_addr"`
|
||||||
|
MountLive bool `json:"mount_live"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -59,7 +59,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))
|
||||||
@ -69,6 +69,10 @@ func NewYDBConnection(ctx context.Context, cfg config.YDB, log *slog.Logger) (*Y
|
|||||||
yc.WithServiceAccountKeyFileCredentials(auth.Path),
|
yc.WithServiceAccountKeyFileCredentials(auth.Path),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
opts = append(opts,
|
||||||
|
ydb.WithDialTimeout(time.Second*3),
|
||||||
|
)
|
||||||
|
|
||||||
db, err := ydb.Open(
|
db, err := ydb.Open(
|
||||||
ctx,
|
ctx,
|
||||||
cfg.DSN,
|
cfg.DSN,
|
||||||
@ -117,7 +121,7 @@ func (r *ydbCourseRepository) List(
|
|||||||
qtParams := queryTemplateParams{
|
qtParams := queryTemplateParams{
|
||||||
Fields: coursesFieldsStr,
|
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",
|
Name: "limit",
|
||||||
@ -235,10 +239,36 @@ func (r *ydbCourseRepository) List(
|
|||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ydbCourseRepository) Get(ctx context.Context, id string) (course domain.Course, err error) {
|
func (r *ydbCourseRepository) Get(
|
||||||
|
ctx context.Context,
|
||||||
|
id string,
|
||||||
|
) (course domain.Course, err error) {
|
||||||
const queryName = "get"
|
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(),
|
||||||
@ -262,63 +292,48 @@ 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) {
|
||||||
|
|||||||
@ -83,7 +83,8 @@ type subcategoryInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type listCoursesTemplateParams struct {
|
type listCoursesTemplateParams struct {
|
||||||
Categories []categoryInfo
|
Categories []categoryInfo
|
||||||
|
NextPageToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
func mapDomainCourseToTemplate(in ...domain.Course) listCoursesTemplateParams {
|
func mapDomainCourseToTemplate(in ...domain.Course) listCoursesTemplateParams {
|
||||||
@ -146,8 +147,9 @@ func (c courseServer) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
courses := result.Courses
|
courses := result.Courses
|
||||||
templateCourses := mapDomainCourseToTemplate(courses...)
|
templateCourses := mapDomainCourseToTemplate(courses...)
|
||||||
|
templateCourses.NextPageToken = result.NextPageToken
|
||||||
|
|
||||||
err = listTemplateParsed.ExecuteTemplate(w, "courses", templateCourses)
|
err = getCoreTemplate(ctx, c.log).ExecuteTemplate(w, "courses", templateCourses)
|
||||||
if handleError(ctx, err, w, c.log, "unable to execute template") {
|
if handleError(ctx, err, w, c.log, "unable to execute template") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -177,6 +179,76 @@ func (c courseServer) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
func (c courseServer) UdpateDescription(w http.ResponseWriter, r *http.Request) {
|
||||||
type requestModel struct {
|
type requestModel struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
|||||||
@ -1,6 +1,48 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import "html/template"
|
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/xslice"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 := xslice.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(
|
var listTemplateParsed = template.Must(
|
||||||
template.New("courses").
|
template.New("courses").
|
||||||
@ -111,6 +153,7 @@ const listTemplate = `{{define "courses"}}
|
|||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
<button onclick="window.location.href='/courses/?next={{.NextPageToken}}'">Next Page</button>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const editableTexts = document.querySelectorAll('.editable-text');
|
const editableTexts = document.querySelectorAll('.editable-text');
|
||||||
|
|||||||
68
internal/kurious/ports/http/templates/common.tmpl
Normal file
68
internal/kurious/ports/http/templates/common.tmpl
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
{{ 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">
|
||||||
|
|
||||||
|
</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 }}
|
||||||
|
|
||||||
76
internal/kurious/ports/http/templates/get.tmpl
Normal file
76
internal/kurious/ports/http/templates/get.tmpl
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
{{ 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">{{ .Name }}</p>
|
||||||
|
<p class="subtitle is-8">{{ .Description }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>{{ .FullPrice }} rub.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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" 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="closest .control">
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
<p class="control">
|
||||||
|
<button class="button is-light" hx-get="/courses/{{ .ID }}/short">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- <button class="btn">Submit</button>
|
||||||
|
<button class="btn" hx-get="/courses/{{ .ID }}/short">Cancel</button> -->
|
||||||
|
</form>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
@ -1,86 +1,69 @@
|
|||||||
{{define "courses"}}
|
{{define "courses"}}
|
||||||
<!DOCTYPE html>
|
<html>
|
||||||
<html>
|
{{ template "html_head" . }}
|
||||||
<head>
|
<body>
|
||||||
<title>Courses</title>
|
{{ template "header" . }}
|
||||||
<style>
|
|
||||||
body {
|
<nav class="level">
|
||||||
font-family: Arial, sans-serif;
|
<div class="level-item has-text-centered">
|
||||||
margin: 0;
|
<div>
|
||||||
padding: 0;
|
<p class="heading">Courses</p>
|
||||||
}
|
<p class="title">10k</p>
|
||||||
header {
|
</div>
|
||||||
background-color: #333;
|
</div>
|
||||||
color: white;
|
<div class="level-item has-text-centered">
|
||||||
padding: 10px;
|
<div>
|
||||||
}
|
<p class="heading">Clients</p>
|
||||||
header h1 {
|
<p class="title">1m</p>
|
||||||
margin: 0;
|
</div>
|
||||||
}
|
</div>
|
||||||
nav ul {
|
<div class="level-item has-text-centered">
|
||||||
list-style-type: none;
|
<div>
|
||||||
margin: 0;
|
<p class="heading">Categories</p>
|
||||||
padding: 0;
|
<p class="title">1,024</p>
|
||||||
}
|
</div>
|
||||||
nav li {
|
</div>
|
||||||
display: inline;
|
<div class="level-item has-text-centered">
|
||||||
margin-right: 10px;
|
<div>
|
||||||
}
|
<p class="heading">Likes</p>
|
||||||
nav a {
|
<p class="title">Over 9m</p>
|
||||||
color: white;
|
</div>
|
||||||
text-decoration: none;
|
</div>
|
||||||
}
|
</nav>
|
||||||
h1, h2, h3 {
|
|
||||||
margin-top: 0;
|
<div class="section">
|
||||||
}
|
<div class="container">
|
||||||
p {
|
<h1>Welcome to the Course Aggregator</h1>
|
||||||
margin-bottom: 10px;
|
<div id="category-course-list">
|
||||||
}
|
{{ range $category := .Categories }}
|
||||||
.course-plate {
|
<div class="title">{{ $category.Name }}</div>
|
||||||
background-color: #f2f2f2;
|
<p>{{ $category.Description }}</p>
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 5px;
|
{{ range $subcategory := $category.Subcategories }}
|
||||||
padding: 10px;
|
<div class="subtitle">{{ $subcategory.Name }}</div>
|
||||||
margin-bottom: 10px;
|
<p>{{ $subcategory.Description }}</p>
|
||||||
text-align: center;
|
|
||||||
}
|
<div class="columns is-multiline">
|
||||||
.course-plate a {
|
{{ range $course := $subcategory.Courses }}
|
||||||
color: #333;
|
{{ template "course_info" $course }}
|
||||||
text-decoration: none;
|
{{ end }}
|
||||||
}
|
</div>
|
||||||
.course-plate a:hover {
|
{{ end }}
|
||||||
text-decoration: underline;
|
{{ end }}
|
||||||
}
|
</div>
|
||||||
</style>
|
<div id="course-info"></div>
|
||||||
</head>
|
</div>
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<h1>My Product</h1>
|
</div>
|
||||||
<nav>
|
|
||||||
<ul>
|
<nav class="pagination is-centered" role="navigation" aria-label="pagination">
|
||||||
<li><a href="/">Main page</a></li>
|
<a class="pagination-previous">Previous</a>
|
||||||
<li><a href="/about">About us</a></li>
|
<a class="pagination-next" href="/courses/?next={{ .NextPageToken }}&per_page=50">Next page</a>
|
||||||
<li><a href="/help">Help</a></li>
|
</nav>
|
||||||
</ul>
|
|
||||||
</nav>
|
{{ template "footer" . }}
|
||||||
</header>
|
|
||||||
<h1>Courses</h1>
|
</body>
|
||||||
{{range $category, $courses := .Courses}}
|
</html>
|
||||||
<h2>{{$category}}</h2>
|
|
||||||
<p>{{$category.Description}}</p>
|
|
||||||
{{range $course := $courses}}
|
|
||||||
<div class="course-plate">
|
|
||||||
<h3><a href="/courses/{{$course.ID}}">{{$course.Name}}</a></h3>
|
|
||||||
<p>{{$course.Description}}</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>
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@ -9,3 +9,9 @@ func Map[S any, E any](s []S, f func(S) E) []E {
|
|||||||
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ForEach[S any](s []S, f func(S)) {
|
||||||
|
for i := range s {
|
||||||
|
f(s[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user