add more style

This commit is contained in:
Aleksandr Trushkin
2024-01-05 23:03:15 +03:00
parent fbe9927ac3
commit 48f5d80f7a
10 changed files with 415 additions and 134 deletions

View File

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

View File

@ -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)
coursesRouter := router.PathPrefix("/courses").Subrouter()
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{
Addr: cfg.ListenAddr,

View File

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

View File

@ -59,7 +59,7 @@ type YDBConnection struct {
}
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) {
case config.YCAuthIAMToken:
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),
)
}
opts = append(opts,
ydb.WithDialTimeout(time.Second*3),
)
db, err := ydb.Open(
ctx,
cfg.DSN,
@ -117,7 +121,7 @@ func (r *ydbCourseRepository) List(
qtParams := queryTemplateParams{
Fields: coursesFieldsStr,
Table: "courses",
Suffix: "ORDER BY id\nLIMIT $limit",
Suffix: "ORDER BY learning_type,course_thematic,id\nLIMIT $limit",
Declares: []queryTemplateDeclaration{
{
Name: "limit",
@ -235,10 +239,36 @@ func (r *ydbCourseRepository) List(
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 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(
table.BeginTx(
table.WithOnlineReadOnly(),
@ -262,63 +292,48 @@ func (r *ydbCourseRepository) Get(ctx context.Context, id string) (course domain
_, res, err := s.Execute(
ctx,
readTx,
`
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;
`,
querySelect,
table.NewQueryParameters(
table.ValueParam("$id", types.TextValue(id)),
),
options.WithCollectStatsModeBasic(),
)
if err != nil {
return fmt.Errorf("executing: %w", err)
return fmt.Errorf("executing query: %w", err)
}
if !res.NextResultSet(ctx) || !res.HasNextRow() {
return errors.ErrNotFound
}
for res.NextResultSet(ctx) {
for res.NextRow() {
var cdb courseDB
_ = res.ScanNamed(cdb.getNamedValues()...)
courses = append(courses, mapCourseDB(cdb))
err = res.ScanNamed(cdb.getNamedValues()...)
if err != nil {
return fmt.Errorf("scanning row: %w", err)
}
course = mapCourseDB(cdb)
}
if err = res.Err(); err != nil {
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
},
table.WithIdempotent())
if err != nil {
return domain.Course{}, err
}
if len(courses) == 0 {
return course, errors.ErrNotFound
}
return courses[0], err
table.WithIdempotent(),
)
return course, err
}
func (r *ydbCourseRepository) GetByExternalID(ctx context.Context, id string) (domain.Course, error) {

View File

@ -84,6 +84,7 @@ type subcategoryInfo struct {
type listCoursesTemplateParams struct {
Categories []categoryInfo
NextPageToken string
}
func mapDomainCourseToTemplate(in ...domain.Course) listCoursesTemplateParams {
@ -146,8 +147,9 @@ func (c courseServer) List(w http.ResponseWriter, r *http.Request) {
courses := result.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") {
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) {
type requestModel struct {
ID string `json:"id"`

View File

@ -1,6 +1,48 @@
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(
template.New("courses").
@ -111,6 +153,7 @@ const listTemplate = `{{define "courses"}}
{{end}}
{{end}}
{{end}}
<button onclick="window.location.href='/courses/?next={{.NextPageToken}}'">Next Page</button>
<script>
const editableTexts = document.querySelectorAll('.editable-text');

View 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 }}

View 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 }}

View File

@ -1,86 +1,69 @@
{{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;
}
.course-plate {
background-color: #f2f2f2;
border: 1px solid #ddd;
border-radius: 5px;
padding: 10px;
margin-bottom: 10px;
text-align: center;
}
.course-plate a {
color: #333;
text-decoration: none;
}
.course-plate a:hover {
text-decoration: underline;
}
</style>
</head>
<html>
{{ template "html_head" . }}
<body>
<header>
<h1>My Product</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>
<h1>Courses</h1>
{{range $category, $courses := .Courses}}
<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>
{{ template "header" . }}
<nav class="level">
<div class="level-item has-text-centered">
<div>
<p class="heading">Courses</p>
<p class="title">10k</p>
</div>
{{end}}
{{end}}
</div>
<div class="level-item has-text-centered">
<div>
<p class="heading">Clients</p>
<p class="title">1m</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="heading">Categories</p>
<p class="title">1,024</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="heading">Likes</p>
<p class="title">Over 9m</p>
</div>
</div>
</nav>
<div class="section">
<div class="container">
<h1>Welcome to the Course Aggregator</h1>
<div id="category-course-list">
{{ range $category := .Categories }}
<div class="title">{{ $category.Name }}</div>
<p>{{ $category.Description }}</p>
{{ range $subcategory := $category.Subcategories }}
<div class="subtitle">{{ $subcategory.Name }}</div>
<p>{{ $subcategory.Description }}</p>
<div class="columns is-multiline">
{{ range $course := $subcategory.Courses }}
{{ template "course_info" $course }}
{{ end }}
</div>
{{ end }}
{{ 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" . }}
</body>
</html>
</html>
{{end}}

View File

@ -9,3 +9,9 @@ func Map[S any, E any](s []S, f func(S) E) []E {
return out
}
func ForEach[S any](s []S, f func(S)) {
for i := range s {
f(s[i])
}
}