Initial commit

This commit is contained in:
Ersteller 1970-01-01 00:00:00 +00:00
commit 052f341ebf
72 changed files with 7517 additions and 0 deletions

40
Dockerfile Normal file
View File

@ -0,0 +1,40 @@
FROM golang:1.23 AS build
WORKDIR /app
COPY . .
ADD https://packaged-cli.prisma.sh/prisma-cli-5.22.0-linux-x64.gz /prisma-cli-linux-x64.gz
ENV PRISMA_CLI_DIR=/root/.cache/prisma/binaries/cli/5.22.0
RUN go mod download
RUN go get github.com/steebchen/prisma-client-go
RUN mkdir -p $PRISMA_CLI_DIR
RUN mv /prisma-cli-linux-x64.gz $PRISMA_CLI_DIR/prisma-cli-linux-x64.gz
RUN gunzip $PRISMA_CLI_DIR/prisma-cli-linux-x64.gz
RUN chmod +x $PRISMA_CLI_DIR/prisma-cli-linux-x64
RUN bash scripts/db-push.sh
RUN go build -o /main main.go
RUN go build -o /create-user cli/create-user_gen.go
FROM alpine
ARG USER=default
ENV HOME /home/$USER
RUN apk add --update libc6-compat
# add new user
RUN adduser -D $USER
RUN mkdir /home/$USER/files
RUN chown -R $USER:$USER /home/$USER/files
USER $USER
WORKDIR $HOME
COPY --from=build --chown=default:default /main ./main
COPY --from=build --chown=default:default /create-user ./create-user
COPY --from=build --chown=default:default /app/.env .env
COPY --chown=default:default views/ ./views
COPY --chown=default:default static/ ./static/
EXPOSE 8089
CMD ./main

42
cli/create-user_gen.go Normal file
View File

@ -0,0 +1,42 @@
package main
// GENERATED FILE
// DO NOT EDIT
import (
"github.com/joho/godotenv"
"os"
"test/crud"
"test/user"
)
func main() {
err := godotenv.Load()
if err != nil {
crud.LogError("Error loading .env file: %v", err)
panic(err)
}
dbUrl := os.Getenv("DATABASE_URL")
argsWithoutProg := os.Args[1:]
email := argsWithoutProg[0]
password := argsWithoutProg[1]
connpool, err := crud.CreatePostgresConnpool(dbUrl)
if err != nil {
crud.LogError("Failed to create connection pool: %v", err)
panic(err)
}
hashedPassword, err := crud.HashPassword(password)
if err != nil {
panic(err)
}
userRepo := user.NewUserRepository(connpool)
_, err = userRepo.Create(user.User{
Email: email,
Password: hashedPassword,
})
if err != nil {
crud.LogError("Failed to create user: %v", err)
panic(err)
}
println("Created user")
}

16
crud/hash.go Normal file
View File

@ -0,0 +1,16 @@
package crud
// https://medium.com/@rnp0728/secure-password-hashing-in-go-a-comprehensive-guide-5500e19e7c1f
import "golang.org/x/crypto/bcrypt"
// HashPassword generates a bcrypt hash for the given password.
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
return string(bytes), err
}
// VerifyPassword verifies if the given password matches the stored hash.
func VerifyPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}

32
crud/logger.go Normal file
View File

@ -0,0 +1,32 @@
package crud
import (
"fmt"
"strings"
)
func LogDebug(message string, a ...any) {
println(fmt.Sprintf(message, a...))
}
func Debug(a ...any) {
stringValue := joinStrings(a)
println(stringValue)
}
func joinStrings(a []any) string {
elementsToLog := []string{}
for i := 0; i < len(a); i++ {
elementsToLog = append(elementsToLog, strings.TrimSpace(fmt.Sprint(a[i])))
}
return strings.Join(elementsToLog, " ")
}
func LogError(message string, a ...any) {
println(fmt.Sprintf("Error: %v", fmt.Sprintf(message, a...)))
}
func Error(a ...any) {
println(fmt.Sprint("Error: ", joinStrings(a)))
}

54
crud/postgres.go Normal file
View File

@ -0,0 +1,54 @@
package crud
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
"time"
)
func CreatePostgresConnpool(dbUrl string) (*pgxpool.Pool, error) {
connPool, err := pgxpool.NewWithConfig(context.Background(), config(dbUrl))
if err != nil {
Error("Error while creating connection to the database!!", err)
return nil, err
}
connection, err := connPool.Acquire(context.Background())
if err != nil {
LogError("Error while acquiring connection from the database pool!! %v", err)
return nil, err
}
defer connection.Release()
err = connection.Ping(context.Background())
if err != nil {
LogError("Could not ping database")
return nil, err
}
return connPool, nil
}
func config(dbUrl string) *pgxpool.Config {
const defaultMaxConns = int32(4)
const defaultMinConns = int32(0)
const defaultMaxConnLifetime = time.Hour
const defaultMaxConnIdleTime = time.Minute * 30
const defaultHealthCheckPeriod = time.Minute
const defaultConnectTimeout = time.Second * 5
dbConfig, err := pgxpool.ParseConfig(dbUrl)
if err != nil {
Error("Failed to create a config, error: ", err)
return nil
}
dbConfig.MaxConns = defaultMaxConns
dbConfig.MinConns = defaultMinConns
dbConfig.MaxConnLifetime = defaultMaxConnLifetime
dbConfig.MaxConnIdleTime = defaultMaxConnIdleTime
dbConfig.HealthCheckPeriod = defaultHealthCheckPeriod
dbConfig.ConnConfig.ConnectTimeout = defaultConnectTimeout
return dbConfig
}

40
go.mod Normal file
View File

@ -0,0 +1,40 @@
module test
go 1.23
toolchain go1.23.2
require (
github.com/doug-martin/goqu/v9 v9.19.0
github.com/google/uuid v1.6.0
github.com/gorilla/sessions v1.4.0
github.com/jackc/pgx/v5 v5.7.1
github.com/joho/godotenv v1.5.1
github.com/labstack/echo/v4 v4.12.0
github.com/pablor21/echo-etag/v4 v4.0.3
github.com/shopspring/decimal v1.4.0
github.com/steebchen/prisma-client-go v0.43.0
golang.org/x/crypto v0.27.0
golang.org/x/image v0.21.0
)
require (
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
golang.org/x/net v0.24.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.19.0 // indirect
golang.org/x/time v0.5.0 // indirect
)

85
go.sum Normal file
View File

@ -0,0 +1,85 @@
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denisenkom/go-mssqldb v0.10.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
github.com/doug-martin/goqu/v9 v9.19.0 h1:PD7t1X3tRcUiSdc5TEyOFKujZA5gs3VSA7wxSvBx7qo=
github.com/doug-martin/goqu/v9 v9.19.0/go.mod h1:nf0Wc2/hV3gYK9LiyqIrzBEVGlI8qW3GuDCEobC4wBQ=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0=
github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/lib/pq v1.10.1 h1:6VXZrLU0jHBYyAqrSPa+MgPfnSvTPuMgK+k0o5kVFWo=
github.com/lib/pq v1.10.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/pablor21/echo-etag/v4 v4.0.3 h1:o49j5NmxbqWIMfKHtzJan33PW12LQnORDFlM6qMaMqw=
github.com/pablor21/echo-etag/v4 v4.0.3/go.mod h1:cKXqBSw57xk+jT68+CR9HZTD4yWgbmGjvDdQcxRWdY0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s=
golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
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=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,7 @@
package html_components
type BooleanInput struct {
Name string
Label string
Value string
}

View File

@ -0,0 +1,8 @@
package html_components
type CropImage struct {
ImageUrl string
SaveUrl string
CancelUrl string
ImageId string
}

View File

@ -0,0 +1,7 @@
package html_components
type DateTimeInput struct {
Name string
Label string
Value string
}

View File

@ -0,0 +1,33 @@
package html_components
type InputType string
const (
InputTypeText InputType = "text"
InputTypeTextarea InputType = "textarea"
InputTypeBool InputType = "bool"
InputTypeInt InputType = "int"
InputTypeFloat InputType = "float"
InputTypeEnum InputType = "enum"
InputTypeDateTime InputType = "dateTime"
InputTypeImage InputType = "image"
)
type EditItemModalInputs struct {
Label string
Value string
Name string
Type InputType
Options []SelectInputOption
HideLabel bool
}
type EditItemModal struct {
Id string
Title string
Url string
IsCreate bool
Inputs []EditItemModalInputs
SubmitButtonLabel string
HaseFileUpload bool
}

View File

@ -0,0 +1,33 @@
package html_components
type EditItemInputType string
const (
EditItemInputTypeText InputType = "text"
EditItemInputTypeBool InputType = "bool"
EditItemInputTypeInt InputType = "int"
EditItemInputTypeEnum InputType = "enum"
EditItemInputTypeDateTime InputType = "dateTime"
EditItemInputTypeImage InputType = "image"
)
type EditItemInputs struct {
Label string
Value string
Name string
Type InputType
Options []SelectInputOption
HideLabel bool
}
type EditItem struct {
Id string
Title string
Url string
CancelUrl string
IsCreate bool
Inputs []EditItemInputs
SubmitButtonLabel string
HasFileUpload bool
FromTable bool
}

View File

@ -0,0 +1,6 @@
package html_components
type EmailLogin struct {
ShowError bool
Error string
}

19
html_components/files.go Normal file
View File

