Initial commit
This commit is contained in:
commit
052f341ebf
40
Dockerfile
Normal file
40
Dockerfile
Normal 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
42
cli/create-user_gen.go
Normal 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
16
crud/hash.go
Normal 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
32
crud/logger.go
Normal 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
54
crud/postgres.go
Normal 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
40
go.mod
Normal 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
85
go.sum
Normal 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=
|
||||||
7
html_components/boolean-input.go
Normal file
7
html_components/boolean-input.go
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
package html_components
|
||||||
|
|
||||||
|
type BooleanInput struct {
|
||||||
|
Name string
|
||||||
|
Label string
|
||||||
|
Value string
|
||||||
|
}
|
||||||
8
html_components/crop-image.go
Normal file
8
html_components/crop-image.go
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
package html_components
|
||||||
|
|
||||||
|
type CropImage struct {
|
||||||
|
ImageUrl string
|
||||||
|
SaveUrl string
|
||||||
|
CancelUrl string
|
||||||
|
ImageId string
|
||||||
|
}
|
||||||
7
html_components/date-time-input.go
Normal file
7
html_components/date-time-input.go
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
package html_components
|
||||||
|
|
||||||
|
type DateTimeInput struct {
|
||||||
|
Name string
|
||||||
|
Label string
|
||||||
|
Value string
|
||||||
|
}
|
||||||
33
html_components/edit-item-modal.go
Normal file
33
html_components/edit-item-modal.go
Normal 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
|
||||||
|
}
|
||||||
33
html_components/edit-item.go
Normal file
33
html_components/edit-item.go
Normal 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
|
||||||
|
}
|
||||||
6
html_components/email-login.go
Normal file
6
html_components/email-login.go
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
package html_components
|
||||||
|
|
||||||
|
type EmailLogin struct {
|
||||||
|
ShowError bool
|
||||||
|
Error string
|
||||||
|
}
|
||||||
19
html_components/files.go
Normal file
19
html_components/files.go
Normal 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
|
||||||
|
}
|
||||||
11
html_components/image-display.go
Normal file
11
html_components/image-display.go
Normal 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
131
html_components/images.go
Normal 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
|
||||||
|
}
|
||||||
35
html_components/item-display.go
Normal file
35
html_components/item-display.go
Normal 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
5
html_components/login.go
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
package html_components
|
||||||
|
|
||||||
|
type Login struct {
|
||||||
|
GoogleLoginUrl string
|
||||||
|
}
|
||||||
70
html_components/parsing.go
Normal file
70
html_components/parsing.go
Normal 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")
|
||||||
|
}
|
||||||
40
html_components/render-go-html.go
Normal file
40
html_components/render-go-html.go
Normal 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"
|
||||||
|
}
|
||||||
26
html_components/select-input.go
Normal file
26
html_components/select-input.go
Normal 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
|
||||||
|
}
|
||||||
11
html_components/strings.go
Normal file
11
html_components/strings.go
Normal 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
|
||||||
|
}
|
||||||
169
html_components/table-prototype.go
Normal file
169
html_components/table-prototype.go
Normal 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
46
html_components/table.go
Normal 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
20
html_components/toast.go
Normal 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
49
html_components/url.go
Normal 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
81
main.go
Normal 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
37
schema.prisma
Normal 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
2
scripts/db-push.sh
Normal 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
9
static/cropper.min.css
vendored
Normal 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
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
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
2
static/flowbite.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
static/flowbite.min.js.map
Normal file
1
static/flowbite.min.js.map
Normal file
File diff suppressed because one or more lines are too long
3925
static/htmx.js
Normal file
3925
static/htmx.js
Normal file
File diff suppressed because it is too large
Load Diff
331
test/test-repository_gen.go
Normal file
331
test/test-repository_gen.go
Normal 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
400
test/test-rest-crud_gen.go
Normal 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
29
test/test_gen.go
Normal 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
97
user/user-login_gen.go
Normal 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
374
user/user-repository_gen.go
Normal 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
27
user/user_gen.go
Normal 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
123
views/base_gen.gohtml
Normal 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}}
|
||||||
11
views/components/boolean-input.gohtml
Normal file
11
views/components/boolean-input.gohtml
Normal 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 }}
|
||||||
7
views/components/button-style.gohtml
Normal file
7
views/components/button-style.gohtml
Normal 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}}
|
||||||
72
views/components/crop-image.gohtml
Normal file
72
views/components/crop-image.gohtml
Normal 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}}
|
||||||
46
views/components/date-time-input.gohtml
Normal file
46
views/components/date-time-input.gohtml
Normal 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 }}
|
||||||
40
views/components/dropdown.gohtml
Normal file
40
views/components/dropdown.gohtml
Normal 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 }}
|
||||||
47
views/components/email-login.gohtml
Normal file
47
views/components/email-login.gohtml
Normal 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}}
|
||||||
11
views/components/float-input.gohtml
Normal file
11
views/components/float-input.gohtml
Normal 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 }}
|
||||||
7
views/components/h1-style.gohtml
Normal file
7
views/components/h1-style.gohtml
Normal 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}}
|
||||||
3
views/components/h2-style.gohtml
Normal file
3
views/components/h2-style.gohtml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{{ define "h2Style" }}
|
||||||
|
class="text-2xl font-bold dark:text-white"
|
||||||
|
{{end}}
|
||||||
13
views/components/image-display.gohtml
Normal file
13
views/components/image-display.gohtml
Normal 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}}
|
||||||
28
views/components/image-input.gohtml
Normal file
28
views/components/image-input.gohtml
Normal 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 }}
|
||||||
10
views/components/int-input.gohtml
Normal file
10
views/components/int-input.gohtml
Normal 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 }}
|
||||||
3
views/components/label-style.gohtml
Normal file
3
views/components/label-style.gohtml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{{ define "labelStyle"}}
|
||||||
|
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
|
||||||
|
{{end}}
|
||||||
3
views/components/link-style.gohtml
Normal file
3
views/components/link-style.gohtml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{{ define "linkStyle" }}
|
||||||
|
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
|
||||||
|
{{end}}
|
||||||
21
views/components/pagination-button.gohtml
Normal file
21
views/components/pagination-button.gohtml
Normal 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 }}
|
||||||
12
views/components/select-input.gohtml
Normal file
12
views/components/select-input.gohtml
Normal 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}}
|
||||||
67
views/components/single-accordion.gohtml
Normal file
67
views/components/single-accordion.gohtml
Normal 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 }}
|
||||||
14
views/components/tabs.gohtml
Normal file
14
views/components/tabs.gohtml
Normal 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}}
|
||||||
10
views/components/text-input.gohtml
Normal file
10
views/components/text-input.gohtml
Normal 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 }}
|
||||||
11
views/components/textarea-input.gohtml
Normal file
11
views/components/textarea-input.gohtml
Normal 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 }}
|
||||||
37
views/components/toast.gohtml
Normal file
37
views/components/toast.gohtml
Normal 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 }}
|
||||||
15
views/crud/crud-display.gohtml
Normal file
15
views/crud/crud-display.gohtml
Normal 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}}
|
||||||
108
views/crud/edit-item-modal.gohtml
Normal file
108
views/crud/edit-item-modal.gohtml
Normal 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 }}
|
||||||
94
views/crud/edit-item.gohtml
Normal file
94
views/crud/edit-item.gohtml
Normal 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 }}
|
||||||
87
views/crud/item-display.gohtml
Normal file
87
views/crud/item-display.gohtml
Normal 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}}
|
||||||
72
views/crud/table-row.gohtml
Normal file
72
views/crud/table-row.gohtml
Normal 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
144
views/crud/table.gohtml
Normal 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
14
views/index_gen.gohtml
Normal 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
14
views/login.gohtml
Normal 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
12
views/test_gen.gohtml
Normal 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 }}
|
||||||
Loading…
Reference in New Issue
Block a user