add simple http
This commit is contained in:
1
go.mod
1
go.mod
@ -11,6 +11,7 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // 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
|
||||||
|
|||||||
2
go.sum
2
go.sum
@ -572,6 +572,8 @@ 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=
|
||||||
|
|||||||
5
internal/common/config/http.go
Normal file
5
internal/common/config/http.go
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
type HTTP struct {
|
||||||
|
ListenAddr string `json:"listen_addr"`
|
||||||
|
}
|
||||||
@ -5,6 +5,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"path"
|
"path"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.loyso.art/frx/kurious/internal/common/config"
|
"git.loyso.art/frx/kurious/internal/common/config"
|
||||||
@ -79,39 +81,90 @@ type ydbCourseRepository struct {
|
|||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ydbCourseRepository) List(ctx context.Context, params domain.ListCoursesParams) (courses []domain.Course, err error) {
|
func (r *ydbCourseRepository) List(
|
||||||
|
ctx context.Context,
|
||||||
|
params domain.ListCoursesParams,
|
||||||
|
) (result domain.ListCoursesResult, err error) {
|
||||||
const limit = 1000
|
const limit = 1000
|
||||||
const queryName = "list"
|
const queryName = "list"
|
||||||
const query = `
|
// const query = `
|
||||||
DECLARE $limit AS Int32;
|
// DECLARE $limit AS Int32;
|
||||||
DECLARE $id AS Text;
|
// DECLARE $id AS Text;
|
||||||
SELECT
|
// SELECT
|
||||||
id,
|
// id,
|
||||||
external_id,
|
// external_id,
|
||||||
source_type,
|
// source_type,
|
||||||
source_name,
|
// source_name,
|
||||||
course_thematic,
|
// course_thematic,
|
||||||
learning_type,
|
// learning_type,
|
||||||
organization_id,
|
// organization_id,
|
||||||
origin_link,
|
// origin_link,
|
||||||
image_link,
|
// image_link,
|
||||||
name,
|
// name,
|
||||||
description,
|
// description,
|
||||||
full_price,
|
// full_price,
|
||||||
discount,
|
// discount,
|
||||||
duration,
|
// duration,
|
||||||
starts_at,
|
// starts_at,
|
||||||
created_at,
|
// created_at,
|
||||||
updated_at,
|
// updated_at,
|
||||||
deleted_at
|
// deleted_at
|
||||||
FROM
|
// FROM
|
||||||
courses
|
// courses
|
||||||
WHERE
|
// WHERE
|
||||||
id > $id
|
// id > $id
|
||||||
ORDER BY id
|
// ORDER BY id
|
||||||
LIMIT $limit;`
|
// 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`
|
||||||
|
|
||||||
courses = make([]domain.Course, 0, 4_000)
|
if params.Limit == 0 {
|
||||||
|
params.Limit = limit
|
||||||
|
}
|
||||||
|
|
||||||
|
qtParams := queryTemplateParams{
|
||||||
|
Fields: fields,
|
||||||
|
Table: "courses",
|
||||||
|
Suffix: "ORDER BY id\nLIMIT $limit",
|
||||||
|
Declares: []queryTemplateDeclaration{{
|
||||||
|
Name: "limit",
|
||||||
|
Type: "Int32",
|
||||||
|
}, {
|
||||||
|
Name: "id",
|
||||||
|
Type: "Text",
|
||||||
|
}},
|
||||||
|
Conditions: []string{
|
||||||
|
"id > $id",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
options := make([]table.ParameterOption, 0, 4)
|
||||||
|
appendParams := func(name string, value string) {
|
||||||
|
if value == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ydbvalue := types.TextValue(value)
|
||||||
|
d := queryTemplateDeclaration{
|
||||||
|
Name: name,
|
||||||
|
Type: ydbvalue.Type().String(),
|
||||||
|
}
|
||||||
|
qtParams.Declares = append(qtParams.Declares, d)
|
||||||
|
qtParams.Conditions = append(qtParams.Conditions, d.Name+"="+d.Arg())
|
||||||
|
options = append(options, table.ValueParam(d.Arg(), ydbvalue))
|
||||||
|
}
|
||||||
|
appendParams("course_thematic", params.CourseThematic)
|
||||||
|
appendParams("learning_type", params.LearningType)
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
err = template.Must(template.New("").Parse(queryTemplateSelect)).Execute(&sb, qtParams)
|
||||||
|
if err != nil {
|
||||||
|
return result, fmt.Errorf("executing template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := sb.String()
|
||||||
|
|
||||||
|
courses := make([]domain.Course, 0, 1_000)
|
||||||
readTx := table.TxControl(
|
readTx := table.TxControl(
|
||||||
table.BeginTx(
|
table.BeginTx(
|
||||||
table.WithOnlineReadOnly(),
|
table.WithOnlineReadOnly(),
|
||||||
@ -508,3 +561,27 @@ func mapSlice[T, U any](in []T, f func(T) U) []U {
|
|||||||
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type queryTemplateDeclaration struct {
|
||||||
|
Name string
|
||||||
|
Type string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d queryTemplateDeclaration) Arg() string {
|
||||||
|
return "$" + d.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
type queryTemplateParams struct {
|
||||||
|
Declares []queryTemplateDeclaration
|
||||||
|
Fields string
|
||||||
|
Table string
|
||||||
|
Conditions []string
|
||||||
|
Suffix string
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryTemplateSelect = `
|
||||||
|
{{ range .Declares }}DECLARE ${{.Name}} AS {{.Type}}\n{{end}}
|
||||||
|
SELECT {{.Fields}}
|
||||||
|
FROM {{.Table}}
|
||||||
|
WHERE {{ range .Conditions }}{{.}}\n{{end}}
|
||||||
|
{{.Suffix}}`
|
||||||
|
|||||||
@ -10,9 +10,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ListCourse struct {
|
type ListCourse struct {
|
||||||
|
CourseThematic string
|
||||||
|
LearningType string
|
||||||
CategoryID string
|
CategoryID string
|
||||||
OrganizationID string
|
OrganizationID string
|
||||||
Keyword string
|
Keyword string
|
||||||
|
|
||||||
|
Limit int
|
||||||
|
Offset int
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListCourseHandler decorator.QueryHandler[ListCourse, []domain.Course]
|
type ListCourseHandler decorator.QueryHandler[ListCourse, []domain.Course]
|
||||||
@ -33,9 +38,11 @@ func NewListCourseHandler(
|
|||||||
|
|
||||||
func (h listCourseHandler) Handle(ctx context.Context, query ListCourse) ([]domain.Course, error) {
|
func (h listCourseHandler) Handle(ctx context.Context, query ListCourse) ([]domain.Course, error) {
|
||||||
courses, err := h.repo.List(ctx, domain.ListCoursesParams{
|
courses, err := h.repo.List(ctx, domain.ListCoursesParams{
|
||||||
CategoryID: query.CategoryID,
|
CourseThematic: query.CourseThematic,
|
||||||
|
LearningType: query.LearningType,
|
||||||
OrganizationID: query.OrganizationID,
|
OrganizationID: query.OrganizationID,
|
||||||
Keyword: query.Keyword,
|
Limit: query.Limit,
|
||||||
|
Offset: query.Offset,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("listing courses: %w", err)
|
return nil, fmt.Errorf("listing courses: %w", err)
|
||||||
|
|||||||
@ -8,9 +8,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ListCoursesParams struct {
|
type ListCoursesParams struct {
|
||||||
|
LearningType string
|
||||||
|
CourseThematic string
|
||||||
OrganizationID string
|
OrganizationID string
|
||||||
CategoryID string
|
|
||||||
Keyword string
|
NextPageToken string
|
||||||
|
Limit int
|
||||||
|
Offset int
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateCourseParams struct {
|
type CreateCourseParams struct {
|
||||||
@ -31,10 +35,15 @@ type CreateCourseParams struct {
|
|||||||
StartsAt time.Time
|
StartsAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ListCoursesResult struct {
|
||||||
|
Courses []Course
|
||||||
|
NextPageToken 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) ([]Course, error)
|
List(ctx context.Context, params ListCoursesParams) (ListCoursesResult, 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)
|
||||||
|
|||||||
118
internal/kurious/ports/http/course.go
Normal file
118
internal/kurious/ports/http/course.go
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.loyso.art/frx/kurious/internal/common/errors"
|
||||||
|
"git.loyso.art/frx/kurious/internal/common/xslice"
|
||||||
|
"git.loyso.art/frx/kurious/internal/kurious/app"
|
||||||
|
"git.loyso.art/frx/kurious/internal/kurious/app/query"
|
||||||
|
"git.loyso.art/frx/kurious/internal/kurious/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type courseServer struct {
|
||||||
|
app app.Application
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
type pagination struct {
|
||||||
|
page 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) {
|
||||||
|
query := r.URL.Query()
|
||||||
|
|
||||||
|
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") {
|
||||||
|
out.perPage, err = strconv.Atoi(query.Get("per_page"))
|
||||||
|
if err != nil {
|
||||||
|
return out, errors.NewValidationError("per_page", "bad per_page value")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.perPage = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseListCoursesParams(r *http.Request) (out listCoursesParams, err error) {
|
||||||
|
out.pagination, err = parsePaginationFromQuery(r)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
query := r.URL.Query()
|
||||||
|
out.learningType = query.Get("category")
|
||||||
|
out.courseThematic = query.Get("type")
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type listCoursesParams struct {
|
||||||
|
pagination
|
||||||
|
|
||||||
|
courseThematic string
|
||||||
|
learningType string
|
||||||
|
}
|
||||||
|
|
||||||
|
type templateCourse domain.Course
|
||||||
|
|
||||||
|
func mapDomainCourseToTemplate(in ...domain.Course) []templateCourse {
|
||||||
|
return xslice.Map(in, func(v domain.Course) templateCourse {
|
||||||
|
return templateCourse(v)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c courseServer) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
params, err := parseListCoursesParams(r)
|
||||||
|
if err != nil {
|
||||||
|
handleError(ctx, err, w, c.log, "unable to parse list courses params")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, offset := params.LimitOffset()
|
||||||
|
|
||||||
|
courses, err := c.app.Queries.ListCourses.Handle(ctx, query.ListCourse{
|
||||||
|
CourseThematic: params.courseThematic,
|
||||||
|
LearningType: params.learningType,
|
||||||
|
Limit: limit,
|
||||||
|
Offset: offset,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
handleError(ctx, err, w, c.log, "unable to list courses")
|
||||||
|
}
|
||||||
|
|
||||||
|
templateCourses := mapDomainCourseToTemplate(courses...)
|
||||||
|
|
||||||
|
err = template.Must(template.ParseFiles("templates/list.tmpl")).Execute(w, templateCourses)
|
||||||
|
if err != nil {
|
||||||
|
handleError(ctx, err, w, c.log, "unable to execute template")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
@ -1,3 +1,55 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
type Server struct{}
|
import (
|
||||||
|
"context"
|
||||||
|
stderrors "errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.loyso.art/frx/kurious/internal/common/errors"
|
||||||
|
"git.loyso.art/frx/kurious/internal/common/xcontext"
|
||||||
|
"git.loyso.art/frx/kurious/internal/kurious/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultPerPage = 50
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
app app.Application
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer(app app.Application) Server {
|
||||||
|
return Server{}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var errorString string
|
||||||
|
var code int
|
||||||
|
valErr := new(errors.ValidationError)
|
||||||
|
switch {
|
||||||
|
case stderrors.As(err, &valErr):
|
||||||
|
errorString = valErr.Error()
|
||||||
|
code = http.StatusBadRequest
|
||||||
|
case stderrors.Is(err, errors.ErrNotFound):
|
||||||
|
errorString = err.Error()
|
||||||
|
code = http.StatusNotFound
|
||||||
|
default:
|
||||||
|
errorString = "internal server error"
|
||||||
|
code = http.StatusInternalServerError
|
||||||
|
}
|
||||||
|
|
||||||
|
xcontext.LogWithWarnError(ctx, log, err, msg, slog.Int("status_code", code), slog.String("response", errorString))
|
||||||
|
|
||||||
|
http.Error(w, errorString, code)
|
||||||
|
}
|
||||||
|
|||||||
86
internal/kurious/ports/http/templates/list.tmpl
Normal file
86
internal/kurious/ports/http/templates/list.tmpl
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
{{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>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user