@ -0,0 +1,19 @@
package html_components
import (
"io"
"os"
)
func CreateFile(path string, src io.Reader) error {
dst, err := os.Create(path)
if err != nil {
return err
}
defer dst.Close()
if _, err = io.Copy(dst, src); err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,11 @@
package html_components
const ImageDisplayDefaultWidth = 500
type ImageDisplay struct {
Label string
Value string
CropUrl string
Width int
TimestampParam string
}

131
html_components/images.go Normal file
View File

@ -0,0 +1,131 @@
package html_components
import (
"bytes"
"test/crud"
"fmt"
. "github.com/labstack/echo/v4"
"golang.org/x/image/draw"
"image"
"image/jpeg"
"image/png"
"io"
"math"
"net/http"
"os"
)
const todotransPixel = "\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\x00\x00\x00\x21\xF9\x04\x01\x00\x00\x00\x00\x2C\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02\x44\x01\x00\x3B"
func ReturnEmptyPixel(c Context) error {
return c.Blob(http.StatusOK, "image/gif", []byte(todotransPixel))
}
func ReturnImage(c Context, path string) error {
height := ParseIntWithDefault(c.QueryParam("height"), -1)
width := ParseIntWithDefault(c.QueryParam("width"), -1)
tmpFilePath := getTmpFilePath(path, width, height)
// if tmp file path exists
if _, err := os.Stat(tmpFilePath); err == nil {
// read from the tmp file
return sendImage(c, tmpFilePath)
} else {
filePath := "files/" + path
if width > 0 || height > 0 {
data, err := os.ReadFile(filePath)
mimeType := http.DetectContentType(data)
if err != nil {
return ReturnEmptyPixel(c)
}
return getResized(c, data, mimeType, width, height, path)
}
crud.Debug("returning the full image: ", filePath)
return sendImage(c, filePath)
}
}
func sendImage(c Context, filePath string) error {
data, err := os.ReadFile(filePath)
if err != nil {
return ReturnEmptyPixel(c)
}
mimeType := http.DetectContentType(data)
return c.Blob(http.StatusOK, mimeType, data)
}
func getResized(c Context, data []byte, mimeType string, width int, height int, path string) error {
// create thumbnail and save to tmp
err := os.MkdirAll("tmp", os.ModePerm)
if err != nil {
crud.Error("Failed to create tmp directory: ", err)
return c.Blob(http.StatusOK, mimeType, data)
}
buffer, err := createThumbnailBuffer(c, data, mimeType, width, height)
if err != nil {
crud.Error("Failed to create thumbnail: ", err)
return c.Blob(http.StatusOK, mimeType, data)
}
filePath := getTmpFilePath(path, width, height)
err = CreateFile(filePath, buffer)
if err != nil {
crud.Error("Failed to save thumbnail: ", err)
return sendImage(c, path)
}
return sendImage(c, filePath)
}
func getTmpFilePath(path string, width int, height int) string {
tmpFilePath := fmt.Sprint("tmp/", path, "_", width, "_", height)
return tmpFilePath
}
func createThumbnailBuffer(c Context, data []byte, mimeType string, width int, height int) (*bytes.Buffer, error) {
reader := bytes.NewReader(data)
buffer := new(bytes.Buffer)
err := ThumbnailImage(reader, buffer, mimeType, width, height)
if err != nil {
return nil, err
}
return buffer, nil
}
// ThumbnailImage https://roeber.dev/posts/resize-an-image-in-go/
// creates a resized image from the reader and writes it to
// the writer. The mimetype determines how the image will be decoded
// and must be either "image/jpeg" or "image/png". The desired width
// of the ThumbnailImage is specified in pixels, and the resulting height
// will be calculated to preserve the aspect ratio.
func ThumbnailImage(r io.Reader, w io.Writer, mimetype string, width int, height int) error {
var src image.Image
var err error
switch mimetype {
case "image/jpeg":
src, err = jpeg.Decode(r)
case "image/png":
src, err = png.Decode(r)
}
if err != nil {
return err
}
ratio := (float64)(src.Bounds().Max.Y) / (float64)(src.Bounds().Max.X)
if height == -1 {
height = int(math.Round(float64(width) * ratio))
}
if width == -1 {
width = int(math.Round(float64(height) / ratio))
}
dst := image.NewRGBA(image.Rect(0, 0, width, height))
draw.CatmullRom.Scale(dst, dst.Rect, src, src.Bounds(), draw.Over, nil)
err = jpeg.Encode(w, dst, nil)
if err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,35 @@
package html_components
type ItemDisplayType string
const (
ItemDisplayTypeText ItemDisplayType = "text"
ItemDisplayTypeTextarea ItemDisplayType = "textarea"
ItemDisplayTypeImage ItemDisplayType = "image"
ItemDisplayTypeDateTime ItemDisplayType = "datetime"
)
type ItemDisplayColumn struct {
Label string
Value string
Type ItemDisplayType
CropUrl string
Width int
TimestampParam string
}
type ItemDisplaySubItem struct {
Label string
Url string
}
type ItemDisplay struct {
BackUrl string
Columns []ItemDisplayColumn
SubItems []ItemDisplaySubItem
EditItemUrl string
DeleteItemUrl string
DeletePushUrl string
HasImages bool
ItemId int
}

5
html_components/login.go Normal file
View File

@ -0,0 +1,5 @@
package html_components
type Login struct {
GoogleLoginUrl string
}

View File

@ -0,0 +1,70 @@
package html_components
import (
"fmt"
"strconv"
"time"
)
func ParseIntWithDefault(input string, defaultValue int) int {
value, err := strconv.Atoi(input)
if err != nil {
return defaultValue
}
return value
}
func ParseFloat32WithDefault(input string, defaultValue float32) float32 {
value, err := strconv.ParseFloat(input, 32)
if err != nil {
return defaultValue
}
return float32(value)
}
func ParseBoolWithDefault(input string, defaultValue bool) bool {
if input == "true" {
return true
}
return defaultValue
}
func ParseCheckboxWithDefault(input string, defaultValue bool) bool {
if input == "on" {
return true
}
return defaultValue
}
func ParseTime(timeString string, timezone string) time.Time {
if timezone == "" {
timezone = "Europe/Berlin"
}
location, err := time.LoadLocation(timezone)
if err != nil {
println(fmt.Sprintf("failed to load location %v, error: %v", timezone, err))
location = time.Local
}
timeObject, err := time.Parse("2006-01-02T15:04", timeString)
if err == nil {
timeObject, err = time.ParseInLocation("2006-01-02T15:04", timeString, location)
}
if err != nil {
timeObject = time.UnixMilli(0).UTC()
}
return timeObject
}
func TimeToString(time time.Time) string {
if time.UnixMilli() == 0 {
return ""
}
return time.Format("2006-01-02 15:04")
}
func TimeToValue(time time.Time) string {
if time.UnixMilli() == 0 {
return ""
}
return time.Format("2006-01-02T15:04")
}

View File

@ -0,0 +1,40 @@
package html_components
import (
. "github.com/labstack/echo/v4"
"html/template"
)
type GoHtmlHandler struct {
tmpl *template.Template
}
func NewGoHtmlHandler() *GoHtmlHandler {
tmpl := template.Must(template.ParseGlob("views/components/*.gohtml"))
tmpl = template.Must(tmpl.ParseGlob("views/crud/*.gohtml"))
tmpl = template.Must(tmpl.ParseGlob("views/base*.gohtml"))
return &GoHtmlHandler{tmpl: tmpl}
}
func (h *GoHtmlHandler) RenderPage(c Context, name string, data any) error {
tmpl := template.Must(h.tmpl.Clone())
tmpl = template.Must(tmpl.ParseGlob(GetTemplatePath(name)))
return tmpl.ExecuteTemplate(c.Response().Writer, "base", data)
}
func (h *GoHtmlHandler) RenderComponent(c Context, name string, data any) error {
tmpl := template.Must(h.tmpl.Clone())
return tmpl.ExecuteTemplate(c.Response().Writer, name, data)
}
func RenderGoHtmlPage(c Context, name string, data any) error {
return NewGoHtmlHandler().RenderPage(c, name, data)
}
func RenderGoHtmlComponent(c Context, name string, data any) error {
return NewGoHtmlHandler().RenderComponent(c, name, data)
}
func GetTemplatePath(name string) string {
return "views/" + name + "*.gohtml"
}

View File

@ -0,0 +1,26 @@
package html_components
type SelectInputOption struct {
Label string
Value string
Selected bool
}
type SelectInput struct {
Label string
Name string
Options []SelectInputOption
HideLabel bool
}
func (s SelectInput) SetSelectedOption(selectedFunc func(option SelectInputOption) bool) SelectInput {
newOptions := make([]SelectInputOption, len(s.Options))
for index, option := range s.Options {
if selectedFunc(option) {
option.Selected = true
}
newOptions[index] = option
}
s.Options = newOptions
return s
}

View File

@ -0,0 +1,11 @@
package html_components
import "fmt"
func ShortenLongText(text string) string {
length := 50
if len(text) > length {
return fmt.Sprint(text[0:length], "...")
}
return text
}

View File

@ -0,0 +1,169 @@
package html_components
import (
"fmt"
. "github.com/labstack/echo/v4"
"time"
)
const tablePrototypePath = "/table-prototype"
type Profession string
const (
Developer Profession = "Developer"
Designer Profession = "Designer"
)
type PersonStruct struct {
Id int
Name string
Age int
Profession Profession
MemberSince time.Time
}
func AddTablePrototype(e *Echo) {
items := getPrototypeStructs()
e.GET(tablePrototypePath, func(c Context) error {
table := Table{
Headers: []string{"Name", "Age", "Profession", "Member Since"},
Rows: prototypeStructsToTableRows(items),
EntityUrl: tablePrototypePath,
}
return RenderGoHtmlPage(c, "table-prototype", table)
})
e.GET(createTablePrototypePath("/create"), func(c Context) error {
inputs := []EditItemModalInputs{
{Label: "Name", Value: "", Name: "name", Type: InputTypeText},
{Label: "Age", Value: "", Name: "age", Type: InputTypeInt},
{Label: "Profession", Value: "", Name: "profession", Type: InputTypeEnum, Options: []SelectInputOption{
{Label: "Developer", Value: "Developer", Selected: false},
{Label: "Designer", Value: "Designer", Selected: false},
},
},
{Label: "Member Since", Value: "", Name: "memberSince", Type: InputTypeDateTime},
}
modal := EditItemModal{
Id: "",
Title: "Create person",
Url: tablePrototypePath,
IsCreate: true,
SubmitButtonLabel: "Create",
Inputs: inputs,
}
return RenderGoHtmlComponent(c, "editItemModal", modal)
})
e.POST(tablePrototypePath, func(c Context) error {
name := c.FormValue("name")
age := c.FormValue("age")
profession := c.FormValue("profession")
memberSince := c.FormValue("memberSince")
item := PersonStruct{
Id: len(items) + 1,
Name: name,
Age: ParseIntWithDefault(age, 0),
Profession: Profession(profession),
MemberSince: ParseDateTime(memberSince),
}
items = append(items, item)
return returnRenderTable(c, items)
})
e.GET(createTablePrototypePath("/:id"), func(c Context) error {
id := ParseIntWithDefault(c.Param("id"), 1)
row := items[id-1]
inputs := []EditItemModalInputs{
{Label: "Name", Value: row.Name, Name: "name", Type: InputTypeText},
{Label: "Age", Value: fmt.Sprint(row.Age), Name: "age", Type: InputTypeInt},
{Label: "Profession", Value: "", Name: "profession", Type: InputTypeEnum, Options: []SelectInputOption{
{Label: "Developer", Value: string(Developer), Selected: row.Profession == Developer},
{Label: "Designer", Value: string(Designer), Selected: row.Profession == Designer},
},
},
{Label: "Member Since", Value: formatDateRangeInputTimeStamp(row.MemberSince), Name: "memberSince", Type: InputTypeDateTime},
}
modal := EditItemModal{
Id: fmt.Sprint(id),
Title: "Update person",
Url: tablePrototypePath,
IsCreate: false,
SubmitButtonLabel: "Update",
Inputs: inputs,
}
return RenderGoHtmlComponent(c, "editItemModal", modal)
})
e.PUT(createTablePrototypePath("/:id"), func(c Context) error {
id := ParseIntWithDefault(c.Param("id"), 1)
name := c.FormValue("name")
age := c.FormValue("age")
profession := c.FormValue("profession")
row := items[id-1]
row.Name = name
row.Age = ParseIntWithDefault(age, 0)
row.Profession = Profession(profession)
row.MemberSince = ParseDateTime(c.FormValue("memberSince"))
return RenderGoHtmlComponent(c, "tableRow", prototypeStructToRow(row))
})
e.DELETE(createTablePrototypePath("/:id"), func(c Context) error {
id := ParseIntWithDefault(c.Param("id"), 1)
items = append(items[:id-1], items[id:]...)
return returnRenderTable(c, items)
})
}
func ParseDateTime(since string) time.Time {
t, err := time.Parse("2006-01-02T15:04", since)
if err != nil {
return time.Now()
}
return t
}
func returnRenderTable(c Context, items []PersonStruct) error {
table := Table{
Headers: []string{"Name", "Age", "Profession", "Member Since"},
Rows: prototypeStructsToTableRows(items),
EntityUrl: tablePrototypePath,
}
return RenderGoHtmlComponent(c, "table", table)
}
func getPrototypeStructs() []PersonStruct {
return []PersonStruct{
{Id: 1, Name: "John", Age: 25, Profession: Developer, MemberSince: time.Now()},
{Id: 2, Name: "Jane", Age: 23, Profession: Designer, MemberSince: time.Now()},
{Id: 3, Name: "Doe", Age: 30, Profession: Developer, MemberSince: time.Now()},
}
}
func prototypeStructsToTableRows(items []PersonStruct) []TableRow {
var rows []TableRow
for _, item := range items {
rows = append(rows, prototypeStructToRow(item))
}
return rows
}
func prototypeStructToRow(person PersonStruct) TableRow {
return TableRow{
Id: fmt.Sprint(person.Id),
Columns: []TableColumn{
TableColumn{Value: person.Name, Type: TableColumnTypeText},
TableColumn{Value: fmt.Sprint(person.Age), Type: TableColumnTypeText},
TableColumn{Value: string(person.Profession), Type: TableColumnTypeText},
TableColumn{Value: dateDisplay(person.MemberSince), Type: TableColumnTypeText}},
EntityUrl: tablePrototypePath,
}
}
func createTablePrototypePath(path string) string {
return tablePrototypePath + path
}
func formatDateRangeInputTimeStamp(time time.Time) string {
return time.Format("2006-01-02T15:04")
}
func dateDisplay(time time.Time) string {
return time.Format("2006-01-02 15:04:05")
}

46
html_components/table.go Normal file
View File

@ -0,0 +1,46 @@
package html_components
type TableColumnType string
const (
TableColumnTypeText TableColumnType = "text"
TableColumnTypeImage TableColumnType = "image"
)
type TableColumn struct {
Value string
Type TableColumnType
}
type TableRow struct {
Id string
Columns []TableColumn
EntityUrl string
EditItemUrl string
DeleteItemUrl string
}
type Pagination struct {
PreviousDisabled bool
NextDisabled bool
Page int
TotalNumberOfItems int
CurrenItemStart int
CurrentItemEnd int
PreviousPage int
NextPage int
}
type Table struct {
Headers []string
Rows []TableRow
EntityUrl string
OrderBy string
OrderDirection string
FilterValue string
FilterSelect SelectInput
Pagination Pagination
ShowBack bool
BackUrl string
CreateItemUrl string
}

20
html_components/toast.go Normal file
View File

@ -0,0 +1,20 @@
package html_components
type Toast struct {
Url string
HasTarget bool
Target string
Label string
LinkLabel string
TimeoutMs int
ToastPosition ToastPosition
}
type ToastPosition string
const (
ToastTopLeft ToastPosition = "top-5 left-5"
ToastTopRight ToastPosition = "top-5 right-5"
ToastBottomLeft ToastPosition = "right-5 bottom-5"
ToastBottomRight ToastPosition = "bottom-5 left-5"
)

49
html_components/url.go Normal file
View File

@ -0,0 +1,49 @@
package html_components
import (
"fmt"
. "github.com/labstack/echo/v4"
"net/url"
"strconv"
)
const HeaderHxCurrentUrl = "Hx-Current-Url"
func getCurrentUrlHeader(c Context) string {
return c.Request().Header.Get(HeaderHxCurrentUrl)
}
const HeaderFromTable = "From-Table"
func HasFromTableHeader(c Context) bool {
parsed, err := strconv.ParseBool(c.Request().Header.Get(HeaderFromTable))
return err == nil && parsed
}
func checkForValidUrl(stringUrl string) string {
_, err := url.Parse(stringUrl)
if err != nil {
return "/" // safe fallback
}
return stringUrl
}
func GetCurrentUrl(c Context, defaultPath string) string {
currentUrlHeader := getCurrentUrlHeader(c)
if currentUrlHeader == "" {
return checkForValidUrl(defaultPath)
}
return currentUrlHeader
}
func GetCurrentUrlQueryParams(c Context) string {
currentUrlHeader := getCurrentUrlHeader(c)
if currentUrlHeader == "" {
return ""
}
currentUrl, err := url.Parse(currentUrlHeader)
if err != nil {
return ""
}
return fmt.Sprint("?", currentUrl.Query().Encode())
}

81
main.go Normal file
View File

@ -0,0 +1,81 @@
package main
import (
"test/crud"
. "test/html_components"
"test/user"
"test/test"
"github.com/joho/godotenv"
. "github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
etag "github.com/pablor21/echo-etag/v4"
"net/http"
"os"
"strings"
)
func main() {
err := godotenv.Load()
if err != nil {
crud.LogError("Error loading .env file: %v", err)
panic(err)
}
pool, err := crud.CreatePostgresConnpool(os.Getenv("DATABASE_URL"))
if err != nil {
crud.LogError("Failed to create connection pool: %v", err)
panic(err)
}
e := New()
e.Use(middleware.Logger())
htmlHandler := NewGoHtmlHandler()
staticGroup := e.Group("/static")
staticGroup.Use(etag.Etag())
staticGroup.Static("/", "static")
e.GET("/", func(c Context) error {
return RenderGoHtmlPage(c, "index", nil)
})
userRepo := user.NewUserRepository(pool)
userLogin := user.NewUserLogin(e, userRepo, htmlHandler)
userLogin.AddLoginRoute()
testRepo := test.NewTestRepository(pool)
testCrud := test.NewTestCrud(e, testRepo, htmlHandler)
testCrud.AddRoutes()
e.Use(CreateAuthenticationMiddleware(userLogin))
e.Logger.Fatal(e.Start(":8089"))
}
func CreateAuthenticationMiddleware(userLogin *user.UserLogin) MiddlewareFunc {
return func(next HandlerFunc) HandlerFunc {
return func(c Context) error {
if strings.HasPrefix(c.Path(), "/static") {
return next(c)
}
paths := []string{"/logout", "/login"}
for _, path := range paths {
if c.Path() == path {
return next(c)
}
}
if userLogin.IsSessionAuthenticated(c) {
return next(c)
}
return redirectToAuthentication(c)
}
}
}
func redirectToAuthentication(c Context) error {
return c.Redirect(http.StatusTemporaryRedirect, "/login")
}

37
schema.prisma Normal file
View File

@ -0,0 +1,37 @@
// AUTO GENERATED
// DO NOT EDIT
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator db {
provider = "go run github.com/steebchen/prisma-client-go"
}
model user {
id Int @id @default(autoincrement())
email String @unique @default("")
password String @default("")
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
test test[]
}
model test {
id Int @id @default(autoincrement())
name String @default("")
checked Boolean @default(false)
user_id Int @default(0)
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
user user @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: Cascade )
}

2
scripts/db-push.sh Normal file
View File

@ -0,0 +1,2 @@
#!/bin/bash
go run github.com/steebchen/prisma-client-go db push

9
static/cropper.min.css vendored Normal file
View File

@ -0,0 +1,9 @@
/*!
* Cropper.js v1.6.2
* https://fengyuanchen.github.io/cropperjs
*
* Copyright 2015-present Chen Fengyuan
* Released under the MIT license
*
* Date: 2024-04-21T07:43:02.731Z
*/.cropper-container{-webkit-touch-callout:none;direction:ltr;font-size:0;line-height:0;position:relative;-ms-touch-action:none;touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.cropper-container img{backface-visibility:hidden;display:block;height:100%;image-orientation:0deg;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;width:100%}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{bottom:0;left:0;position:absolute;right:0;top:0}.cropper-canvas,.cropper-wrap-box{overflow:hidden}.cropper-drag-box{background-color:#fff;opacity:0}.cropper-modal{background-color:#000;opacity:.5}.cropper-view-box{display:block;height:100%;outline:1px solid #39f;outline-color:rgba(51,153,255,.75);overflow:hidden;width:100%}.cropper-dashed{border:0 dashed #eee;display:block;opacity:.5;position:absolute}.cropper-dashed.dashed-h{border-bottom-width:1px;border-top-width:1px;height:33.33333%;left:0;top:33.33333%;width:100%}.cropper-dashed.dashed-v{border-left-width:1px;border-right-width:1px;height:100%;left:33.33333%;top:0;width:33.33333%}.cropper-center{display:block;height:0;left:50%;opacity:.75;position:absolute;top:50%;width:0}.cropper-center:after,.cropper-center:before{background-color:#eee;content:" ";display:block;position:absolute}.cropper-center:before{height:1px;left:-3px;top:0;width:7px}.cropper-center:after{height:7px;left:0;top:-3px;width:1px}.cropper-face,.cropper-line,.cropper-point{display:block;height:100%;opacity:.1;position:absolute;width:100%}.cropper-face{background-color:#fff;left:0;top:0}.cropper-line{background-color:#39f}.cropper-line.line-e{cursor:ew-resize;right:-3px;top:0;width:5px}.cropper-line.line-n{cursor:ns-resize;height:5px;left:0;top:-3px}.cropper-line.line-w{cursor:ew-resize;left:-3px;top:0;width:5px}.cropper-line.line-s{bottom:-3px;cursor:ns-resize;height:5px;left:0}.cropper-point{background-color:#39f;height:5px;opacity:.75;width:5px}.cropper-point.point-e{cursor:ew-resize;margin-top:-3px;right:-3px;top:50%}.cropper-point.point-n{cursor:ns-resize;left:50%;margin-left:-3px;top:-3px}.cropper-point.point-w{cursor:ew-resize;left:-3px;margin-top:-3px;top:50%}.cropper-point.point-s{bottom:-3px;cursor:s-resize;left:50%;margin-left:-3px}.cropper-point.point-ne{cursor:nesw-resize;right:-3px;top:-3px}.cropper-point.point-nw{cursor:nwse-resize;left:-3px;top:-3px}.cropper-point.point-sw{bottom:-3px;cursor:nesw-resize;left:-3px}.cropper-point.point-se{bottom:-3px;cursor:nwse-resize;height:20px;opacity:1;right:-3px;width:20px}@media (min-width:768px){.cropper-point.point-se{height:15px;width:15px}}@media (min-width:992px){.cropper-point.point-se{height:10px;width:10px}}@media (min-width:1200px){.cropper-point.point-se{height:5px;opacity:.75;width:5px}}.cropper-point.point-se:before{background-color:#39f;bottom:-50%;content:" ";display:block;height:200%;opacity:0;position:absolute;right:-50%;width:200%}.cropper-invisible{opacity:0}.cropper-bg{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC")}.cropper-hide{display:block;height:0;position:absolute;width:0}.cropper-hidden{display:none!important}.cropper-move{cursor:move}.cropper-crop{cursor:crosshair}.cropper-disabled .cropper-drag-box,.cropper-disabled .cropper-face,.cropper-disabled .cropper-line,.cropper-disabled .cropper-point{cursor:not-allowed}

10
static/cropper.min.js vendored Normal file

File diff suppressed because one or more lines are too long

1
static/flowbite.min.css vendored Normal file

File diff suppressed because one or more lines are too long

2
static/flowbite.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

3925
static/htmx.js Normal file

File diff suppressed because it is too large Load Diff

331
test/test-repository_gen.go Normal file
View File

@ -0,0 +1,331 @@
package test
// GENERATED FILE
// DO NOT EDIT
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/doug-martin/goqu/v9"
_ "github.com/doug-martin/goqu/v9/dialect/postgres"
"github.com/doug-martin/goqu/v9/exp"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"strings"
"test/crud"
"time"
)
type TestRepository struct {
connPool *pgxpool.Pool
dialect goqu.DialectWrapper
}
func NewTestRepository(connPool *pgxpool.Pool) *TestRepository {
return &TestRepository{
connPool: connPool,
dialect: goqu.Dialect("postgres"),
}
}
func (r *TestRepository) Create(test Test) (int, error) {
sql, args, err := r.dialect.Insert("test").
Prepared(true).
Rows(goqu.Record{
"updated_at": time.Now(),
"name": test.Name,
"checked": test.Checked,
"user_id": test.UserId,
}).
Returning("id").
ToSQL()
if err != nil {
crud.LogError("error creating create Test sql: %v", err)
return -1, err
}
rows, err := r.connPool.Query(context.Background(), sql, args...)
if err != nil {
crud.LogError("error creating Test: %v", err)
return -1, err
}
defer rows.Close()
var id int
if rows.Next() {
err = rows.Scan(&id)
if err != nil {
crud.LogError("error scanning User: %v", err)
return -1, err
}
} else {
crud.Error("Test already exists")
return -1, TestAlreadyExistsError{Test: test}
}
return id, nil
}
type TestAlreadyExistsError struct {
Test Test
}
func (e TestAlreadyExistsError) Error() string {
return fmt.Sprint("Test ", e.Test, " already exists")
}
func (r *TestRepository) getSelectColumns() []any {
return []any{"id", "created_at", "updated_at",
"name", "checked", "user_id",
}
}
func (r *TestRepository) Read(userId int, id int) (Test, error) {
crud.Debug("Getting Test by id ", id)
sql, args, _ := r.dialect.From("test").
Prepared(true).
Select(r.getSelectColumns()...).
Where(goqu.Ex{
"id": id,
"user_id": userId,
}).
ToSQL()
rows, err := r.connPool.Query(context.Background(), sql, args...)
if err != nil {
crud.Error("Failed to get Test: ", err)
}
defer rows.Close()
if rows.Next() {
item, _, err := r.rowToItem(rows, false)
return item, err
}
return Test{}, errors.New("no rows found")
}
type TestItemScan struct {
Test
RowId int
Count int
}
func (r *TestRepository) rowToItem(rows pgx.Rows, rowId bool) (Test, int, error) {
var item TestItemScan
if rowId {
err := rows.Scan(
&item.RowId,
&item.Count,
&item.Id,
&item.CreatedAt,
&item.UpdatedAt,
&item.Name,
&item.Checked,
&item.UserId,
)
if err != nil {
return Test{}, -1, err
}
} else {
err := rows.Scan(
&item.Id,
&item.CreatedAt,
&item.UpdatedAt,
&item.Name,
&item.Checked,
&item.UserId,
)
if err != nil {
return Test{}, -1, err
}
}
return Test{
Id: item.Id,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
Name: item.Name,
Checked: item.Checked,
UserId: item.UserId,
}, item.Count, nil
}
func (r *TestRepository) Update(userId int, test Test) error {
sql, args, err := r.dialect.Update("test").
Prepared(true).
Set(goqu.Record{
"updated_at": time.Now(),
"name": test.Name,
"checked": test.Checked,
"user_id": test.UserId,
}).
Where(goqu.Ex{
"id": test.Id,
"user_id": userId,
}).
ToSQL()
if err != nil {
crud.LogError("error creating update Test sql: %v", err)
return err
}
_, err = r.connPool.Exec(context.Background(), sql, args...)
if err != nil {
crud.LogError("error updating Test: %v", err)
return err
}
return nil
}
func (r *TestRepository) Delete(userId int, id int) error {
sql, args, err := r.dialect.Delete("test").
Prepared(true).
Where(goqu.Ex{
"id": id,
"user_id": userId,
}).
ToSQL()
if err != nil {
crud.LogError("error creating delete Test sql: %v", err)
return err
}
_, err = r.connPool.Exec(context.Background(), sql, args...)
if err != nil {
crud.LogError("error deleting Test: %v", err)
return err
}
return nil
}
type TestField string
const (
TestFieldName TestField = "name"
TestFieldChecked TestField = "checked"
)
type TestNameFilter struct {
Active bool
Value string
}
type TestCheckedFilter struct {
Active bool
Value bool
}
type TestOrderDirection string
const (
TestOrderDirectionAsc TestOrderDirection = "asc"
TestOrderDirectionDesc TestOrderDirection = "desc"
)
type TestReferences struct {
UserId int
}
type TestPaginationParams struct {
RowId int
PageSize int
OrderBy TestField
OrderDirection TestOrderDirection
NameFilter TestNameFilter
CheckedFilter TestCheckedFilter
References TestReferences
}
func (r *TestRepository) GetPage(params TestPaginationParams) ([]Test, int, error) {
var orderByWindow exp.WindowExpression
if params.OrderDirection == TestOrderDirectionAsc {
orderByWindow = goqu.W().OrderBy(goqu.C(string(params.OrderBy)).Asc())
} else {
orderByWindow = goqu.W().OrderBy(goqu.C(string(params.OrderBy)).Desc())
}
selectColumns := []any{
goqu.ROW_NUMBER().Over(orderByWindow).As("row_id"),
goqu.COUNT("*"),
}
selectColumns = append(selectColumns, r.getSelectColumns()...)
whereExpressions := []goqu.Expression{
goqu.Ex{
"user_id": params.References.UserId,
},
}
whereExpressions = r.addPageFilters(params, whereExpressions)
var colOrder exp.OrderedExpression
if params.OrderDirection == TestOrderDirectionAsc {
colOrder = goqu.C(string(params.OrderBy)).Asc()
} else {
colOrder = goqu.C(string(params.OrderBy)).Desc()
}
dialect := goqu.Dialect("postgres")
innerFrom := dialect.From("test").
Prepared(true).
Select(selectColumns...).
Where(whereExpressions...).
Order(colOrder)
sql, args, _ := dialect.From(innerFrom).
Prepared(true).
Where(goqu.Ex{"row_id": goqu.Op{"gt": params.RowId}}).
Limit(uint(params.PageSize)).
ToSQL()
sql = strings.Replace(sql, "COUNT(*)", "COUNT(*) over()", 1)
rows, err := r.connPool.Query(context.Background(), sql, args...)
if err != nil {
crud.LogError("failed to run sql query: %v", err)
return nil, -1, err
}
defer rows.Close()
results := make([]Test, 0)
totalCount := 0
for rows.Next() {
parsed, count, err := r.rowToItem(rows, true)
if err != nil {
return nil, -1, err
}
totalCount = count
results = append(results, parsed)
}
return results, totalCount, nil
}
func (r *TestRepository) addPageFilters(params TestPaginationParams, whereExpressions []goqu.Expression) []goqu.Expression {
if params.NameFilter.Active {
whereExpressions = append(whereExpressions, goqu.Ex{
"name": goqu.Op{"like": fmt.Sprint("%", params.NameFilter.Value, "%")},
})
}
if params.CheckedFilter.Active {
whereExpressions = append(whereExpressions, goqu.Ex{
"checked": params.CheckedFilter.Value,
})
}
return whereExpressions
}
func (r *TestRepository) jsonToString(jsonData any) string {
bytes, err := json.Marshal(jsonData)
if err != nil {
return "{}"
}
return string(bytes)
}
func (r *TestRepository) FirstLetterToUpper(name string) string {
return strings.ToUpper(name[:1]) + name[1:]
}

400
test/test-rest-crud_gen.go Normal file
View File

@ -0,0 +1,400 @@
package test
import (
"errors"
"fmt"
"github.com/google/uuid"
. "github.com/labstack/echo/v4"
"test/crud"
. "test/html_components"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
const TestPath = "/test"
type TestCrud struct {
e *Echo
repo *TestRepository
html *GoHtmlHandler
}
func NewTestCrud(e *Echo, repo *TestRepository, html *GoHtmlHandler) *TestCrud {
return &TestCrud{e: e, repo: repo, html: html}
}
func (i *TestCrud) AddRoutes() {
crud.Debug("Adding Test crud routes")
g := i.e.Group(TestPath)
g.GET("", i.GetItems)
g.GET("/:id", i.GetItem)
g.GET("/create", i.CreateNewItemInputs)
g.POST("", i.CreateItem)
g.GET("/:id/edit", i.EditItem)
g.PUT("/:id", i.UpdateItem)
g.DELETE("/:id", i.DeleteItem)
}
func (i *TestCrud) readItem(c Context) (Test, error) {
userId := c.Get("userId").(int)
id := ParseIntWithDefault(c.Param("id"), 1)
return i.repo.Read(userId, id)
}
func (i *TestCrud) GetItem(c Context) error {
item, err := i.readItem(c)
if err != nil {
return err
}
queryString := GetCurrentUrlQueryParams(c)
itemDisplay := ItemDisplay{
Columns: []ItemDisplayColumn{
{Label: "Name", Value: item.Name, Type: ItemDisplayTypeText},
{Label: "Checked", Value: fmt.Sprint(item.Checked), Type: ItemDisplayTypeText},
},
SubItems: i.getSubItemDisplays(item, c),
EditItemUrl: fmt.Sprint(i.GetEntityUrl(c), "/", item.Id, "/edit", queryString),
DeleteItemUrl: fmt.Sprint(i.GetEntityUrl(c), "/", item.Id, queryString),
DeletePushUrl: fmt.Sprint(i.GetEntityUrl(c), queryString),
HasImages: false,
BackUrl: fmt.Sprint(i.GetEntityUrl(c), queryString),
ItemId: item.Id,
}
return i.html.RenderPage(c, "test", TestDisplay{
IsDisplay: true,
ItemDisplay: itemDisplay,
})
}
func (i *TestCrud) getSubItemDisplays(item Test, c Context) []ItemDisplaySubItem {
var items []ItemDisplaySubItem
return items
}
type TestDisplay struct {
IsTable bool
Table Table
IsDisplay bool
ItemDisplay ItemDisplay
IsEdit bool
EditItem EditItem
}
func (i *TestCrud) GetItems(c Context) error {
page, count, err := i.getPage(c)
if err != nil {
return err
}
table := i.itemsToTable(c, page, count)
return i.html.RenderPage(c, "test", TestDisplay{
IsTable: true,
Table: table,
})
}
func (i *TestCrud) GetEntityUrl(c Context) string {
return TestPath
}
func (i *TestCrud) GetParentEntityUrl(c Context) string {
return ""
}
func (i *TestCrud) CreateNewItemInputs(c Context) error {
inputs := []EditItemInputs{
{Label: "Name", Value: "", Name: "Name", Type: InputTypeText, Options: []SelectInputOption{}},
{Label: "Checked", Value: "", Name: "Checked", Type: InputTypeBool, Options: []SelectInputOption{}},
}
url := fmt.Sprint(i.GetEntityUrl(c), "?", c.QueryString())
s := EditItem{
Id: "",
Title: "Create test",
Url: url,
CancelUrl: url,
IsCreate: true,
SubmitButtonLabel: "Create",
Inputs: inputs,
HasFileUpload: false,
}
return i.html.RenderPage(c, "test", TestDisplay{
IsEdit: true,
EditItem: s,
})
}
func (i *TestCrud) CreateItem(c Context) error {
userId := c.Get("userId").(int)
item := Test{
UserId: userId,
Name: c.FormValue("Name"),
Checked: ParseCheckboxWithDefault(c.FormValue("Checked"), false),
}
_, err := i.repo.Create(item)
if err != nil {
return err
}
return i.GetItems(c)
}
func (i *TestCrud) EditItem(c Context) error {
userId := c.Get("userId").(int)
id := ParseIntWithDefault(c.Param("id"), 1)
item, err := i.repo.Read(userId, id)
if err != nil {
return err
}
inputs := []EditItemInputs{
{Label: "Name", Value: item.Name, Name: "Name", Type: InputTypeText, Options: []SelectInputOption{}},
{Label: "Checked", Value: fmt.Sprint(item.Checked), Name: "Checked", Type: InputTypeBool, Options: []SelectInputOption{}},
}
path := fmt.Sprint(i.GetEntityUrl(c), "/", item.Id)
queryString := GetCurrentUrlQueryParams(c)
url := fmt.Sprint(path, queryString)
cancelUrl := url
if HasFromTableHeader(c) {
cancelUrl = fmt.Sprint(i.GetEntityUrl(c), queryString)
}
s := EditItem{
Id: fmt.Sprint(id),
Title: fmt.Sprint("Update test ", id),
Url: url,
CancelUrl: cancelUrl,
IsCreate: false,
SubmitButtonLabel: "Update",
Inputs: inputs,
HasFileUpload: false,
FromTable: HasFromTableHeader(c),
}
return i.html.RenderPage(c, "test", TestDisplay{
IsEdit: true,
EditItem: s,
})
}
func (i *TestCrud) UpdateItem(c Context) error {
userId := c.Get("userId").(int)
id := ParseIntWithDefault(c.Param("id"), 1)
item, err := i.repo.Read(userId, id)
if err != nil {
return err
}
item.Name = c.FormValue("Name")
item.Checked = ParseCheckboxWithDefault(c.FormValue("Checked"), false)
err = i.repo.Update(userId, item)
if err != nil {
return err
}
if HasFromTableHeader(c) {
return i.GetItems(c)
}
return i.GetItem(c)
}
func (i *TestCrud) DeleteItem(c Context) error {
userId := c.Get("userId").(int)
id := ParseIntWithDefault(c.Param("id"), 1)
err := i.repo.Delete(userId, id)
if err != nil {
return err
}
return i.GetItems(c)
}
func (i *TestCrud) parseDateTime(since string) time.Time {
t, err := time.Parse("2006-01-02T15:04", since)
if err != nil {
return time.Now()
}
return t
}
func (i *TestCrud) renderPage(c Context, repo *TestRepository) error {
page, count, err := i.getPage(c)
if err != nil {
return err
}
table := i.itemsToTable(c, page, count)
return i.html.RenderPage(c, "test", table)
}
func (i *TestCrud) returnRenderTable(c Context, items []Test, count int) error {
table := i.itemsToTable(c, items, count)
return i.html.RenderComponent(c, "table", table)
}
func (i *TestCrud) itemsToTable(c Context, items []Test, count int) Table {
filter := c.FormValue("filter")
page := ParseIntWithDefault(c.FormValue("pageNumber"), 1)
index := (page - 1) * 5
itemEnd := index + 5
if itemEnd > count {
itemEnd = count
}
return Table{
Headers: []string{
"Name",
"Checked",
},
Rows: i.structsToTableRows(c, items),
EntityUrl: i.GetEntityUrl(c),
CreateItemUrl: fmt.Sprint(i.GetEntityUrl(c), "/create?", c.QueryString()),
OrderBy: string(i.getOrderBy(c)),
OrderDirection: string(i.getOrderDirection(c)),
FilterValue: c.FormValue("filterValue"),
FilterSelect: SelectInput{
Label: "Filter by",
Name: "filter",
HideLabel: true,
Options: []SelectInputOption{
{Label: "Name filter", Value: "Name", Selected: filter == "Name"},
{Label: "Checked filter", Value: "Checked", Selected: filter == "Checked"},
},
},
Pagination: Pagination{
CurrenItemStart: index + 1,
CurrentItemEnd: index + 10,
TotalNumberOfItems: count,
PreviousDisabled: index == 0,
NextDisabled: index+10 >= count,
Page: page,
PreviousPage: page - 1,
NextPage: page + 1,
},
ShowBack: false,
BackUrl: i.GetParentEntityUrl(c),
}
}
func (i *TestCrud) structsToTableRows(c Context, items []Test) []TableRow {
var rows []TableRow
for _, item := range items {
rows = append(rows, i.structToRow(c, item))
}
return rows
}
func (i *TestCrud) structToRow(c Context, item Test) TableRow {
return TableRow{
Id: fmt.Sprint(item.Id),
Columns: []TableColumn{
{Value: item.Name, Type: TableColumnTypeText},
{Value: fmt.Sprint(item.Checked), Type: TableColumnTypeText},
},
EntityUrl: i.GetEntityUrl(c),
EditItemUrl: fmt.Sprint(i.GetEntityUrl(c), "/", item.Id, "/edit"),
DeleteItemUrl: fmt.Sprint(i.GetEntityUrl(c), "/", item.Id),
}
}
func (i *TestCrud) formatDateRangeInputTimeStamp(time time.Time) string {
return time.Format("2006-01-02T15:04")
}
func (i *TestCrud) dateDisplay(time time.Time) string {
return time.Format("2006-01-02 15:04:05")
}
func (i *TestCrud) getPage(c Context) ([]Test, int, error) {
userId := c.Get("userId").(int)
filter := c.FormValue("filter")
filterValue := c.FormValue("filterValue")
page := ParseIntWithDefault(c.FormValue("pageNumber"), 1)
return i.repo.GetPage(TestPaginationParams{
RowId: (page - 1) * 10,
PageSize: 10,
OrderBy: i.getOrderBy(c),
OrderDirection: i.getOrderDirection(c),
NameFilter: TestNameFilter{
Active: filter == "Name",
Value: filterValue,
},
CheckedFilter: TestCheckedFilter{
Active: filter == "Checked",
Value: ParseBoolWithDefault(filterValue, false),
},
References: TestReferences{
UserId: userId,
},
})
}
func (i *TestCrud) getOrderBy(c Context) TestField {
orderBy := c.QueryParam("orderBy")
if orderBy == "" {
return TestFieldName
}
return TestField(strings.ToLower(orderBy))
}
func (i *TestCrud) getOrderDirection(c Context) TestOrderDirection {
orderDirection := c.QueryParam("orderDirection")
if orderDirection == "" {
return TestOrderDirectionAsc
}
return TestOrderDirection(orderDirection)
}
func (i *TestCrud) saveFileAndReturnFileName(c Context, name string, currentFilename string) string {
file, err := c.FormFile(name)
if errors.Is(err, http.ErrMissingFile) {
return currentFilename
}
if err != nil {
crud.Error("Failed to save file from input", name, "with error", err)
return currentFilename
}
src, err := file.Open()
if err != nil {
crud.Error("Failed to save file from input", name, "with error", err)
return currentFilename
}
defer src.Close()
err = os.MkdirAll("files", os.ModePerm)
if err != nil {
crud.Error("Failed to save file from input", name, "with error", err)
return currentFilename
}
extension := filepath.Ext(file.Filename)
filename := fmt.Sprint(uuid.New().String(), extension)
err = CreateFile("files/"+filename, src)
if err != nil {
crud.Error("Failed to save file from input", name, "with error", err)
return currentFilename
}
return filename
}

29
test/test_gen.go Normal file
View File

@ -0,0 +1,29 @@
package test
// AUTO GENERATED
// DO NOT EDIT
import (
"fmt"
"time"
)
type Test struct {
Id int `db:"id"`
Name string `db:"name"`
Checked bool `db:"checked"`
UserId int `db:"user_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
func (s *Test) String() string {
return fmt.Sprint("Test{ ",
"Id: ", s.Id, ", ",
"Name: ", s.Name, ", ",
"Checked: ", s.Checked, ", ",
"UserId: ", s.UserId, ", ",
"CreatedAt: ", s.CreatedAt, ", ",
"UpdatedAt: ", s.UpdatedAt, ", ",
"}")
}

97
user/user-login_gen.go Normal file
View File

@ -0,0 +1,97 @@
package user
import (
"github.com/gorilla/sessions"
. "github.com/labstack/echo/v4"
"net/http"
"os"
"test/crud"
. "test/html_components"
)
// GENERATED FILE
// DO NOT EDIT
const cookieMaxAge = 60 * 60 * 24
type UserLogin struct {
e *Echo
repo *UserRepository
cookieStore *sessions.CookieStore
html *GoHtmlHandler
}
func NewUserLogin(e *Echo, repo *UserRepository, html *GoHtmlHandler) *UserLogin {
store := sessions.NewCookieStore([]byte(os.Getenv("SESSION_SECRET")))
isLocal := os.Getenv("IS_LOCAL") == "true"
if isLocal {
store.Options.Secure = false
store.Options.SameSite = http.SameSiteLaxMode
}
store.Options.HttpOnly = true
store.MaxAge(cookieMaxAge)
return &UserLogin{e: e, repo: repo, cookieStore: store, html: html}
}
func (u *UserLogin) AddLoginRoute() {
u.e.GET("/login", func(c Context) error {
return u.html.RenderPage(c, "login", EmailLogin{})
})
u.e.POST("/login", func(c Context) error {
email := c.FormValue("email")
password := c.FormValue("password")
crud.Debug("login request received for email: ", email)
success, userId, err := u.repo.VerifyPassword(email, password)
if err != nil {
crud.Error("error while verifying password: ", err)
return u.returnLoginFailed(c)
}
crud.Debug("login success: ", success)
if !success {
return u.returnLoginFailed(c)
}
err = u.createSession(c, email, userId)
if err != nil {
crud.Error("error while creating session: ", err)
return u.returnLoginFailed(c)
}
c.Response().Header().Set("HX-Redirect", "/")
return nil
})
}
func (u *UserLogin) createSession(c Context, email string, userId int) error {
s, err := u.cookieStore.New(c.Request(), "session")
if err != nil {
return err
}
s.Values["email"] = email
s.Values["userId"] = userId
return s.Save(c.Request(), c.Response())
}
func (u *UserLogin) returnLoginFailed(c Context) error {
return u.html.RenderComponent(c, "emailLogin", EmailLogin{
ShowError: true,
Error: "Could not authenticate the user",
})
}
func (u *UserLogin) IsSessionAuthenticated(c Context) bool {
session, err := u.cookieStore.Get(c.Request(), "session")
if err != nil {
return false
}
email, ok := session.Values["email"].(string)
if !ok {
return false
}
id, ok := session.Values["userId"].(int)
if !ok {
return false
}
c.Set("user", email)
c.Set("userId", id)
return id > -1
}

374
user/user-repository_gen.go Normal file
View File

@ -0,0 +1,374 @@
package user
// GENERATED FILE
// DO NOT EDIT
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/doug-martin/goqu/v9"
_ "github.com/doug-martin/goqu/v9/dialect/postgres"
"github.com/doug-martin/goqu/v9/exp"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"strings"
"test/crud"
"time"
)
type UserRepository struct {
connPool *pgxpool.Pool
dialect goqu.DialectWrapper
}
func NewUserRepository(connPool *pgxpool.Pool) *UserRepository {
return &UserRepository{
connPool: connPool,
dialect: goqu.Dialect("postgres"),
}
}
func (r *UserRepository) Create(user User) (int, error) {
sql, args, err := r.dialect.Insert("user").
Prepared(true).
Rows(goqu.Record{
"updated_at": time.Now(),
"email": user.Email,
"password": user.Password,
}).
Returning("id").
ToSQL()
if err != nil {
crud.LogError("error creating create User sql: %v", err)
return -1, err
}
rows, err := r.connPool.Query(context.Background(), sql, args...)
if err != nil {
crud.LogError("error creating User: %v", err)
return -1, err
}
defer rows.Close()
var id int
if rows.Next() {
err = rows.Scan(&id)
if err != nil {
crud.LogError("error scanning User: %v", err)
return -1, err
}
} else {
crud.Error("User already exists")
return -1, UserAlreadyExistsError{User: user}
}
return id, nil
}
type UserAlreadyExistsError struct {
User User
}
func (e UserAlreadyExistsError) Error() string {
return fmt.Sprint("User ", e.User, " already exists")
}
func (r *UserRepository) getSelectColumns() []any {
return []any{"id", "created_at", "updated_at",
"email", "password",
}
}
func (r *UserRepository) Read(id int) (User, error) {
crud.Debug("Getting User by id ", id)
sql, args, _ := r.dialect.From("user").
Prepared(true).
Select(r.getSelectColumns()...).
Where(goqu.Ex{
"id": id,
}).
ToSQL()
rows, err := r.connPool.Query(context.Background(), sql, args...)
if err != nil {
crud.Error("Failed to get User: ", err)
}
defer rows.Close()
if rows.Next() {
item, _, err := r.rowToItem(rows, false)
return item, err
}
return User{}, errors.New("no rows found")
}
type UserItemScan struct {
User
RowId int
Count int
}
func (r *UserRepository) rowToItem(rows pgx.Rows, rowId bool) (User, int, error) {
var item UserItemScan
if rowId {
err := rows.Scan(
&item.RowId,
&item.Count,
&item.Id,
&item.CreatedAt,
&item.UpdatedAt,
&item.Email,
&item.Password,
)
if err != nil {
return User{}, -1, err
}
} else {
err := rows.Scan(
&item.Id,
&item.CreatedAt,
&item.UpdatedAt,
&item.Email,
&item.Password,
)
if err != nil {
return User{}, -1, err
}
}
return User{
Id: item.Id,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
Email: item.Email,
Password: item.Password,
}, item.Count, nil
}
func (r *UserRepository) Update(user User) error {
sql, args, err := r.dialect.Update("user").
Prepared(true).
Set(goqu.Record{
"updated_at": time.Now(),
"email": user.Email,
"password": user.Password,
}).
Where(goqu.Ex{
"id": user.Id,
}).
ToSQL()
if err != nil {
crud.LogError("error creating update User sql: %v", err)
return err
}
_, err = r.connPool.Exec(context.Background(), sql, args...)
if err != nil {
crud.LogError("error updating User: %v", err)
return err
}
return nil
}
func (r *UserRepository) Delete(id int) error {
sql, args, err := r.dialect.Delete("user").
Prepared(true).
Where(goqu.Ex{
"id": id,
}).
ToSQL()
if err != nil {
crud.LogError("error creating delete User sql: %v", err)
return err
}
_, err = r.connPool.Exec(context.Background(), sql, args...)
if err != nil {
crud.LogError("error deleting User: %v", err)
return err
}
return nil
}
type UserField string
const (
UserFieldEmail UserField = "email"
UserFieldPassword UserField = "password"
)
type UserEmailFilter struct {
Active bool
Value string
}
type PasswordFilter struct {
Active bool
Value string
}
type UserOrderDirection string
const (
UserOrderDirectionAsc UserOrderDirection = "asc"
UserOrderDirectionDesc UserOrderDirection = "desc"
)
type UserPaginationParams struct {
RowId int
PageSize int
OrderBy UserField
OrderDirection UserOrderDirection
EmailFilter UserEmailFilter
PasswordFilter PasswordFilter
}
func (r *UserRepository) GetPage(params UserPaginationParams) ([]User, int, error) {
var orderByWindow exp.WindowExpression
if params.OrderDirection == UserOrderDirectionAsc {
orderByWindow = goqu.W().OrderBy(goqu.C(string(params.OrderBy)).Asc())
} else {
orderByWindow = goqu.W().OrderBy(goqu.C(string(params.OrderBy)).Desc())
}
selectColumns := []any{
goqu.ROW_NUMBER().Over(orderByWindow).As("row_id"),
goqu.COUNT("*"),
}
selectColumns = append(selectColumns, r.getSelectColumns()...)
whereExpressions := []goqu.Expression{
goqu.Ex{},
}
whereExpressions = r.addPageFilters(params, whereExpressions)
var colOrder exp.OrderedExpression
if params.OrderDirection == UserOrderDirectionAsc {
colOrder = goqu.C(string(params.OrderBy)).Asc()
} else {
colOrder = goqu.C(string(params.OrderBy)).Desc()
}
dialect := goqu.Dialect("postgres")
innerFrom := dialect.From("user").
Prepared(true).
Select(selectColumns...).
Where(whereExpressions...).
Order(colOrder)
sql, args, _ := dialect.From(innerFrom).
Prepared(true).
Where(goqu.Ex{"row_id": goqu.Op{"gt": params.RowId}}).
Limit(uint(params.PageSize)).
ToSQL()
sql = strings.Replace(sql, "COUNT(*)", "COUNT(*) over()", 1)
rows, err := r.connPool.Query(context.Background(), sql, args...)
if err != nil {
crud.LogError("failed to run sql query: %v", err)
return nil, -1, err
}
defer rows.Close()
results := make([]User, 0)
totalCount := 0
for rows.Next() {
parsed, count, err := r.rowToItem(rows, true)
if err != nil {
return nil, -1, err
}
totalCount = count
results = append(results, parsed)
}
return results, totalCount, nil
}
func (r *UserRepository) addPageFilters(params UserPaginationParams, whereExpressions []goqu.Expression) []goqu.Expression {
if params.EmailFilter.Active {
whereExpressions = append(whereExpressions, goqu.Ex{
"email": goqu.Op{"like": fmt.Sprint("%", params.EmailFilter.Value, "%")},
})
}
if params.PasswordFilter.Active {
whereExpressions = append(whereExpressions, goqu.Ex{
"password": goqu.Op{"like": fmt.Sprint("%", params.PasswordFilter.Value, "%")},
})
}
return whereExpressions
}
func (r *UserRepository) jsonToString(jsonData any) string {
bytes, err := json.Marshal(jsonData)
if err != nil {
return "{}"
}
return string(bytes)
}
func (r *UserRepository) FirstLetterToUpper(name string) string {
return strings.ToUpper(name[:1]) + name[1:]
}
func (u *UserRepository) DoesUserEmailExist(email string) (bool, error) {
sql, args, _ := u.dialect.From("user").
Prepared(true).
Select(goqu.COUNT("email")).
Where(goqu.Ex{"email": email}).
ToSQL()
rows, err := u.connPool.Query(context.Background(), sql, args...)
if err != nil {
crud.LogError("failed to run sql query: %v", err)
return false, err
}
defer rows.Close()
if rows.Next() {
var count int
err = rows.Scan(&count)
if err != nil {
return false, err
}
return count == 1, nil
}
return false, nil
}
func (u *UserRepository) GetUserId(email string) (int, error) {
sql, args, _ := u.dialect.From("user").
Prepared(true).
Select("id").
Where(goqu.Ex{"email": email}).
ToSQL()
rows, err := u.connPool.Query(context.Background(), sql, args...)
if err != nil {
crud.LogError("failed to run sql query: %v", err)
return -1, err
}
defer rows.Close()
if rows.Next() {
var id int
err = rows.Scan(&id)
if err != nil {
return -1, err
}
return id, nil
}
return -1, nil
}
func (r *UserRepository) VerifyPassword(email string, password string) (bool, int, error) {
userId, err := r.GetUserId(email)
if err != nil {
return false, -1, err
}
user, err := r.Read(userId)
if err != nil {
return false, -1, err
}
return crud.VerifyPassword(password, user.Password), userId, nil
}

27
user/user_gen.go Normal file
View File

@ -0,0 +1,27 @@
package user
// AUTO GENERATED
// DO NOT EDIT
import (
"fmt"
"time"
)
type User struct {
Id int `db:"id"`
Email string `db:"email"`
Password string `db:"password"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
func (s *User) String() string {
return fmt.Sprint("User{ ",
"Id: ", s.Id, ", ",
"Email: ", s.Email, ", ",
"Password: ", s.Password, ", ",
"CreatedAt: ", s.CreatedAt, ", ",
"UpdatedAt: ", s.UpdatedAt, ", ",
"}")
}

123
views/base_gen.gohtml Normal file
View File

@ -0,0 +1,123 @@
{{define "base"}}
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ template "title" . }}</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="/static/htmx.js"></script>
<link href="/static/flowbite.min.css" rel="stylesheet"/>
</head>
{{/* https://www.reddit.com/r/htmx/comments/1blwnc4/tip_of_the_day_unobtrusive_global_loading/*/}}
<body hx-indicator=".loading-bar">
<style>
.loading-bar {
opacity: 0;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 4px;
background: linear-gradient(90deg, transparent,
#000, transparent,
#000, transparent
);
}
.htmx-request.loading-bar {
opacity: 1;
animation: fadeIn 2s linear forwards, slide 0.8s ease-in-out infinite;
}
@keyframes slide {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
@keyframes fadeIn {
0% {
opacity: 0;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}
</style>
<div class="loading-bar"></div>
<nav class="bg-white border-gray-200 dark:bg-gray-900">
<div class="flex flex-wrap justify-between items-center mx-auto max-w-screen-xl p-4">
<div class="flex items-center space-x-3 rtl:space-x-reverse">
<span class="self-center text-2xl font-semibold whitespace-nowrap dark:text-white">test - {{ template "title" . }}</span>
</div>
</div>
</nav>
<nav class="bg-gray-50 dark:bg-gray-700 lg:hidden">
<div class="max-w-screen-xl px-4 py-3 mx-auto">
<div class="flex items-center justify-between">
<button id="menu-toggle" class="text-gray-900 dark:text-white focus:outline-none" style="width: 200px">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6h16M4 12h16m-7 6h7"></path>
</svg>
</button>
</div>
<div id="menu" class="hidden mt-4">
<ul class="flex flex-col space-y-4 text-sm">
{{ template "navMenu" . }}
</ul>
</div>
</div>
</nav>
<script>
document.getElementById('menu-toggle').addEventListener('click', function () {
var menu = document.getElementById('menu');
menu.classList.toggle('hidden');
});
</script>
<nav class="bg-gray-50 dark:bg-gray-700 hidden lg:block">
<div class="max-w-screen-xl px-4 py-3 mx-auto">
<div class="flex items-center">
<ul class="flex flex-row font-medium mt-0 space-x-8 rtl:space-x-reverse text-sm">
{{ template "navMenu" . }}
</ul>
</div>
</div>
</nav>
<div class="lg:flex justify-center px-2 py-2 max-w-screen-2xl" style="width: 100vw;">
<div class="main">
{{ template "main" . }}
</div>
</div>
<script src="/static/flowbite.min.js"></script>
</body>
</html>
{{end}}
{{ define "navMenu"}}
<li>
<a href="/" class="text-gray-900 dark:text-white hover:underline" aria-current="page">Home</a>
</li>
<li>
<a href="/test" class="text-gray-900 dark:text-white hover:underline">Test</a>
</li>
{{end}}

View File

@ -0,0 +1,11 @@
{{ block "booleanInput" . }}
<label for="{{.Name}}"
{{template "labelStyle"}}>
{{.Label}}
</label>
<input type="checkbox" id="{{.Name}}"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
name="{{.Name}}"
{{ if eq .Value "true" }}checked{{end}}
/>
{{ end }}

View File

@ -0,0 +1,7 @@
{{ define "buttonStyle" }}
class="{{ template "buttonStyleClasses" }}"
{{end}}
{{ define "buttonStyleClasses" }}
text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800
{{end}}

View File

@ -0,0 +1,72 @@
{{- /*gotype: crud-generator/html_components.CropImage*/ -}}
{{ define "cropImage" }}
<div>
<img id="crop-image" src="{{.ImageUrl}}" alt="Image to crop"
style="max-width: 100%; display: block">
</div>
<div class="py-5">
<form
id="cropForm"
data-hx-encoding="multipart/form-data"
data-hx-target="#{{.ImageId}}-imageDisplay"
data-hx-push-url="false"
data-hx-put="{{ .SaveUrl}}"
>
<input type="file" name="image" id="imageInput" class="hidden">
<button type="submit" id="cropTrigger" class="hidden"></button>
<button id="save" {{template "buttonStyle"}} type="button"
>
Save
</button>
</form>
<button id="rotate" {{template "buttonStyle"}} type="button">Rotate</button>
<button {{template "buttonStyle"}} type="button"
data-hx-target="#{{.ImageId}}-imageDisplay"
data-hx-push-url="false"
data-hx-get="{{ .CancelUrl}}"
>Cancel
</button>
<progress id="cropProgress" value="0" max="100" style="width: 100%"></progress>
</div>
<script>
(() => {
const image = document.getElementById('crop-image');
const cropper = new Cropper(image, {
rotatable: true,
crop(event) {
},
});
document.getElementById('rotate').addEventListener('click', function () {
image.cropper.rotate(90);
});
htmx.on('#cropForm', 'htmx:xhr:progress', function (evt) {
let progressValue = evt.detail.loaded / evt.detail.total * 100;
htmx.find('#cropProgress').setAttribute('value', progressValue)
});
htmx.on('body', 'htmx:afterSwap', function (evt) {
if (evt.target.tagName.toLowerCase() !== 'body') {
return
}
document.querySelectorAll('.crop-image').forEach((img) => {
const originalSource = img.getAttribute('data-original-source')
img.src = originalSource + '&t=' + new Date().getTime()
});
});
document.getElementById('save').addEventListener('click', function (evt) {
evt.preventDefault()
const canvas = cropper.getCroppedCanvas();
canvas.toBlob((blob) => {
// https://stackoverflow.com/a/66466855
const file = new File([blob], 'image.jpeg', {type: 'image/jpeg'});
const container = new DataTransfer();
container.items.add(file);
document.getElementById('imageInput').files = container.files
document.getElementById('cropTrigger').click()
}, 'image/jpeg');
});
})()
</script>
{{end}}

View File

@ -0,0 +1,46 @@
{{- /*gotype: crud-generator/html_components.DateTimeInput*/ -}}
{{ block "dateTimeInput" . }}
<div class="pr-5">
<label for="{{.Name}}"
{{template "labelStyle"}}
>
{{.Label}}
</label>
<div class="flex">
<div class="relative max-w-sm">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400" aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor" viewBox="0 0 20 20">
<path d="M20 4a2 2 0 0 0-2-2h-2V1a1 1 0 0 0-2 0v1h-3V1a1 1 0 0 0-2 0v1H6V1a1 1 0 0 0-2 0v1H2a2 2 0 0 0-2 2v2h20V4ZM0 18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8H0v10Zm5-8h10a1 1 0 0 1 0 2H5a1 1 0 0 1 0-2Z"/>
</svg>
</div>
<input type="datetime-local"
name="{{ .Name }}"
value="{{ .Value }}"
id="{{ .Name}}"
aria-label="{{ .Label }}"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Select date and time">
</div>
<div class="px-4">
<button
id="{{.Name}}-clear"
class="flex items-center bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded"
type="button"
aria-label="Clear date and time"
>
Clear
</button>
</div>
</div>
</div>
<script>
(function () {
document.getElementById("{{.Name}}-clear").addEventListener("click", function () {
document.getElementById("{{.Name}}").value = "";
});
})();
</script>
{{ end }}

View File

@ -0,0 +1,40 @@
{{- /*gotype: assistant/components.Dropdown*/ -}}
{{ define "dropdown" }}
<button data-dropdown-toggle="{{.Id}}" id="{{.Id}}Trigger"
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
type="button">
{{ .Label}}
<svg class="w-2.5 h-2.5 ms-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 10 6">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="m1 1 4 4 4-4"/>
</svg>
</button>
<!-- Dropdown menu -->
<div id="{{.Id}}"
class="z-10 hidden bg-white divide-y divide-gray-100 rounded-lg shadow w-44 dark:bg-gray-700">
<ul class="py-2 text-sm text-gray-700 dark:text-gray-200" aria-labelledby="dropdownDefaultButton">
{{ range .Items}}
<li>
<a href="#"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
{{ if .IsPost}} hx-post="{{.Url}}" {{ else }} hx-get="{{.Url}}" {{ end }}
hx-target="{{.Target}}"
>
{{ .Label }}
</a>
</li>
{{ end }}
</ul>
</div>
<script>
(() => {
if ((typeof Dropdown) === 'undefined') {
return
}
const element = document.getElementById('{{.Id}}Trigger')
new Dropdown(document.getElementById(element.getAttribute('data-dropdown-toggle')), element);
})()
</script>
{{ end }}

View File

@ -0,0 +1,47 @@
{{- /*gotype: crud-generator/html_components.EmailLogin*/ -}}
{{define "emailLogin"}}
<div id="emailLogin">
<form data-hx-post="/login"
data-hx-target="#emailLogin"
>
<section class="bg-gray-50 dark:bg-gray-900">
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<div class="w-full bg-white rounded-lg shadow dark:border md:mt-0 sm:max-w-md xl:p-0 dark:bg-gray-800 dark:border-gray-700">
<div class="p-6 space-y-4 md:space-y-6 sm:p-8">
<h1 class="text-xl font-bold leading-tight tracking-tight text-gray-900 md:text-2xl dark:text-white">
Sign in to your account
</h1>
<form class="space-y-4 md:space-y-6" action="#">
<div>
<label for="email"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Your
email</label>
<input type="email" name="email" id="email"
class="bg-gray-50 border border-gray-300 text-gray-900 rounded-lg focus:ring-blue-600 focus:border-blue-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="name@company.com" required="">
</div>
<div>
<label for="password"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
<input type="password" name="password" id="password" placeholder="••••••••"
class="bg-gray-50 border border-gray-300 text-gray-900 rounded-lg focus:ring-blue-600 focus:border-blue-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
required="">
</div>
<button type="submit"
class="w-full text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
Sign in
</button>
</form>
</div>
</div>
</div>
{{ if .ShowError }}
<div class="p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-gray-800 dark:text-red-400"
role="alert">
<span class="font-medium">Error</span> {{.Error }}
</div>
{{ end }}
</section>
</form>
</div>
{{end}}

View File

@ -0,0 +1,11 @@
{{ block "floatInput" . }}
<label for="{{.Name}}"
{{template "labelStyle"}}>
{{.Label}}
</label>
<input type="number" id="{{.Name}}"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
step="any"
name="{{.Name}}"
value="{{.Value}}"/>
{{ end }}

View File

@ -0,0 +1,7 @@
{{ define "h1Style" }}
class="mb-4 text-4xl font-extrabold leading-none tracking-tight text-gray-900 md:text-5xl lg:text-6xl dark:text-white"
{{end}}
{{ define "h1StyleSmall"}}
class="mb-4 text-2xl font-extrabold leading-none tracking-tight text-gray-900 md:text-5xl lg:text-2xl dark:text-white"
{{end}}

View File

@ -0,0 +1,3 @@
{{ define "h2Style" }}
class="text-2xl font-bold dark:text-white"
{{end}}

View File

@ -0,0 +1,13 @@
{{- /*gotype: crud-generator/html_components.ImageDisplay*/ -}}
{{define "imageDisplay"}}
<a href="{{.Value}}">
<img src="{{.Value}}?width{{.Width}}&{{.TimestampParam}}" alt="Column image"
class="crop-image">
</a>
<button class="cropImageButton {{template "buttonStyleClasses"}}"
data-hx-get="{{.CropUrl}}"
data-hx-push-url="false"
data-hx-target="#{{.Label}}-imageDisplay"
>Crop
</button>
{{end}}

View File

@ -0,0 +1,28 @@
{{ block "imageInput" . }}
<label for="{{.Name}}"
{{template "labelStyle"}}>
{{.Label}}
</label>
<input type="file" id="{{.Name}}"
accept="image/png, image/jpeg"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
capture="user"
name="{{.Name}}"
value="{{.Value}}"/>
<label for="{{.Name}}-userInput">User Camera</label>
<input type="checkbox" id="{{.Name}}-userInput" checked>
<script>
(() => {
const input = document.getElementById('{{.Name}}')
const checkbox = document.getElementById('{{.Name}}-userInput')
checkbox.addEventListener('change', function () {
if (checkbox.checked) {
input.setAttribute('capture', 'user')
} else {
input.removeAttribute('capture')
}
})
})()
</script>
{{ end }}

View File

@ -0,0 +1,10 @@
{{ block "intInput" . }}
<label for="{{.Name}}"
{{template "labelStyle"}}>
{{.Label}}
</label>
<input type="number" id="{{.Name}}"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
name="{{.Name}}"
value="{{.Value}}"/>
{{ end }}

View File

@ -0,0 +1,3 @@
{{ define "labelStyle"}}
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
{{end}}

View File

@ -0,0 +1,3 @@
{{ define "linkStyle" }}
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
{{end}}

View File

@ -0,0 +1,21 @@
{{ block "paginationButton" . }}
{{ if .Enabled }}
<button class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
type="submit"
id="{{.Name}}"
>
{{.Label}}
</button>
<script>
document.getElementById("{{.Name}}").addEventListener("click", function () {
document.querySelector("input[name='page']").value = "{{.Page}}";
document.querySelector("form").submit();
});
</script>
{{ else }}
<button type="button"
class="text-white bg-blue-400 dark:bg-blue-500 cursor-not-allowed font-medium rounded-lg text-sm px-5 py-2.5 text-center"
disabled>{{.Label}}
</button>
{{end}}
{{ end }}

View File

@ -0,0 +1,12 @@
{{- /*gotype: assistant/components.SelectInput*/ -}}
{{ define "selectInput" }}
{{ if not .HideLabel}}<label for="{{.Name}}"
{{template "labelStyle"}}>{{.Label}}</label>{{end}}
<select id="{{.Name}}"
name="{{.Name}}"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
{{ range .Options}}
<option value="{{.Value}}" {{ if .Selected}}selected{{end}}>{{.Label}}</option>
{{end}}
</select>
{{end}}

View File

@ -0,0 +1,67 @@
{{- /*gotype: assistant/components.Accordion*/ -}}
{{ define "singleAccordion"}}
<div id="{{.Name}}-accordion" data-accordion="collapse">
<h2 id="{{.Name}}-collapse">
<button type="button" id="{{.Name}}-collapse-button"
class="flex items-center justify-between w-full p-5 font-medium rtl:text-right text-gray-500 border border-b-0 border-gray-200 rounded-t-xl focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-800 dark:border-gray-700 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 gap-3"
style="background-color: rgb(243 244 246);"
data-accordion-target="#{{.Name}}-collapse-target" aria-expanded="true"
aria-controls="{{.Name}}-collapse-target"
hx-get="{{ .Url }}"
hx-target="#{{.Name}}-content"
hx-trigger="{{.Name}}-trigger-event"
>
<span>Body</span>
<svg data-accordion-icon class="w-3 h-3 rotate-180 shrink-0" aria-hidden="true"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5 5 1 1 5"/>
</svg>
</button>
</h2>
<div id="{{.Name}}-collapse-target" class="hidden" aria-labelledby="{{.Name}}-collapse">
<div class="p-2 md:p-5 border border-b-0 border-gray-200 dark:border-gray-700 dark:bg-gray-900">
<p class="mb-2 text-gray-500 dark:text-gray-400" id="{{.Name}}-content">
</p>
</div>
</div>
</div>
<script>
(() => {
const name = '{{.Name}}'
function createBodyAccordion() {
let openedOnce = false
return new Accordion(document.getElementById(`${name}-accordion`), [
{
id: `${name}-collapse`,
triggerEl: document.getElementById(`${name}-collapse`),
targetEl: document.getElementById(`${name}-collapse-target`),
active: false
}
], {
onOpen: () => {
if (openedOnce) {
return
}
htmx.trigger(document.getElementById(`${name}-collapse-button`), `${name}-trigger-event`)
openedOnce = true
}
})
}
document.querySelector('body').onload = function () {
setTimeout(() => {
{{ if .IsClosed }}
document.getElementById(`${name}-collapse-button`).click()
{{ end }}
createBodyAccordion()
}, 1)
}
if ((typeof Accordion) === 'undefined') {
return
}
createBodyAccordion()
})()
</script>
{{ end }}

View File

@ -0,0 +1,14 @@
{{- /*gotype: assistant/components.Tabs*/ -}}
{{ define "tabs" }}
<ul class="flex flex-wrap text-sm font-medium text-center text-gray-500 border-b border-gray-200 dark:border-gray-700 dark:text-gray-400">
{{ range .Items }}
<li class="me-2">
<a href="{{.Url}}"
class="inline-block p-4 rounded-t-lg hover:text-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800 dark:hover:text-gray-300 {{ if .Selected}}text-blue-600 dark:text-blue-500 dark:bg-gray-80 bg-gray-100{{ end }}"
>
{{ .Label }}
</a>
</li>
{{ end }}
</ul>
{{ end}}

View File

@ -0,0 +1,10 @@
{{ block "textInput" . }}
<label for="{{.Name}}"
{{template "labelStyle"}}>
{{.Label}}
</label>
<input type="text" id="{{.Name}}"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
name="{{.Name}}"
value="{{.Value}}"/>
{{ end }}

View File

@ -0,0 +1,11 @@
{{ block "textareaInput" . }}
<label for="{{.Name}}"
{{template "labelStyle"}}>
{{.Label}}
</label>
<textarea id="{{.Name}}"
rows="4"
aria-label="{{.Label}}"
class="block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
name="{{.Name}}">{{.Value}}</textarea>
{{ end }}

View File

@ -0,0 +1,37 @@
{{- /*gotype: assistant/components.Toast*/ -}}
{{ define "toast" }}
<div class="toastComponent {{ .ToastPosition }} transition-opacity fixed flex items-center w-full max-w-xs p-4 space-x-4 text-gray-500 bg-white divide-x rtl:divide-x-reverse divide-gray-200 rounded-lg shadow dark:text-gray-400 dark:divide-gray-700 dark:bg-gray-800" role="alert">
<div class="text-sm font-normal">
{{ .Label }}
</div>
<div class="flex items-center ms-auto space-x-2 rtl:space-x-reverse">
<a class="toastClose text-sm font-medium text-blue-600 p-1.5 hover:bg-blue-100 rounded-lg dark:text-blue-500 dark:hover:bg-gray-700"
hx-post="{{ .Url }}" href="#"
{{ if .HasTarget }} hx-target="{{ .Target }}" {{ end }}
>{{.LinkLabel}}</a>
<button type="button" class="toastClose ms-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex items-center justify-center h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#archiveToast" aria-label="Close">
<span class="sr-only">Close</span>
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
</svg>
</button>
</div>
</div>
<script>
function hideToast() {
document.querySelectorAll('.toastComponent').forEach(function (element) {
element.style.display = 'none';
});
}
document.querySelectorAll('.toastClose').forEach(function (element) {
element.addEventListener('click', function () {
hideToast();
});
});
setTimeout(() => {
hideToast();
}, ({{or .TimeoutMs 5000}}));
</script>
{{ end }}

View File

@ -0,0 +1,15 @@
{{ define "crudDisplay"}}
{{ if not (or .IsTable .IsDisplay .IsEdit) }}
<div class="error">Invalid state: No display mode selected</div>
{{ end }}
{{ if .IsTable}}
{{ template "table" .Table }}
{{ end }}
{{ if .IsDisplay }}
{{ template "itemDisplay" .ItemDisplay }}
{{ end }}
{{ if .IsEdit }}
{{ template "editItem" .EditItem }}
{{ end }}
{{end}}

View File

@ -0,0 +1,108 @@
{{- /*gotype: crud-generator/html_components.EditItemModal*/ -}}
{{ define "editItemModal" }}
{{$modalId := "edit-item-modal"}}
<div id="{{$modalId}}" tabindex="-1" aria-hidden="true"
class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
<div class="relative p-4 w-full max-w-md max-h-full">
<!-- Modal content -->
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<!-- Modal header -->
<div class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{{.Title}}
</h3>
<button type="button"
class="modalClose text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"
data-modal-toggle="{{$modalId}}"
>
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
</svg>
<span class="sr-only">Close modal</span>
</button>
</div>
<!-- Modal body -->
<form class="p-4 md:p-5"
id="editItemModalForm"
{{ if .IsCreate}}
data-hx-post="{{ .Url}}"
data-hx-target="#tableDisplay"
{{ end }}
{{ if not .IsCreate}}
data-hx-put="{{ .Url}}"
data-hx-target="#row-{{.Id}}"
{{ end }}
data-hx-swap="outerHTML"
{{ if .HaseFileUpload}}
data-hx-encoding="multipart/form-data"
{{ end }}
>
{{ if .HaseFileUpload}}
<progress id="progress" value="0" max="100" style="width: 100%"></progress>
<script>
htmx.on('#editItemModalForm', 'htmx:xhr:progress', function(evt) {
let progressValue = evt.detail.loaded/evt.detail.total * 100;
if (evt.detail.total === 0 || progressValue === 100) {
const modal = new Modal(document.getElementById('{{$modalId}}'));
modal.hide();
return
}
htmx.find('#progress').setAttribute('value', progressValue)
});
</script>
{{ end }}
<div class="grid gap-4 mb-4 grid-cols-2">
<div class="col-span-2">
{{ range .Inputs }}
{{ if eq .Type "text" }}
{{ template "textInput" . }}
{{ end }}
{{ if eq .Type "int" }}
{{ template "intInput" . }}
{{ end }}
{{ if eq .Type "enum" }}
{{ template "selectInput" . }}
{{ end }}
{{ if eq .Type "dateTime" }}
{{ template "dateTimeInput" . }}
{{ end }}
{{ if eq .Type "bool" }}
{{ template "booleanInput" . }}
{{ end }}
{{ if eq .Type "image" }}
{{ template "imageInput" . }}
{{ end }}
{{ end }}
</div>
</div>
<button type="submit"
class="{{ if not .HaseFileUpload}}modalClose {{end}}text-white inline-flex items-center bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
>
{{ if .IsCreate }}
<svg class="me-1 -ms-1 w-5 h-5" fill="currentColor" viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd"
d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z"
clip-rule="evenodd"></path>
</svg>
{{ end }}
{{ .SubmitButtonLabel }}
</button>
</form>
</div>
</div>
</div>
<script>
(() => {
const modal = new Modal(document.getElementById('{{$modalId}}'));
document.querySelectorAll('.modalClose').forEach(function (element) {
element.addEventListener('click', function () {
modal.hide();
});
});
modal.show();
})()
</script>
{{ end }}

View File

@ -0,0 +1,94 @@
{{- /*gotype: crud-generator/html_components.EditItem*/ -}}
{{ define "editItem" }}
<h2>{{.Title}}</h2>
<form class="p-4 md:p-5"
id="editItemForm"
data-hx-target="body"
{{ if .IsCreate}}
data-hx-post="{{ .Url}}"
{{ end }}
{{ if not .IsCreate}}
data-hx-put="{{ .Url}}"
{{ end }}
data-hx-push-url="true"
{{ if .HasFileUpload}}
data-hx-encoding="multipart/form-data"
{{ end }}
{{ if .FromTable }}
data-hx-headers='{"From-Table": "true"}'
{{ end }}
>
{{ if .HasFileUpload}}
<label for="progress" class="sr-only">File upload progress</label>
<progress id="progress" value="0" max="100" style="width: 100%"
aria-label="File upload progress"
role="progressbar"></progress>
<script>
htmx.on('#editItemForm', 'htmx:xhr:progress', function (evt) {
let progressValue = evt.detail.loaded / evt.detail.total * 100;
htmx.find('#progress').setAttribute('value', progressValue)
});
</script>
{{ end }}
<div class="grid gap-4 mb-4 grid-cols-2">
<div class="col-span-2">
{{ range .Inputs }}
{{ if eq .Type "text" }}
{{ template "textInput" . }}
{{ end }}
{{ if eq .Type "textarea" }}
{{ template "textareaInput" . }}
{{ end }}
{{ if eq .Type "int" }}
{{ template "intInput" . }}
{{ end }}
{{ if eq .Type "float" }}
{{ template "floatInput" . }}
{{ end }}
{{ if eq .Type "enum" }}
{{ template "selectInput" . }}
{{ end }}
{{ if eq .Type "dateTime" }}
{{ template "dateTimeInput" . }}
{{ end }}
{{ if eq .Type "bool" }}
{{ template "booleanInput" . }}
{{ end }}
{{ if eq .Type "image" }}
{{ template "imageInput" . }}
{{ end }}
{{ end }}
</div>
</div>
<div class="flex flex-wrap">
<div>
<button type="submit"
class="text-white inline-flex items-center bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
>
{{ if .IsCreate }}
<svg class="me-1 -ms-1 w-5 h-5" fill="currentColor" viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd"
d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z"
clip-rule="evenodd"></path>
</svg>
{{ end }}
{{ .SubmitButtonLabel }}
</button>
</div>
<div class="px-5">
<button
type="button"
class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600"
id="cancelEditButton"
data-hx-get="{{.CancelUrl}}"
data-hx-push-url="true"
>
Cancel
</button>
</div>
</div>
</form>
{{ end }}

View File

@ -0,0 +1,87 @@
{{- /*gotype: crud-generator/html_components.ItemDisplay*/ -}}
{{ define "itemDisplay" }}
{{ if .HasImages}}
<script src="/static/cropper.min.js"></script>
<link href="/static/cropper.min.css" rel="stylesheet"/>
{{end}}
<div id="itemDisplay">
<button {{template "buttonStyle"}} id="backButton"
data-hx-get="{{.BackUrl}}"
data-hx-target="body"
data-hx-push-url="true"
>Back
</button>
<button {{template "buttonStyle"}}
data-hx-get="{{.EditItemUrl}}"
data-hx-target="body"
data-hx-push-url="true"
id="editButton">Edit
</button>
<button {{template "buttonStyle"}}
data-hx-delete="{{.DeleteItemUrl}}"
data-hx-target="body"
data-hx-push-url="{{.DeletePushUrl}}"
data-hx-confirm="Are you sure you want to delete this item?"
id="deleteButton">Delete
</button>
<dl class="max-w-md text-gray-900 divide-y divide-gray-200 dark:text-white dark:divide-gray-700">
{{ range .Columns}}
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-lg dark:text-gray-400">{{.Label}}</dt>
<dd class="text-lg font-semibold">
{{ if eq .Type "text"}}
{{.Value}}
{{ end }}
{{ if eq .Type "textarea"}}
<div id="{{.Label}}-text" class="whitespace-pre-line" aria-readonly="true">{{.Value}}</div>
<button type="button" {{template "buttonStyle"}}>Read More</button>
<script>
(() => {
// shorten the long text of label-text
const text = document.getElementById("{{.Label}}-text");
const innerText = text.innerText;
if (text.innerText.length > 100) {
const shortenedText = text.innerText.substring(0, 100) + "...";
let isShortened = true
text.innerText = shortenedText
const button = document.getElementById("{{.Label}}-text").nextElementSibling;
button.onclick = () => {
if (isShortened) {
text.innerText = innerText;
button.innerText = "Read Less";
isShortened = false;
} else {
text.innerText = text.innerText.substring(0, 100) + "...";
button.innerText = "Read More";
isShortened = true;
}
};
}
})()
</script>
{{ end }}
{{ if eq .Type "image"}}
<div id="{{.Label}}-imageDisplay">
{{template "imageDisplay" .}}
</div>
{{ end }}
{{ if eq .Type "datetime"}}
{{.Value}}
{{ end }}
</dd>
</div>
{{ end }}
{{ range .SubItems }}
<div>
<button {{template "buttonStyle"}}
data-hx-get="{{.Url}}"
data-hx-target="body"
data-hx-push-url="true"
>{{.Label}}</button>
</div>
{{ end }}
</dl>
</div>
{{end}}

View File

@ -0,0 +1,72 @@
{{- /*gotype: crud-generator/html_components.TableRow*/ -}}
{{ define "tableRow" }}
{{$rowId := .Id}}
{{$entityUrl := .EntityUrl}}
<tr id="row-{{.Id}}" class="border-b dark:border-gray-700">
{{ range .Columns }}
<td class="px-4 py-3 cursor-pointer"
data-hx-get="{{$entityUrl}}/{{$rowId}}"
data-hx-target="body"
data-hx-push-url="true">
{{ if eq .Type "text"}}
{{.Value}}
{{ end }}
{{ if eq .Type "image"}}
<img src="{{.Value}}?height=24" alt="Column image" style="max-height: 24px">
{{ end }}
</td>
{{ end }}
<td class="px-4 py-3 flex items-center justify-end">
<button id="{{.Id}}-dropdown-button"
aria-label="Row actions"
aria-expanded="false"
aria-controls="{{.Id}}-dropdown"
data-dropdown-toggle="{{.Id}}-dropdown"
class="rowDropdownTrigger inline-flex items-center p-0.5 text-sm font-medium text-center text-gray-500 hover:text-gray-800 rounded-lg focus:outline-none dark:text-gray-400 dark:hover:text-gray-100"
type="button">
<svg class="w-5 h-5" aria-hidden="true" fill="currentColor"
viewbox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path d="M6 10a2 2 0 11-4 0 2 2 0 014 0zM12 10a2 2 0 11-4 0 2 2 0 014 0zM16 12a2 2 0 100-4 2 2 0 000 4z"/>
</svg>
</button>
<div id="{{.Id}}-dropdown"
class="hidden z-10 w-44 bg-white rounded divide-y divide-gray-100 shadow dark:bg-gray-700 dark:divide-gray-600">
<ul class="py-1 text-sm text-gray-700 dark:text-gray-200"
aria-labelledby="apple-imac-27-dropdown-button">
<li>
<a href="#"
class="editItemLink block py-2 px-4 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
data-hx-get="{{.EditItemUrl}}"
data-hx-headers='{"From-Table": "true"}'
data-hx-target="body"
data-hx-push-url="true"
id="editItemLink-{{.Id}}"
>
Edit
</a>
</li>
</ul>
<div class="py-1">
<div
class="block py-2 px-4 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white cursor-pointer"
data-hx-delete="{{.DeleteItemUrl}}"
data-hx-confirm="Are you sure you want to delete this item?"
data-hx-target="#tableDisplay"
data-hx-trigger="click"
data-hx-push-url="false"
>
Delete
</div>
</div>
<script>
(() => {
if ((typeof Dropdown) === 'undefined') {
return
}
new Dropdown(document.getElementById('{{.Id}}-dropdown'), document.getElementById('{{.Id}}-dropdown-button'))
})()
</script>
</div>
</td>
</tr>
{{ end }}

144
views/crud/table.gohtml Normal file
View File

@ -0,0 +1,144 @@
{{- /*gotype: crud-generator/html_components.Table*/ -}}
{{ define "table" }}
<div id="tableDisplay" aria-live="polite">
{{$entityUrl := .EntityUrl}}
<div>
{{ if .ShowBack }}
<button {{template "buttonStyle"}}
data-hx-get="{{.BackUrl}}"
data-hx-target="body"
data-hx-push-url="true"
>Back
</button>
{{ end }}
</div>
<form data-hx-get="{{$entityUrl}}"
data-hx-target="body"
data-hx-swap="outerHTML"
data-hx-push-url="true"
data-hx-include="input, select"
>
<input type="hidden" name="orderBy" value="{{.OrderBy}}">
<input type="hidden" name="orderDirection" value="{{.OrderDirection}}">
<input type="hidden" name="pageNumber" value="{{.Pagination.Page}}">
<div id="itemTable" class="mx-auto max-w-screen-2xl sm:px-4 lg:px-12">
<!-- Start coding here -->
<div class="bg-white dark:bg-gray-800 relative shadow-md sm:rounded-lg overflow-hidden">
<div class="flex flex-col md:flex-row items-center justify-between space-y-3 md:space-y-0 md:space-x-4 p-4">
<div class="w-full md:w-auto flex flex-col md:flex-row space-y-2 md:space-y-0 items-stretch md:items-center justify-end md:space-x-3 flex-shrink-0">
<button type="button"
data-hx-get="{{.CreateItemUrl}}"
data-hx-target="body"
data-hx-push-url="false"
id="createItemButton"
class="flex items-center justify-center text-white bg-blue-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-primary-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-primary-800"
>
<svg class="h-3.5 w-3.5 mr-2" fill="currentColor" viewbox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path clip-rule="evenodd" fill-rule="evenodd"
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"/>
</svg>
Add
</button>
</div>
<div>
{{ template "selectInput" .FilterSelect}}
</div>
<label for="table-search" class="sr-only">Search</label>
<div class="relative">
<div class="absolute inset-y-0 rtl:inset-r-0 start-0 flex items-center ps-3 pointer-events-none">
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400" aria-hidden="true"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"/>
</svg>
</div>
<input id="filterValueInput" name="filterValue" value="{{.FilterValue}}" type="text"
class="block p-2 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg w-80 bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Filter input">
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
<tr>
{{ range .Headers}}
<th scope="col" class="px-4 py-3">
<a
href="#"
data-hx-get="{{$entityUrl}}?orderBy={{.}}&orderDirection={{if eq $.OrderDirection "desc"}}asc{{else}}desc{{end}}"
data-hx-params="not orderBy,orderDirection"
>
<div class="flex items-center">
{{.}}
<svg class="w-3 h-3 ms-1.5" aria-hidden="true"
xmlns="http://www.w3.org/2000/svg" fill="currentColor"
viewBox="0 0 24 24">
<path d="M8.574 11.024h6.852a2.075 2.075 0 0 0 1.847-1.086 1.9 1.9 0 0 0-.11-1.986L13.736 2.9a2.122 2.122 0 0 0-3.472 0L6.837 7.952a1.9 1.9 0 0 0-.11 1.986 2.074 2.074 0 0 0 1.847 1.086Zm6.852 1.952H8.574a2.072 2.072 0 0 0-1.847 1.087 1.9 1.9 0 0 0 .11 1.985l3.426 5.05a2.123 2.123 0 0 0 3.472 0l3.427-5.05a1.9 1.9 0 0 0 .11-1.985 2.074 2.074 0 0 0-1.846-1.087Z"/>
</svg>
</div>
</a>
</th>
{{end}}
<th scope="col" class="px-4 py-3">
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
{{ range .Rows }}
{{ template "tableRow" . }}
{{ end }}
</tbody>
</table>
<nav class="flex items-center flex-column flex-wrap md:flex-row justify-between pt-4 py-2 px-2"
aria-label="Table navigation">
<span class="text-sm font-normal text-gray-500 dark:text-gray-400 mb-4 md:mb-0 block w-full md:inline md:w-auto">Showing <span
class="font-semibold text-gray-900 dark:text-white">{{.Pagination.CurrenItemStart}}-{{.Pagination.CurrentItemEnd}}</span> of <span
class="font-semibold text-gray-900 dark:text-white">{{.Pagination.TotalNumberOfItems}}</span></span>
<ul class="inline-flex -space-x-px rtl:space-x-reverse text-sm h-8">
<li>
{{if .Pagination.PreviousDisabled}}
<div
class="{{ template "disabledPaginationButtonStyle"}} rounded-s-lg">
Previous
</div>
{{ else }}
<a href="#"
id="previous"
data-hx-get="{{$entityUrl}}?pageNumber={{.Pagination.PreviousPage}}"
data-hx-params="not pageNumber"
class="{{template "paginationButtonStyle"}} rounded-s-lg">Previous</a>
{{ end }}
</li>
<li>
{{ if .Pagination.NextDisabled }}
<div
class="{{ template "disabledPaginationButtonStyle"}} rounded-e-lg">
Next
</div>
{{ else }}
<a href="#"
id="next"
data-hx-get="{{$entityUrl}}?pageNumber={{.Pagination.NextPage}}"
data-hx-params="not pageNumber"
class="{{template "paginationButtonStyle"}} rounded-e-lg">Next</a>
{{ end }}
</li>
</ul>
</nav>
</div>
</div>
</div>
</form>
</div>
{{end}}
{{ define "disabledPaginationButtonStyle" }}
flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 bg-gray-300 border border-gray-300 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400
{{end}}
{{define "paginationButtonStyle"}}
flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white
{{end}}

14
views/index_gen.gohtml Normal file
View File

@ -0,0 +1,14 @@
{{ define "title" }}
Index
{{ end }}
{{ define "main" }}
<h1 class="mb-4 text-4xl font-extrabold leading-none tracking-tight text-gray-900 md:text-5xl lg:text-6xl dark:text-white">
Index
</h1>
<div>
<a href="/login">Login</a>
</div>
{{ end }}

14
views/login.gohtml Normal file
View File

@ -0,0 +1,14 @@
{{ define "title" }}
Login
{{ end }}
{{- /*gotype: assistant/login.LoginView*/ -}}
{{ define "main" }}
<h1 {{template "h1Style"}}>
Login
</h1>
<p>
{{template "emailLogin"}}
</p>
{{ end }}

12
views/test_gen.gohtml Normal file
View File

@ -0,0 +1,12 @@
{{ define "title" }}
Test
{{ end }}
{{ define "main" }}
<h1 class="mb-4 text-4xl font-extrabold leading-none tracking-tight text-gray-900 md:text-5xl lg:text-6xl dark:text-white">
Test
</h1>
{{ template "crudDisplay" .}}
{{ end }}