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