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)
|
||||
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,
|
||||
|
||||
@ -2,4 +2,5 @@ package config
|
||||
|
||||
type HTTP struct {
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
|
||||
for res.NextResultSet(ctx) {
|
||||
for res.NextRow() {
|
||||
var cdb courseDB
|
||||
_ = res.ScanNamed(cdb.getNamedValues()...)
|
||||
courses = append(courses, mapCourseDB(cdb))
|
||||
if !res.NextResultSet(ctx) || !res.HasNextRow() {
|
||||
return errors.ErrNotFound
|
||||
}
|
||||
|
||||
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 {
|
||||
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) {
|
||||
|
||||
@ -83,7 +83,8 @@ type subcategoryInfo struct {
|
||||
}
|
||||
|
||||
type listCoursesTemplateParams struct {
|
||||
Categories []categoryInfo
|
||||
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"`
|
||||
|
||||
@ -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');
|
||||
|
||||
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"}}
|
||||
<!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>
|
||||
<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>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</body>
|
||||
</html>
|
||||
<html>
|
||||
{{ template "html_head" . }}
|
||||
<body>
|
||||
{{ template "header" . }}
|
||||
|
||||
<nav class="level">
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="heading">Courses</p>
|
||||
<p class="title">10k</p>
|
||||
</div>
|
||||
</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>
|
||||
{{end}}
|
||||
|
||||
@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user