Merge pull request #2 from Skrazzo/User-authentication
User authentication complete
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
data
|
||||
@@ -7,6 +7,9 @@ WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# ---- Production mode ----
|
||||
FROM base AS prod
|
||||
|
||||
# Copy code, and compile
|
||||
COPY . .
|
||||
RUN go build -o server main.go
|
||||
@@ -17,3 +20,15 @@ EXPOSE 8080
|
||||
# Run server binary on start
|
||||
CMD ["./server"]
|
||||
|
||||
# ---- Development mode ----
|
||||
FROM base AS dev
|
||||
|
||||
# Install air for hot reloading
|
||||
RUN go install github.com/air-verse/air@latest
|
||||
|
||||
# Copy code, and expose port
|
||||
COPY . .
|
||||
EXPOSE 8080
|
||||
|
||||
# Enable hot reloading for go
|
||||
CMD [ "air" ]
|
||||
|
||||
@@ -30,6 +30,9 @@ backend/
|
||||
├── models/ # Data models, structs, db access
|
||||
│ ├── user.go
|
||||
│ └── coverletter.go
|
||||
├── db/ # Database connections / migrations
|
||||
│ ├── db.go
|
||||
│ └── migrations.sql
|
||||
├── services/ # Business logic (e.g. OpenAI integration)
|
||||
│ ├── auth_service.go
|
||||
│ └── coverletter_service.go
|
||||
|
||||
52
backend/.air.toml
Normal file
52
backend/.air.toml
Normal file
@@ -0,0 +1,52 @@
|
||||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
args_bin = []
|
||||
bin = "./tmp/main"
|
||||
cmd = "go build -o ./tmp/main ."
|
||||
delay = 1000
|
||||
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = ""
|
||||
include_dir = []
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
include_file = []
|
||||
kill_delay = "0s"
|
||||
log = "build-errors.log"
|
||||
poll = false
|
||||
poll_interval = 0
|
||||
post_cmd = []
|
||||
pre_cmd = []
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = false
|
||||
stop_on_error = false
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
silent = false
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
|
||||
[proxy]
|
||||
app_port = 0
|
||||
enabled = false
|
||||
proxy_port = 0
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = false
|
||||
keep_scroll = true
|
||||
2
backend/.env-example
Normal file
2
backend/.env-example
Normal file
@@ -0,0 +1,2 @@
|
||||
# This is secret key for jwt signature
|
||||
JWT_SECRET=just a random string here
|
||||
2
backend/.gitignore
vendored
Normal file
2
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
tmp
|
||||
.env
|
||||
@@ -9,12 +9,14 @@ func defaultValue(val string, def string) string {
|
||||
return val
|
||||
}
|
||||
|
||||
func LoadEnv() map[string]string {
|
||||
var Env map[string]string
|
||||
|
||||
func LoadEnv() {
|
||||
// Create object where to store used variables
|
||||
env := make(map[string]string, 1)
|
||||
Env = make(map[string]string)
|
||||
|
||||
// Get env variables that will be used while server is running
|
||||
env["port"] = defaultValue(os.Getenv("PORT"), "8080")
|
||||
|
||||
return env
|
||||
Env["db"] = defaultValue(os.Getenv("POSTGRES_DB"), "postgresql://postgres:postgres@db:5432/cover-letter")
|
||||
Env["JWT_SECRET"] = defaultValue(os.Getenv("JWT_SECRET"), "just a random string here")
|
||||
Env["Environment"] = defaultValue(os.Getenv("Environment"), "dev")
|
||||
}
|
||||
|
||||
147
backend/controllers/user/user.go
Normal file
147
backend/controllers/user/user.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"backend/config"
|
||||
"backend/models/user"
|
||||
"backend/utils/hash"
|
||||
"backend/utils/jwt"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
JWT "github.com/golang-jwt/jwt"
|
||||
|
||||
res "backend/utils/responses"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
type RegisterForm struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Name string `json:"name" validate:"required,min=2,max=50"`
|
||||
Password string `json:"password" validate:"required,min=8"`
|
||||
RepeatPassword string `json:"repeatPassword" validate:"required,min=8,eqfield=Password"`
|
||||
}
|
||||
|
||||
func Register(c *gin.Context) {
|
||||
// Receive data from frontend, check if data is okay, hash password, call model
|
||||
var data RegisterForm
|
||||
if err := c.ShouldBindJSON(&data); err != nil {
|
||||
res.Error(c, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate data
|
||||
validate := validator.New()
|
||||
if err := validate.Struct(data); err != nil {
|
||||
// Handle error
|
||||
res.Error(c, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Hash password
|
||||
hash, err := hash.HashPassword(data.Password)
|
||||
if err != nil {
|
||||
res.Error(c, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Insert into database
|
||||
if err := user.Create(data.Email, data.Name, hash); err != nil {
|
||||
// Find out postgres error
|
||||
var pgErr *pgconn.PgError
|
||||
if !errors.As(err, &pgErr) {
|
||||
// Unknown error
|
||||
res.Error(c, fmt.Sprintf("[UNEXPECTED DB ERROR] %v", err.Error()), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Postgres error
|
||||
log.Printf("[ERROR] Postgres code: %s", pgErr.Code)
|
||||
if pgErr.Code == "23505" {
|
||||
// UNIQUE constraint violation (EMAIL TAKEN)
|
||||
res.Error(c, "Email already exists", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Unknown error
|
||||
res.Error(c, fmt.Sprintf("[UNKNOWN ERROR] %v", err.Error()), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return success
|
||||
res.Success(c, gin.H{
|
||||
"message": "Successfully registered",
|
||||
})
|
||||
}
|
||||
|
||||
type LoginForm struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Password string `json:"password" validate:"required"`
|
||||
}
|
||||
|
||||
func Login(c *gin.Context) {
|
||||
// Bind data
|
||||
var data LoginForm
|
||||
if err := c.ShouldBindJSON(&data); err != nil {
|
||||
res.Error(c, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate data
|
||||
validate := validator.New()
|
||||
if err := validate.Struct(data); err != nil {
|
||||
res.Error(c, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Find user in database
|
||||
user, err := user.FindByEmail(data.Email)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
// Check if pg err
|
||||
if errors.As(err, &pgErr) {
|
||||
res.Error(c, pgErr.Message, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Email not found
|
||||
res.Error(c, "Email or password are incorrect", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Check hash
|
||||
match := hash.CheckPasswordHash(data.Password, user.Password)
|
||||
if !match {
|
||||
res.Error(c, "Email or password are incorrect", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate JWT token, and send to client
|
||||
signedToken, err := jwt.GenerateJWT(user)
|
||||
if err != nil {
|
||||
res.Error(c, fmt.Sprintf("[JWT Generation] %s", err.Error()), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return token as cookie
|
||||
secureCookie := config.Env["Environment"] != "dev" // In dev environment cookie wont be secure
|
||||
// 3600S -> 1H * 24H -> 1D * 7 -> 1W
|
||||
c.SetCookie("jwt-token", signedToken, 3600*24*7, "/", "localhost", secureCookie, true)
|
||||
|
||||
// Return successful login
|
||||
res.Success(c, gin.H{"message": "Successfully logged in"})
|
||||
}
|
||||
|
||||
// Returns info from token middleware
|
||||
func TokenInfo(c *gin.Context) {
|
||||
user := c.MustGet("user").(JWT.MapClaims)
|
||||
res.Success(c, gin.H{
|
||||
"id": user["id"],
|
||||
"name": user["name"],
|
||||
"email": user["email"],
|
||||
})
|
||||
}
|
||||
59
backend/db/db.go
Normal file
59
backend/db/db.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var Pool *pgxpool.Pool
|
||||
|
||||
func Connect(url string) error {
|
||||
// Create context timeout, if connection takes more than 5 seconds, it will be canceled automatically
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
// Always cancel timeout to clean up resources
|
||||
defer cancel()
|
||||
|
||||
// Open new connection with database url
|
||||
var err error
|
||||
Pool, err = pgxpool.New(ctx, url)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] Failed to connect to DB: %v\n", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Ping database to check if its online
|
||||
err = Pool.Ping(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] Failed to ping DB: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Connection successful
|
||||
log.Printf("[INFO] Connected to DB: %v\n", url)
|
||||
return nil
|
||||
}
|
||||
|
||||
//go:embed migrations.sql
|
||||
var migrationFS embed.FS
|
||||
|
||||
func Migrate() error {
|
||||
// Read file from embed
|
||||
sql, err := migrationFS.ReadFile("migrations.sql")
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] Failed to read migrations.sql: %v\n", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Execute sql file into database
|
||||
_, err = Pool.Exec(context.Background(), string(sql))
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] Failed to migrate DB: %v\n", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
7
backend/db/migrations.sql
Normal file
7
backend/db/migrations.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -2,7 +2,13 @@ module backend
|
||||
|
||||
go 1.24.4
|
||||
|
||||
require github.com/gin-gonic/gin v1.10.1
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/go-playground/validator/v10 v10.20.0
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible
|
||||
github.com/jackc/pgx/v5 v5.7.5
|
||||
golang.org/x/crypto v0.37.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
@@ -13,22 +19,26 @@ require (
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/goccy/go-json v0.10.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/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.23.0 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
golang.org/x/text v0.15.0 // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
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=
|
||||
@@ -25,15 +26,29 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
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/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
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.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
|
||||
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
@@ -47,6 +62,8 @@ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
@@ -66,22 +83,25 @@ github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
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=
|
||||
|
||||
@@ -2,18 +2,32 @@ package main
|
||||
|
||||
import (
|
||||
"backend/config"
|
||||
"backend/db"
|
||||
"backend/routes"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load env variables
|
||||
env := config.LoadEnv()
|
||||
config.LoadEnv()
|
||||
|
||||
// Connect to database
|
||||
err := db.Connect(config.Env["db"])
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Migrate database if needed
|
||||
err = db.Migrate()
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Setup routes
|
||||
routes := routes.SetupRoutes()
|
||||
|
||||
// Listen on port smth
|
||||
log.Printf("Starting server on %s PORT\n", env["port"])
|
||||
log.Printf("Starting server ...")
|
||||
log.Fatal(routes.Run(":8080"))
|
||||
}
|
||||
|
||||
36
backend/middleware/middleware.go
Normal file
36
backend/middleware/middleware.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"backend/utils/jwt"
|
||||
res "backend/utils/responses"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func IsAuthenticated() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Get token from cookie
|
||||
token, err := c.Cookie("jwt-token")
|
||||
if err != nil {
|
||||
res.NeedsToLogin(c)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse and check token
|
||||
parsed, err := jwt.ParseJWT(token)
|
||||
if err != nil {
|
||||
// Fail the request
|
||||
res.NeedsToLogin(c)
|
||||
return
|
||||
}
|
||||
|
||||
// Set user in context
|
||||
c.Set("user", parsed)
|
||||
|
||||
// Execute next middleware/request
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Implement middleware for accessing your specific template
|
||||
// Check if template exists, and is user template (type shi)
|
||||
49
backend/models/user/user.go
Normal file
49
backend/models/user/user.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"backend/db"
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
Id int
|
||||
Email string
|
||||
Name string
|
||||
Password string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func Create(email string, name string, hash string) error {
|
||||
// Generate background context for managing timeouts and disconnections
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Build database query
|
||||
query := `INSERT INTO users (email, name, password) VALUES ($1, $2, $3)`
|
||||
_, err := db.Pool.Exec(ctx, query, strings.ToLower(email), name, hash)
|
||||
|
||||
// Return error if any
|
||||
return err
|
||||
}
|
||||
|
||||
// Symbol * will follow the pointer to its value
|
||||
func FindByEmail(email string) (*User, error) {
|
||||
// bg context
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Build query
|
||||
query := `SELECT * FROM users WHERE email = $1`
|
||||
row := db.Pool.QueryRow(ctx, query, strings.ToLower(email))
|
||||
|
||||
// Scan will map rows to User struct
|
||||
var u User
|
||||
if err := row.Scan(&u.Id, &u.Email, &u.Name, &u.Password, &u.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// give pointer
|
||||
return &u, nil
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"backend/controllers/user"
|
||||
"backend/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -9,11 +10,15 @@ import (
|
||||
func SetupRoutes() *gin.Engine {
|
||||
r := gin.Default()
|
||||
|
||||
r.GET("/ping", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "pong",
|
||||
})
|
||||
})
|
||||
// Guest routes (Register, Login, check auth)
|
||||
r.POST("/register", user.Register)
|
||||
r.POST("/login", user.Login)
|
||||
|
||||
// Authenticated routes middleware/group
|
||||
auth := r.Group("/")
|
||||
auth.Use(middleware.IsAuthenticated())
|
||||
|
||||
auth.GET("/info", user.TokenInfo) // Route to check if user is authenticated
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
13
backend/utils/hash/hash.go
Normal file
13
backend/utils/hash/hash.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package hash
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
func CheckPasswordHash(password, hash string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
55
backend/utils/jwt/jwt.go
Normal file
55
backend/utils/jwt/jwt.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"backend/config"
|
||||
"backend/models/user"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt"
|
||||
)
|
||||
|
||||
func GenerateJWT(u *user.User) (string, error) {
|
||||
// Generate JWT token
|
||||
mySigningKey := []byte(config.Env["JWT_SECRET"])
|
||||
token := jwt.New(jwt.SigningMethodHS256)
|
||||
|
||||
// Add claims (Values)
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
claims["id"] = u.Id
|
||||
claims["name"] = u.Name
|
||||
claims["email"] = u.Email
|
||||
claims["exp"] = time.Now().Add(time.Hour * 24 * 7).Unix() // Expire in 7 days
|
||||
|
||||
// Generate signed token
|
||||
tokenString, err := token.SignedString(mySigningKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return tokenString, nil
|
||||
}
|
||||
|
||||
func ParseJWT(tokenString string) (jwt.MapClaims, error) {
|
||||
mySigningKey := []byte(config.Env["JWT_SECRET"])
|
||||
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
|
||||
return mySigningKey, nil
|
||||
})
|
||||
// Check token parsing errors
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If good values then return
|
||||
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// Return on invalid token
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
31
backend/utils/responses/responses.go
Normal file
31
backend/utils/responses/responses.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package responses
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Success(c *gin.Context, data gin.H) {
|
||||
// Return success to api
|
||||
c.JSON(200, gin.H{
|
||||
"success": true,
|
||||
"data": data,
|
||||
})
|
||||
}
|
||||
|
||||
func Error(c *gin.Context, err string, code int) {
|
||||
// Return error to api
|
||||
c.AbortWithStatusJSON(code, gin.H{
|
||||
"success": false,
|
||||
"error": err,
|
||||
})
|
||||
}
|
||||
|
||||
func NeedsToLogin(c *gin.Context) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"error": "Authentication required",
|
||||
"needsAuthentication": true, // only appears in this error
|
||||
})
|
||||
}
|
||||
@@ -3,14 +3,26 @@ services:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: ../Dockerfile.backend
|
||||
target: dev # Development mode with hot reload
|
||||
restart: unless-stopped
|
||||
container_name: cover-letter-backend
|
||||
|
||||
volumes:
|
||||
- "./backend:/app"
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
environment:
|
||||
- GIN_MODE=debug
|
||||
# - GIN_MODE=release # For production
|
||||
- GIN_MODE=debug
|
||||
# - POSTGRES_DB=postgresql://username:password@host:port/database_name
|
||||
- POSTGRES_DB=postgresql://postgres:postgres@db:5432/cover-letter
|
||||
# - Environment=prod # For production
|
||||
- Environment=dev
|
||||
networks:
|
||||
- cover-letter-network
|
||||
depends_on:
|
||||
db: # Wait for database to be ready (Pass healthcheck)
|
||||
condition: service_healthy
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
@@ -27,13 +39,38 @@ services:
|
||||
proxy:
|
||||
image: caddy:alpine
|
||||
restart: unless-stopped
|
||||
container_name: cover-letter-proxy
|
||||
|
||||
networks:
|
||||
- cover-letter-network
|
||||
volumes:
|
||||
- ./caddy/Caddyfile:/etc/caddy/Caddyfile
|
||||
ports:
|
||||
- 8080:8080
|
||||
- 8000:8080
|
||||
depends_on:
|
||||
- frontend
|
||||
- backend
|
||||
db:
|
||||
image: postgres:13.21-alpine3.22
|
||||
restart: unless-stopped
|
||||
container_name: cover-letter-db
|
||||
|
||||
networks:
|
||||
- cover-letter-network
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
# - POSTGRES_HOST_AUTH_METHOD=trust # No password needed
|
||||
- POSTGRES_DB=cover-letter
|
||||
ports:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- ./data/db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 2s
|
||||
timeout: 2s
|
||||
retries: 5
|
||||
|
||||
networks:
|
||||
cover-letter-network:
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
{
|
||||
"projectName": ".",
|
||||
"mode": "file-router",
|
||||
"typescript": true,
|
||||
"tailwind": true,
|
||||
"packageManager": "bun",
|
||||
"git": true,
|
||||
"version": 1,
|
||||
"framework": "react-cra",
|
||||
"chosenAddOns": [
|
||||
"form",
|
||||
"tanstack-query"
|
||||
]
|
||||
}
|
||||
"projectName": ".",
|
||||
"mode": "file-router",
|
||||
"typescript": true,
|
||||
"tailwind": true,
|
||||
"packageManager": "bun",
|
||||
"git": true,
|
||||
"version": 1,
|
||||
"framework": "react-cra",
|
||||
"chosenAddOns": ["form", "tanstack-query"]
|
||||
}
|
||||
|
||||
9
frontend/.prettierrc.mjs
Normal file
9
frontend/.prettierrc.mjs
Normal file
@@ -0,0 +1,9 @@
|
||||
const config = {
|
||||
trailingComma: "es5",
|
||||
printWidth: 110,
|
||||
tabWidth: 4,
|
||||
semi: true,
|
||||
singleQuote: false,
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,4 +1,4 @@
|
||||
Welcome to your new TanStack app!
|
||||
Welcome to your new TanStack app!
|
||||
|
||||
# Getting Started
|
||||
|
||||
@@ -6,7 +6,7 @@ To run this application:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bunx --bun run start
|
||||
bunx --bun run start
|
||||
```
|
||||
|
||||
# Building For Production
|
||||
@@ -29,10 +29,8 @@ bunx --bun run test
|
||||
|
||||
This project uses [Tailwind CSS](https://tailwindcss.com/) for styling.
|
||||
|
||||
|
||||
|
||||
|
||||
## Routing
|
||||
|
||||
This project uses [TanStack Router](https://tanstack.com/router). The initial setup is a file based router. Which means that the routes are managed as files in `src/routes`.
|
||||
|
||||
### Adding A Route
|
||||
@@ -68,32 +66,31 @@ In the File Based Routing setup the layout is located in `src/routes/__root.tsx`
|
||||
Here is an example layout that includes a header:
|
||||
|
||||
```tsx
|
||||
import { Outlet, createRootRoute } from '@tanstack/react-router'
|
||||
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
|
||||
import { Outlet, createRootRoute } from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: () => (
|
||||
<>
|
||||
<header>
|
||||
<nav>
|
||||
<Link to="/">Home</Link>
|
||||
<Link to="/about">About</Link>
|
||||
</nav>
|
||||
</header>
|
||||
<Outlet />
|
||||
<TanStackRouterDevtools />
|
||||
</>
|
||||
),
|
||||
})
|
||||
component: () => (
|
||||
<>
|
||||
<header>
|
||||
<nav>
|
||||
<Link to="/">Home</Link>
|
||||
<Link to="/about">About</Link>
|
||||
</nav>
|
||||
</header>
|
||||
<Outlet />
|
||||
<TanStackRouterDevtools />
|
||||
</>
|
||||
),
|
||||
});
|
||||
```
|
||||
|
||||
The `<TanStackRouterDevtools />` component is not required so you can remove it if you don't want it in your layout.
|
||||
|
||||
More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts).
|
||||
|
||||
|
||||
## Data Fetching
|
||||
|
||||
There are multiple ways to fetch data in your application. You can use TanStack Query to fetch data from a server. But you can also use the `loader` functionality built into TanStack Router to load the data for a route before it's rendered.
|
||||
@@ -102,26 +99,26 @@ For example:
|
||||
|
||||
```tsx
|
||||
const peopleRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/people",
|
||||
loader: async () => {
|
||||
const response = await fetch("https://swapi.dev/api/people");
|
||||
return response.json() as Promise<{
|
||||
results: {
|
||||
name: string;
|
||||
}[];
|
||||
}>;
|
||||
},
|
||||
component: () => {
|
||||
const data = peopleRoute.useLoaderData();
|
||||
return (
|
||||
<ul>
|
||||
{data.results.map((person) => (
|
||||
<li key={person.name}>{person.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/people",
|
||||
loader: async () => {
|
||||
const response = await fetch("https://swapi.dev/api/people");
|
||||
return response.json() as Promise<{
|
||||
results: {
|
||||
name: string;
|
||||
}[];
|
||||
}>;
|
||||
},
|
||||
component: () => {
|
||||
const data = peopleRoute.useLoaderData();
|
||||
return (
|
||||
<ul>
|
||||
{data.results.map((person) => (
|
||||
<li key={person.name}>{person.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
@@ -149,13 +146,13 @@ const queryClient = new QueryClient();
|
||||
// ...
|
||||
|
||||
if (!rootElement.innerHTML) {
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -165,13 +162,13 @@ You can also add TanStack Query Devtools to the root route (optional).
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
<>
|
||||
<Outlet />
|
||||
<ReactQueryDevtools buttonPosition="top-right" />
|
||||
<TanStackRouterDevtools />
|
||||
</>
|
||||
),
|
||||
component: () => (
|
||||
<>
|
||||
<Outlet />
|
||||
<ReactQueryDevtools buttonPosition="top-right" />
|
||||
<TanStackRouterDevtools />
|
||||
</>
|
||||
),
|
||||
});
|
||||
```
|
||||
|
||||
@@ -183,24 +180,24 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import "./App.css";
|
||||
|
||||
function App() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ["people"],
|
||||
queryFn: () =>
|
||||
fetch("https://swapi.dev/api/people")
|
||||
.then((res) => res.json())
|
||||
.then((data) => data.results as { name: string }[]),
|
||||
initialData: [],
|
||||
});
|
||||
const { data } = useQuery({
|
||||
queryKey: ["people"],
|
||||
queryFn: () =>
|
||||
fetch("https://swapi.dev/api/people")
|
||||
.then((res) => res.json())
|
||||
.then((data) => data.results as { name: string }[]),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ul>
|
||||
{data.map((person) => (
|
||||
<li key={person.name}>{person.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<ul>
|
||||
{data.map((person) => (
|
||||
<li key={person.name}>{person.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -228,14 +225,12 @@ import "./App.css";
|
||||
const countStore = new Store(0);
|
||||
|
||||
function App() {
|
||||
const count = useStore(countStore);
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => countStore.setState((n) => n + 1)}>
|
||||
Increment - {count}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
const count = useStore(countStore);
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => countStore.setState((n) => n + 1)}>Increment - {count}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -253,23 +248,21 @@ import "./App.css";
|
||||
const countStore = new Store(0);
|
||||
|
||||
const doubledStore = new Derived({
|
||||
fn: () => countStore.state * 2,
|
||||
deps: [countStore],
|
||||
fn: () => countStore.state * 2,
|
||||
deps: [countStore],
|
||||
});
|
||||
doubledStore.mount();
|
||||
|
||||
function App() {
|
||||
const count = useStore(countStore);
|
||||
const doubledCount = useStore(doubledStore);
|
||||
const count = useStore(countStore);
|
||||
const doubledCount = useStore(doubledStore);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => countStore.setState((n) => n + 1)}>
|
||||
Increment - {count}
|
||||
</button>
|
||||
<div>Doubled - {doubledCount}</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => countStore.setState((n) => n + 1)}>Increment - {count}</button>
|
||||
<div>Doubled - {doubledCount}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"": {
|
||||
"name": ".",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@tailwindcss/vite": "^4.0.6",
|
||||
"@tanstack/react-form": "^1.0.0",
|
||||
"@tanstack/react-query": "^5.66.5",
|
||||
@@ -11,8 +12,13 @@
|
||||
"@tanstack/react-router": "^1.121.2",
|
||||
"@tanstack/react-router-devtools": "^1.121.2",
|
||||
"@tanstack/router-plugin": "^1.121.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.525.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.0.6",
|
||||
"zod": "^3.24.2",
|
||||
},
|
||||
@@ -23,6 +29,8 @@
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"jsdom": "^26.0.0",
|
||||
"prettier": "^3.6.2",
|
||||
"tw-animate-css": "^1.3.5",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.1.0",
|
||||
"vitest": "^3.0.5",
|
||||
@@ -167,6 +175,10 @@
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="],
|
||||
|
||||
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
|
||||
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.19", "", {}, "sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.44.1", "", { "os": "android", "cpu": "arm" }, "sha512-JAcBr1+fgqx20m7Fwe1DxPUl/hPkee6jA6Pl7n1v2EFiktAHenTaXl5aIFjUIEsfn9w3HE4gK1lEgNGMzBDs1w=="],
|
||||
@@ -353,6 +365,8 @@
|
||||
|
||||
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||
|
||||
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
@@ -479,6 +493,8 @@
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"lucide-react": ["lucide-react@0.525.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ=="],
|
||||
|
||||
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
|
||||
@@ -521,6 +537,8 @@
|
||||
|
||||
"react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="],
|
||||
|
||||
"react-hot-toast": ["react-hot-toast@2.5.2", "", { "dependencies": { "csstype": "^3.1.3", "goober": "^2.1.16" }, "peerDependencies": { "react": ">=16", "react-dom": ">=16" } }, "sha512-Tun3BbCxzmXXM7C+NI4qiv6lT0uwGh4oAfeJyNOjYUejTsm35mK9iCaYLGv8cBz9L5YxZLx/2ii7zsIwPtPUdw=="],
|
||||
|
||||
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||
@@ -565,6 +583,8 @@
|
||||
|
||||
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="],
|
||||
|
||||
"tapable": ["tapable@2.2.2", "", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="],
|
||||
@@ -601,6 +621,8 @@
|
||||
|
||||
"tsx": ["tsx@4.20.3", "", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ=="],
|
||||
|
||||
"tw-animate-css": ["tw-animate-css@1.3.5", "", {}, "sha512-t3u+0YNoloIhj1mMXs779P6MO9q3p3mvGn4k1n3nJPqJw/glZcuijG2qTSN4z4mgNRfW5ZC3aXJFLwDtiipZXA=="],
|
||||
|
||||
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
||||
|
||||
"unplugin": ["unplugin@2.3.5", "", { "dependencies": { "acorn": "^8.14.1", "picomatch": "^4.0.2", "webpack-virtual-modules": "^0.6.2" } }, "sha512-RyWSb5AHmGtjjNQ6gIlA67sHOsWpsbWpwDokLwTcejVdOjEkJZh7QKu14J00gDDVSh8kGH4KYC/TNBceXFZhtw=="],
|
||||
|
||||
21
frontend/components.json
Normal file
21
frontend/components.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/styles.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -1,20 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-tsrouter-app"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>Create TanStack App - .</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="Web site created using create-tsrouter-app" />
|
||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>Cover letter templater</title>
|
||||
</head>
|
||||
<body class="bg-body">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,37 +1,44 @@
|
||||
{
|
||||
"name": "Cover letter frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 3000 --host",
|
||||
"build": "vite build && tsc",
|
||||
"serve": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.0.6",
|
||||
"@tanstack/react-form": "^1.0.0",
|
||||
"@tanstack/react-query": "^5.66.5",
|
||||
"@tanstack/react-query-devtools": "^5.66.5",
|
||||
"@tanstack/react-router": "^1.121.2",
|
||||
"@tanstack/react-router-devtools": "^1.121.2",
|
||||
"@tanstack/router-plugin": "^1.121.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwindcss": "^4.0.6",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"jsdom": "^26.0.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.1.0",
|
||||
"vitest": "^3.0.5",
|
||||
"web-vitals": "^4.2.4"
|
||||
}
|
||||
"name": "Cover letter frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 3000 --host",
|
||||
"build": "vite build && tsc",
|
||||
"serve": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@tailwindcss/vite": "^4.0.6",
|
||||
"@tanstack/react-form": "^1.0.0",
|
||||
"@tanstack/react-query": "^5.66.5",
|
||||
"@tanstack/react-query-devtools": "^5.66.5",
|
||||
"@tanstack/react-router": "^1.121.2",
|
||||
"@tanstack/react-router-devtools": "^1.121.2",
|
||||
"@tanstack/router-plugin": "^1.121.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.525.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.0.6",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"jsdom": "^26.0.0",
|
||||
"prettier": "^3.6.2",
|
||||
"tw-animate-css": "^1.3.5",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.1.0",
|
||||
"vitest": "^3.0.5",
|
||||
"web-vitals": "^4.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.4 KiB |
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"short_name": "TanStack App",
|
||||
"name": "Create TanStack App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
@@ -1,25 +1,15 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { House } from "lucide-react";
|
||||
|
||||
export default function Header() {
|
||||
return (
|
||||
<header className="p-2 flex gap-2 bg-white text-black justify-between">
|
||||
<nav className="flex flex-row">
|
||||
<div className="px-2 font-bold">
|
||||
<Link to="/">Home</Link>
|
||||
</div>
|
||||
|
||||
<div className="px-2 font-bold">
|
||||
<Link to="/demo/form/simple">Simple Form</Link>
|
||||
</div>
|
||||
|
||||
<div className="px-2 font-bold">
|
||||
<Link to="/demo/form/address">Address Form</Link>
|
||||
</div>
|
||||
|
||||
<div className="px-2 font-bold">
|
||||
<Link to="/demo/tanstack-query">TanStack Query</Link>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
return (
|
||||
<header className="py-3 px-4 flex gap-2 bg-panel text-black justify-between shadow">
|
||||
<nav className="flex flex-row font-bold">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<House size={20} />
|
||||
Home
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { useStore } from '@tanstack/react-form'
|
||||
|
||||
import { useFieldContext, useFormContext } from '../hooks/demo.form-context'
|
||||
|
||||
export function SubscribeButton({ label }: { label: string }) {
|
||||
const form = useFormContext()
|
||||
return (
|
||||
<form.Subscribe selector={(state) => state.isSubmitting}>
|
||||
{(isSubmitting) => (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="px-6 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)}
|
||||
</form.Subscribe>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorMessages({
|
||||
errors,
|
||||
}: {
|
||||
errors: Array<string | { message: string }>
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{errors.map((error) => (
|
||||
<div
|
||||
key={typeof error === 'string' ? error : error.message}
|
||||
className="text-red-500 mt-1 font-bold"
|
||||
>
|
||||
{typeof error === 'string' ? error : error.message}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function TextField({
|
||||
label,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string
|
||||
placeholder?: string
|
||||
}) {
|
||||
const field = useFieldContext<string>()
|
||||
const errors = useStore(field.store, (state) => state.meta.errors)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={label} className="block font-bold mb-1 text-xl">
|
||||
{label}
|
||||
<input
|
||||
value={field.state.value}
|
||||
placeholder={placeholder}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
className="w-full px-4 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</label>
|
||||
{field.state.meta.isTouched && <ErrorMessages errors={errors} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TextArea({
|
||||
label,
|
||||
rows = 3,
|
||||
}: {
|
||||
label: string
|
||||
rows?: number
|
||||
}) {
|
||||
const field = useFieldContext<string>()
|
||||
const errors = useStore(field.store, (state) => state.meta.errors)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={label} className="block font-bold mb-1 text-xl">
|
||||
{label}
|
||||
<textarea
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
rows={rows}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
className="w-full px-4 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</label>
|
||||
{field.state.meta.isTouched && <ErrorMessages errors={errors} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Select({
|
||||
label,
|
||||
values,
|
||||
}: {
|
||||
label: string
|
||||
values: Array<{ label: string; value: string }>
|
||||
placeholder?: string
|
||||
}) {
|
||||
const field = useFieldContext<string>()
|
||||
const errors = useStore(field.store, (state) => state.meta.errors)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={label} className="block font-bold mb-1 text-xl">
|
||||
{label}
|
||||
</label>
|
||||
<select
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
className="w-full px-4 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
{values.map((value) => (
|
||||
<option key={value.value} value={value.value}>
|
||||
{value.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{field.state.meta.isTouched && <ErrorMessages errors={errors} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
48
frontend/src/components/forms/TextField.tsx
Normal file
48
frontend/src/components/forms/TextField.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useFieldContext } from "@/hooks/formHook";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "../ui/input";
|
||||
|
||||
// --- field components ---
|
||||
interface TextFieldProps {
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
type?: React.ComponentProps<"input">["type"];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function TextField({
|
||||
placeholder,
|
||||
type = "text",
|
||||
className = "",
|
||||
label = "",
|
||||
}: TextFieldProps) {
|
||||
// Get field with predefined text type
|
||||
const field = useFieldContext<string>();
|
||||
|
||||
// Render custom field
|
||||
return (
|
||||
<div className={cn("flex flex-col", className)}>
|
||||
{label && (
|
||||
<label htmlFor={field.name} className="ml-1 mb-2 text-secondary-foreground text-sm">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
type={type}
|
||||
placeholder={placeholder || `${type} input`}
|
||||
/>
|
||||
|
||||
{!field.state.meta.isValid && (
|
||||
<span className="text-xs text-danger mt-1">
|
||||
{field.state.meta.errors.map((e) => e.message).join(", ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
frontend/src/components/ui/button.tsx
Normal file
50
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />;
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
59
frontend/src/components/ui/card.tsx
Normal file
59
frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="card-description" className={cn("text-muted-foreground text-sm", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
|
||||
3
frontend/src/components/ui/container.tsx
Normal file
3
frontend/src/components/ui/container.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Container({ children, className = "" }: React.ComponentProps<"div">) {
|
||||
return <div className={`container mx-auto max-w-6xl px-4 ${className}`}>{children}</div>;
|
||||
}
|
||||
21
frontend/src/components/ui/input.tsx
Normal file
21
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
2
frontend/src/consts.ts
Normal file
2
frontend/src/consts.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const BASE = "/";
|
||||
export const API_BASE = `${BASE}/api/`;
|
||||
@@ -1,4 +0,0 @@
|
||||
import { createFormHookContexts } from '@tanstack/react-form'
|
||||
|
||||
export const { fieldContext, useFieldContext, formContext, useFormContext } =
|
||||
createFormHookContexts()
|
||||
@@ -1,22 +0,0 @@
|
||||
import { createFormHook } from '@tanstack/react-form'
|
||||
|
||||
import {
|
||||
Select,
|
||||
SubscribeButton,
|
||||
TextArea,
|
||||
TextField,
|
||||
} from '../components/demo.FormComponents'
|
||||
import { fieldContext, formContext } from './demo.form-context'
|
||||
|
||||
export const { useAppForm } = createFormHook({
|
||||
fieldComponents: {
|
||||
TextField,
|
||||
Select,
|
||||
TextArea,
|
||||
},
|
||||
formComponents: {
|
||||
SubscribeButton,
|
||||
},
|
||||
fieldContext,
|
||||
formContext,
|
||||
})
|
||||
14
frontend/src/hooks/formHook.tsx
Normal file
14
frontend/src/hooks/formHook.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import TextField from "@/components/forms/TextField";
|
||||
import { createFormHookContexts, createFormHook } from "@tanstack/react-form";
|
||||
|
||||
// export useFieldContext for use in your custom components
|
||||
export const { fieldContext, formContext, useFieldContext } = createFormHookContexts();
|
||||
|
||||
export const { useAppForm } = createFormHook({
|
||||
fieldComponents: {
|
||||
TextField,
|
||||
},
|
||||
formComponents: {},
|
||||
fieldContext,
|
||||
formContext,
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
|
||||
export default function LayoutAddition() {
|
||||
return <ReactQueryDevtools buttonPosition="bottom-right" />
|
||||
return <ReactQueryDevtools buttonPosition="bottom-right" />;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export function getContext() {
|
||||
return {
|
||||
queryClient,
|
||||
}
|
||||
return {
|
||||
queryClient,
|
||||
};
|
||||
}
|
||||
|
||||
export function Provider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
26
frontend/src/layouts/Authorised.tsx
Normal file
26
frontend/src/layouts/Authorised.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import Header from "@/components/Header";
|
||||
import Container from "@/components/ui/container";
|
||||
import requests from "@/lib/requests";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { TokenUserInfo } from "@/types/api";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Authorised({ children, className = "" }: Props) {
|
||||
// Check authentication
|
||||
const info = useQuery({
|
||||
queryKey: ["user_info"],
|
||||
queryFn: () => requests.get<TokenUserInfo>("/info", {}),
|
||||
staleTime: 60 * 1000, // 1 minutes
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<Container>{children}</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
3
frontend/src/layouts/Guest.tsx
Normal file
3
frontend/src/layouts/Guest.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Guest({ children, className = "" }: React.ComponentProps<"div">) {
|
||||
return <div className={`${className}`}>{children}</div>;
|
||||
}
|
||||
119
frontend/src/lib/requests.ts
Normal file
119
frontend/src/lib/requests.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { API_BASE } from "@/consts";
|
||||
import { normalizeLink } from "./utils";
|
||||
import type { ApiResponse } from "@/types/api";
|
||||
import toast from "react-hot-toast";
|
||||
import { tryCatch } from "./tryCatch";
|
||||
|
||||
interface RequestProps<T> {
|
||||
error?: (err: Error) => void;
|
||||
success?: (data: T) => void;
|
||||
before?: () => void;
|
||||
finally?: () => void;
|
||||
}
|
||||
|
||||
interface PostProps<T> extends RequestProps<T> {
|
||||
data: Record<string, any>;
|
||||
}
|
||||
|
||||
interface GetProps<T> extends RequestProps<T> {
|
||||
params?: Record<string, any>;
|
||||
}
|
||||
|
||||
class Requests {
|
||||
constructor() {}
|
||||
|
||||
async verifyData<T>(res: Response): Promise<T> {
|
||||
// Get response data
|
||||
const { data, error } = await tryCatch<ApiResponse<T>>(res.json());
|
||||
if (error) {
|
||||
throw new Error(`Parsing error: ${res.statusText} - ${res.status}`);
|
||||
}
|
||||
|
||||
// Check if authentication is required
|
||||
if ("needsAuthentication" in data && data.needsAuthentication) {
|
||||
window.location.replace("/login");
|
||||
throw new Error("Authentication is required");
|
||||
}
|
||||
|
||||
// Check if data is ok
|
||||
if ("success" in data && !data.success) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
// Another check for unexpected error
|
||||
if (!res.ok) {
|
||||
throw new Error("Unexpected API ERROR with code: " + res.status);
|
||||
}
|
||||
|
||||
// Return response data
|
||||
return data.data;
|
||||
}
|
||||
|
||||
async get<T>(url: string, props: GetProps<T>): Promise<T | void> {
|
||||
// Call before
|
||||
props.before?.();
|
||||
|
||||
// Get url parameters
|
||||
const urlParams = props.params ? new URLSearchParams(props.params).toString() : "";
|
||||
// Normalize url
|
||||
const finalUrl = normalizeLink(`${API_BASE}/${url}${urlParams}`);
|
||||
|
||||
try {
|
||||
// Do request
|
||||
const res = await fetch(finalUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
// Verify data
|
||||
const responseData = await this.verifyData<T>(res);
|
||||
|
||||
// Otherwise return response data
|
||||
props.success?.(responseData);
|
||||
return responseData;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
// Show notification, and call error callback
|
||||
toast.error(err.message);
|
||||
props.error?.(err);
|
||||
} finally {
|
||||
props.finally?.();
|
||||
}
|
||||
}
|
||||
|
||||
async post<T>(url: string, props: PostProps<T>): Promise<T | void> {
|
||||
props.before?.();
|
||||
|
||||
// Normalize url
|
||||
const finalUrl = normalizeLink(`${API_BASE}/${url}`);
|
||||
|
||||
try {
|
||||
// Do request
|
||||
const res = await fetch(finalUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(props.data),
|
||||
});
|
||||
|
||||
// Verify data
|
||||
const responseData = await this.verifyData<T>(res);
|
||||
|
||||
// Otherwise return response data
|
||||
props.success?.(responseData);
|
||||
return responseData;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
// Show notification, and call error callback
|
||||
toast.error(err.message);
|
||||
props.error?.(err);
|
||||
} finally {
|
||||
props.finally?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new Requests();
|
||||
32
frontend/src/lib/tryCatch.ts
Normal file
32
frontend/src/lib/tryCatch.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// Types for the result object with discriminated union
|
||||
type Success<T> = {
|
||||
data: T;
|
||||
error: null;
|
||||
};
|
||||
|
||||
type Failure<E> = {
|
||||
data: null;
|
||||
error: E;
|
||||
};
|
||||
|
||||
type Result<T, E = Error> = Success<T> | Failure<E>;
|
||||
|
||||
// Main wrapper function
|
||||
export async function tryCatch<T, E = Error>(promise: Promise<T>): Promise<Result<T, E>> {
|
||||
try {
|
||||
const data = await promise;
|
||||
return { data, error: null };
|
||||
} catch (error) {
|
||||
return { data: null, error: error as E };
|
||||
}
|
||||
}
|
||||
|
||||
// function for sync
|
||||
export function tryCatchSync<T, E = Error>(callback: () => T): Result<T, E> {
|
||||
try {
|
||||
const data = callback();
|
||||
return { data, error: null };
|
||||
} catch (error) {
|
||||
return { data: null, error: error as E };
|
||||
}
|
||||
}
|
||||
17
frontend/src/lib/utils.ts
Normal file
17
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function normalizeLink(link: string) {
|
||||
let tmp = link;
|
||||
|
||||
// Remove double slashes
|
||||
while (tmp.includes("//")) {
|
||||
tmp = tmp.replaceAll("//", "/");
|
||||
}
|
||||
|
||||
return tmp;
|
||||
}
|
||||
@@ -1,48 +1,49 @@
|
||||
import { StrictMode } from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
||||
import { StrictMode } from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||
|
||||
import * as TanStackQueryProvider from './integrations/tanstack-query/root-provider.tsx'
|
||||
import * as TanStackQueryProvider from "./integrations/tanstack-query/root-provider.tsx";
|
||||
|
||||
// Import the generated route tree
|
||||
import { routeTree } from './routeTree.gen'
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
|
||||
import './styles.css'
|
||||
import reportWebVitals from './reportWebVitals.ts'
|
||||
import "./styles.css";
|
||||
import reportWebVitals from "./reportWebVitals.ts";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
// Create a new router instance
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
context: {
|
||||
...TanStackQueryProvider.getContext(),
|
||||
},
|
||||
defaultPreload: 'intent',
|
||||
scrollRestoration: true,
|
||||
defaultStructuralSharing: true,
|
||||
defaultPreloadStaleTime: 0,
|
||||
})
|
||||
routeTree,
|
||||
context: {
|
||||
...TanStackQueryProvider.getContext(),
|
||||
},
|
||||
defaultPreload: "intent",
|
||||
scrollRestoration: true,
|
||||
defaultStructuralSharing: true,
|
||||
defaultPreloadStaleTime: 0,
|
||||
});
|
||||
|
||||
// Register the router instance for type safety
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
|
||||
// Render the app
|
||||
const rootElement = document.getElementById('app')
|
||||
const rootElement = document.getElementById("app");
|
||||
if (rootElement && !rootElement.innerHTML) {
|
||||
const root = ReactDOM.createRoot(rootElement)
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<TanStackQueryProvider.Provider>
|
||||
<RouterProvider router={router} />
|
||||
</TanStackQueryProvider.Provider>
|
||||
</StrictMode>,
|
||||
)
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<TanStackQueryProvider.Provider>
|
||||
{/* Toaster for notifications */}
|
||||
<Toaster position="top-right" reverseOrder={false} />
|
||||
<RouterProvider router={router} />
|
||||
</TanStackQueryProvider.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals()
|
||||
reportWebVitals();
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
const reportWebVitals = (onPerfEntry?: () => void) => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ onCLS, onINP, onFCP, onLCP, onTTFB }) => {
|
||||
onCLS(onPerfEntry)
|
||||
onINP(onPerfEntry)
|
||||
onFCP(onPerfEntry)
|
||||
onLCP(onPerfEntry)
|
||||
onTTFB(onPerfEntry)
|
||||
})
|
||||
}
|
||||
}
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import("web-vitals").then(({ onCLS, onINP, onFCP, onLCP, onTTFB }) => {
|
||||
onCLS(onPerfEntry);
|
||||
onINP(onPerfEntry);
|
||||
onFCP(onPerfEntry);
|
||||
onLCP(onPerfEntry);
|
||||
onTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals
|
||||
export default reportWebVitals;
|
||||
|
||||
@@ -9,77 +9,72 @@
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as RegisterRouteImport } from './routes/register'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as DemoTanstackQueryRouteImport } from './routes/demo.tanstack-query'
|
||||
import { Route as DemoFormSimpleRouteImport } from './routes/demo.form.simple'
|
||||
import { Route as DemoFormAddressRouteImport } from './routes/demo.form.address'
|
||||
|
||||
const RegisterRoute = RegisterRouteImport.update({
|
||||
id: '/register',
|
||||
path: '/register',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const LoginRoute = LoginRouteImport.update({
|
||||
id: '/login',
|
||||
path: '/login',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DemoTanstackQueryRoute = DemoTanstackQueryRouteImport.update({
|
||||
id: '/demo/tanstack-query',
|
||||
path: '/demo/tanstack-query',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DemoFormSimpleRoute = DemoFormSimpleRouteImport.update({
|
||||
id: '/demo/form/simple',
|
||||
path: '/demo/form/simple',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DemoFormAddressRoute = DemoFormAddressRouteImport.update({
|
||||
id: '/demo/form/address',
|
||||
path: '/demo/form/address',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
|
||||
'/demo/form/address': typeof DemoFormAddressRoute
|
||||
'/demo/form/simple': typeof DemoFormSimpleRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/register': typeof RegisterRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
|
||||
'/demo/form/address': typeof DemoFormAddressRoute
|
||||
'/demo/form/simple': typeof DemoFormSimpleRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/register': typeof RegisterRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
|
||||
'/demo/form/address': typeof DemoFormAddressRoute
|
||||
'/demo/form/simple': typeof DemoFormSimpleRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/register': typeof RegisterRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/demo/tanstack-query'
|
||||
| '/demo/form/address'
|
||||
| '/demo/form/simple'
|
||||
fullPaths: '/' | '/login' | '/register'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/demo/tanstack-query' | '/demo/form/address' | '/demo/form/simple'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/demo/tanstack-query'
|
||||
| '/demo/form/address'
|
||||
| '/demo/form/simple'
|
||||
to: '/' | '/login' | '/register'
|
||||
id: '__root__' | '/' | '/login' | '/register'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute
|
||||
DemoFormAddressRoute: typeof DemoFormAddressRoute
|
||||
DemoFormSimpleRoute: typeof DemoFormSimpleRoute
|
||||
LoginRoute: typeof LoginRoute
|
||||
RegisterRoute: typeof RegisterRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/register': {
|
||||
id: '/register'
|
||||
path: '/register'
|
||||
fullPath: '/register'
|
||||
preLoaderRoute: typeof RegisterRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/login': {
|
||||
id: '/login'
|
||||
path: '/login'
|
||||
fullPath: '/login'
|
||||
preLoaderRoute: typeof LoginRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
@@ -87,35 +82,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/demo/tanstack-query': {
|
||||
id: '/demo/tanstack-query'
|
||||
path: '/demo/tanstack-query'
|
||||
fullPath: '/demo/tanstack-query'
|
||||
preLoaderRoute: typeof DemoTanstackQueryRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/demo/form/simple': {
|
||||
id: '/demo/form/simple'
|
||||
path: '/demo/form/simple'
|
||||
fullPath: '/demo/form/simple'
|
||||
preLoaderRoute: typeof DemoFormSimpleRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/demo/form/address': {
|
||||
id: '/demo/form/address'
|
||||
path: '/demo/form/address'
|
||||
fullPath: '/demo/form/address'
|
||||
preLoaderRoute: typeof DemoFormAddressRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
DemoTanstackQueryRoute: DemoTanstackQueryRoute,
|
||||
DemoFormAddressRoute: DemoFormAddressRoute,
|
||||
DemoFormSimpleRoute: DemoFormSimpleRoute,
|
||||
LoginRoute: LoginRoute,
|
||||
RegisterRoute: RegisterRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'
|
||||
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
|
||||
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||
import TanStackQueryLayout from "../integrations/tanstack-query/layout.tsx";
|
||||
|
||||
import Header from '../components/Header'
|
||||
|
||||
import TanStackQueryLayout from '../integrations/tanstack-query/layout.tsx'
|
||||
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
interface MyRouterContext {
|
||||
queryClient: QueryClient
|
||||
queryClient: QueryClient;
|
||||
}
|
||||
|
||||
export const Route = createRootRouteWithContext<MyRouterContext>()({
|
||||
component: () => (
|
||||
<>
|
||||
<Header />
|
||||
component: () => (
|
||||
<>
|
||||
<Outlet />
|
||||
|
||||
<Outlet />
|
||||
<TanStackRouterDevtools />
|
||||
|
||||
<TanStackQueryLayout />
|
||||
</>
|
||||
),
|
||||
})
|
||||
{/* dev tools */}
|
||||
<TanStackRouterDevtools />
|
||||
<TanStackQueryLayout />
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
import { useAppForm } from '../hooks/demo.form'
|
||||
|
||||
export const Route = createFileRoute('/demo/form/address')({
|
||||
component: AddressForm,
|
||||
})
|
||||
|
||||
function AddressForm() {
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
fullName: '',
|
||||
email: '',
|
||||
address: {
|
||||
street: '',
|
||||
city: '',
|
||||
state: '',
|
||||
zipCode: '',
|
||||
country: '',
|
||||
},
|
||||
phone: '',
|
||||
},
|
||||
validators: {
|
||||
onBlur: ({ value }) => {
|
||||
const errors = {
|
||||
fields: {},
|
||||
} as {
|
||||
fields: Record<string, string>
|
||||
}
|
||||
if (value.fullName.trim().length === 0) {
|
||||
errors.fields.fullName = 'Full name is required'
|
||||
}
|
||||
return errors
|
||||
},
|
||||
},
|
||||
onSubmit: ({ value }) => {
|
||||
console.log(value)
|
||||
// Show success message
|
||||
alert('Form submitted successfully!')
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center min-h-screen bg-gradient-to-br from-purple-100 to-blue-100 p-4 text-white"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'radial-gradient(50% 50% at 5% 40%, #f4a460 0%, #8b4513 70%, #1a0f0a 100%)',
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-2xl p-8 rounded-xl backdrop-blur-md bg-black/50 shadow-xl border-8 border-black/10">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
form.handleSubmit()
|
||||
}}
|
||||
className="space-y-6"
|
||||
>
|
||||
<form.AppField name="fullName">
|
||||
{(field) => <field.TextField label="Full Name" />}
|
||||
</form.AppField>
|
||||
|
||||
<form.AppField
|
||||
name="email"
|
||||
validators={{
|
||||
onBlur: ({ value }) => {
|
||||
if (!value || value.trim().length === 0) {
|
||||
return 'Email is required'
|
||||
}
|
||||
if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(value)) {
|
||||
return 'Invalid email address'
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
}}
|
||||
>
|
||||
{(field) => <field.TextField label="Email" />}
|
||||
</form.AppField>
|
||||
|
||||
<form.AppField
|
||||
name="address.street"
|
||||
validators={{
|
||||
onBlur: ({ value }) => {
|
||||
if (!value || value.trim().length === 0) {
|
||||
return 'Street address is required'
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
}}
|
||||
>
|
||||
{(field) => <field.TextField label="Street Address" />}
|
||||
</form.AppField>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<form.AppField
|
||||
name="address.city"
|
||||
validators={{
|
||||
onBlur: ({ value }) => {
|
||||
if (!value || value.trim().length === 0) {
|
||||
return 'City is required'
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
}}
|
||||
>
|
||||
{(field) => <field.TextField label="City" />}
|
||||
</form.AppField>
|
||||
<form.AppField
|
||||
name="address.state"
|
||||
validators={{
|
||||
onBlur: ({ value }) => {
|
||||
if (!value || value.trim().length === 0) {
|
||||
return 'State is required'
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
}}
|
||||
>
|
||||
{(field) => <field.TextField label="State" />}
|
||||
</form.AppField>
|
||||
<form.AppField
|
||||
name="address.zipCode"
|
||||
validators={{
|
||||
onBlur: ({ value }) => {
|
||||
if (!value || value.trim().length === 0) {
|
||||
return 'Zip code is required'
|
||||
}
|
||||
if (!/^\d{5}(-\d{4})?$/.test(value)) {
|
||||
return 'Invalid zip code format'
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
}}
|
||||
>
|
||||
{(field) => <field.TextField label="Zip Code" />}
|
||||
</form.AppField>
|
||||
</div>
|
||||
|
||||
<form.AppField
|
||||
name="address.country"
|
||||
validators={{
|
||||
onBlur: ({ value }) => {
|
||||
if (!value || value.trim().length === 0) {
|
||||
return 'Country is required'
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
}}
|
||||
>
|
||||
{(field) => (
|
||||
<field.Select
|
||||
label="Country"
|
||||
values={[
|
||||
{ label: 'United States', value: 'US' },
|
||||
{ label: 'Canada', value: 'CA' },
|
||||
{ label: 'United Kingdom', value: 'UK' },
|
||||
{ label: 'Australia', value: 'AU' },
|
||||
{ label: 'Germany', value: 'DE' },
|
||||
{ label: 'France', value: 'FR' },
|
||||
{ label: 'Japan', value: 'JP' },
|
||||
]}
|
||||
placeholder="Select a country"
|
||||
/>
|
||||
)}
|
||||
</form.AppField>
|
||||
|
||||
<form.AppField
|
||||
name="phone"
|
||||
validators={{
|
||||
onBlur: ({ value }) => {
|
||||
if (!value || value.trim().length === 0) {
|
||||
return 'Phone number is required'
|
||||
}
|
||||
if (
|
||||
!/^(\+\d{1,3})?\s?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}$/.test(
|
||||
value,
|
||||
)
|
||||
) {
|
||||
return 'Invalid phone number format'
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
}}
|
||||
>
|
||||
{(field) => (
|
||||
<field.TextField label="Phone" placeholder="123-456-7890" />
|
||||
)}
|
||||
</form.AppField>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<form.AppForm>
|
||||
<form.SubscribeButton label="Submit" />
|
||||
</form.AppForm>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { useAppForm } from '../hooks/demo.form'
|
||||
|
||||
export const Route = createFileRoute('/demo/form/simple')({
|
||||
component: SimpleForm,
|
||||
})
|
||||
|
||||
const schema = z.object({
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
description: z.string().min(1, 'Description is required'),
|
||||
})
|
||||
|
||||
function SimpleForm() {
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
title: '',
|
||||
description: '',
|
||||
},
|
||||
validators: {
|
||||
onBlur: schema,
|
||||
},
|
||||
onSubmit: ({ value }) => {
|
||||
console.log(value)
|
||||
// Show success message
|
||||
alert('Form submitted successfully!')
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center min-h-screen bg-gradient-to-br from-purple-100 to-blue-100 p-4 text-white"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'radial-gradient(50% 50% at 5% 40%, #add8e6 0%, #0000ff 70%, #00008b 100%)',
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-2xl p-8 rounded-xl backdrop-blur-md bg-black/50 shadow-xl border-8 border-black/10">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
form.handleSubmit()
|
||||
}}
|
||||
className="space-y-6"
|
||||
>
|
||||
<form.AppField name="title">
|
||||
{(field) => <field.TextField label="Title" />}
|
||||
</form.AppField>
|
||||
|
||||
<form.AppField name="description">
|
||||
{(field) => <field.TextArea label="Description" />}
|
||||
</form.AppField>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<form.AppForm>
|
||||
<form.SubscribeButton label="Submit" />
|
||||
</form.AppForm>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
export const Route = createFileRoute('/demo/tanstack-query')({
|
||||
component: TanStackQueryDemo,
|
||||
})
|
||||
|
||||
function TanStackQueryDemo() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['people'],
|
||||
queryFn: () =>
|
||||
Promise.resolve([{ name: 'John Doe' }, { name: 'Jane Doe' }]),
|
||||
initialData: [],
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h1 className="text-2xl mb-4">People list</h1>
|
||||
<ul>
|
||||
{data.map((person) => (
|
||||
<li key={person.name}>{person.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,39 +1,14 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import logo from "../logo.svg";
|
||||
import Authorised from "@/layouts/Authorised";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: App,
|
||||
component: App,
|
||||
});
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<header className="min-h-screen flex flex-col items-center justify-center bg-[#282c34] text-white text-[calc(10px+2vmin)]">
|
||||
<img
|
||||
src={logo}
|
||||
className="h-[40vmin] pointer-events-none animate-[spin_20s_linear_infinite]"
|
||||
alt="logo"
|
||||
/>
|
||||
<p>
|
||||
Edit <code>src/routes/index.tsx</code> and save to reload.
|
||||
</p>
|
||||
<a
|
||||
className="text-[#61dafb] hover:underline"
|
||||
href="https://reactjs.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn React
|
||||
</a>
|
||||
<a
|
||||
className="text-[#61dafb] hover:underline"
|
||||
href="https://tanstack.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn TanStack
|
||||
</a>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Authorised>
|
||||
<h1 className="mt-4">Welcome to cover letter</h1>
|
||||
</Authorised>
|
||||
);
|
||||
}
|
||||
|
||||
92
frontend/src/routes/login.tsx
Normal file
92
frontend/src/routes/login.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardAction, CardContent } from "@/components/ui/card";
|
||||
import { useAppForm } from "@/hooks/formHook";
|
||||
import Guest from "@/layouts/Guest";
|
||||
import requests from "@/lib/requests";
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const Route = createFileRoute("/login")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().nonempty("Password is required"),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const loading = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
validators: {
|
||||
onBlur: loginSchema,
|
||||
},
|
||||
onSubmit: ({ value }) => {
|
||||
requests.post<{ message: string }>("/login", {
|
||||
data: value,
|
||||
before() {
|
||||
// use state to true loading
|
||||
loading[1](true);
|
||||
},
|
||||
success() {
|
||||
navigate({ to: "/" });
|
||||
},
|
||||
error() {
|
||||
form.setFieldValue("password", "");
|
||||
},
|
||||
finally() {
|
||||
loading[1](false);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Guest className="h-screen w-screen grid place-items-center">
|
||||
<Card className="w-full max-w-[400px]">
|
||||
<CardHeader>
|
||||
<CardTitle>Login</CardTitle>
|
||||
<CardDescription>Log into your account</CardDescription>
|
||||
<CardAction>
|
||||
<Button variant={"link"}>
|
||||
<Link to="/register">Register</Link>
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<form.AppField
|
||||
name="email"
|
||||
children={(f) => (
|
||||
<f.TextField
|
||||
label="Email address"
|
||||
placeholder="Your email address"
|
||||
type="email"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<form.AppField
|
||||
name="password"
|
||||
children={(f) => (
|
||||
<f.TextField
|
||||
label="Password"
|
||||
placeholder="Your accounts password"
|
||||
type="password"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Button disabled={loading[0]} onClick={form.handleSubmit} className="w-full">
|
||||
Login
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Guest>
|
||||
);
|
||||
}
|
||||
123
frontend/src/routes/register.tsx
Normal file
123
frontend/src/routes/register.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useAppForm } from "@/hooks/formHook";
|
||||
import Guest from "@/layouts/Guest";
|
||||
import requests from "@/lib/requests";
|
||||
import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const Route = createFileRoute("/register")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
const registerSchema = z
|
||||
// Basic validation
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().min(2, "Name is too short").max(50, "Name is too long (50 char max)"),
|
||||
password: z.string().min(8, "Password is too short"),
|
||||
repeatPassword: z.string().min(8, "Password is too short"),
|
||||
})
|
||||
// Custom validation
|
||||
.refine((data) => data.password === data.repeatPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ["repeatPassword"],
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const loading = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
name: "",
|
||||
repeatPassword: "",
|
||||
},
|
||||
validators: {
|
||||
onBlur: registerSchema,
|
||||
},
|
||||
onSubmit: ({ value }) => {
|
||||
requests.post<{ message: string }>("/register", {
|
||||
data: value,
|
||||
before() {
|
||||
// use state to true loading
|
||||
loading[1](true);
|
||||
},
|
||||
success(data) {
|
||||
toast.success(data.message);
|
||||
form.reset();
|
||||
|
||||
// Wait a bit before redirecting
|
||||
setTimeout(() => {
|
||||
navigate({ to: "/login" });
|
||||
}, 1000);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Guest className="h-screen w-screen grid place-items-center">
|
||||
<Card className="w-full max-w-[400px]">
|
||||
<CardHeader>
|
||||
<CardTitle>Register</CardTitle>
|
||||
<CardDescription>Create an account</CardDescription>
|
||||
<CardAction>
|
||||
<Button variant={"link"}>
|
||||
<Link to="/login">Login</Link>
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<form.AppField
|
||||
name="email"
|
||||
children={(field) => (
|
||||
<field.TextField
|
||||
label="Email address"
|
||||
placeholder="Your email address"
|
||||
type="email"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<form.AppField
|
||||
name="name"
|
||||
children={(field) => (
|
||||
<field.TextField label="Name" placeholder="Full name or nickname" />
|
||||
)}
|
||||
/>
|
||||
|
||||
<form.AppField
|
||||
name="password"
|
||||
children={(field) => (
|
||||
<field.TextField
|
||||
label="Password"
|
||||
placeholder="Your accounts password"
|
||||
type="password"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<form.AppField
|
||||
name="repeatPassword"
|
||||
children={(field) => (
|
||||
<field.TextField
|
||||
label="Repeat password"
|
||||
placeholder="Repeat your password"
|
||||
type="password"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button disabled={loading[0]} onClick={form.handleSubmit} className="w-full">
|
||||
Register
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Guest>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,138 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
|
||||
/* My own variables */
|
||||
--color-body: var(--background);
|
||||
--color-panel: var(--card);
|
||||
--color-danger: var(--destructive);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
@apply m-0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
|
||||
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
@apply m-0;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
|
||||
"Droid Sans", "Helvetica Neue", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
|
||||
monospace;
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
}
|
||||
|
||||
20
frontend/src/types/api.ts
Normal file
20
frontend/src/types/api.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// Data structure for successful responses
|
||||
export interface SuccessResponse<T> {
|
||||
success: true;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// Data structure for error responses
|
||||
export interface ErrorResponse {
|
||||
success: false;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export type ApiResponse<T> = SuccessResponse<T> | ErrorResponse;
|
||||
|
||||
// user info returned by /info route
|
||||
export interface TokenUserInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
8
frontend/src/types/global.d.ts
vendored
Normal file
8
frontend/src/types/global.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
// types/global.d.ts or at the top of a relevant file
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
navigateToLogin?: Promise<void>;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,28 @@
|
||||
{
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
/* Linting */
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import viteReact from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { defineConfig } from "vite";
|
||||
import viteReact from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
|
||||
import { resolve } from 'node:path'
|
||||
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
TanStackRouterVite({ autoCodeSplitting: true }),
|
||||
viteReact(),
|
||||
tailwindcss(),
|
||||
],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, './src'),
|
||||
plugins: [TanStackRouterVite({ autoCodeSplitting: true }), viteReact(), tailwindcss()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
},
|
||||
},
|
||||
})
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
6
scripts/start.sh
Executable file
6
scripts/start.sh
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Load variables
|
||||
source ./var.sh
|
||||
|
||||
cd .. && docker compose -f $file up -d --build
|
||||
6
scripts/stop.sh
Executable file
6
scripts/stop.sh
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Load variables
|
||||
source ./var.sh
|
||||
|
||||
cd .. && docker compose -f $file down
|
||||
2
scripts/var.sh
Executable file
2
scripts/var.sh
Executable file
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
export file="development.yml"
|
||||
Reference in New Issue
Block a user