Merge pull request #2 from Skrazzo/User-authentication

User authentication complete
This commit is contained in:
Leons Aleksandrovs
2025-07-06 21:41:23 +03:00
committed by GitHub
67 changed files with 1732 additions and 892 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
data

View File

@@ -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" ]

View File

@@ -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
View 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
View 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
View File

@@ -0,0 +1,2 @@
tmp
.env

View File

@@ -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")
}

View 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
View 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
}

View 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
);

View File

@@ -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
)

View File

@@ -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=

View File

@@ -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"))
}

View 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)

View 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
}

View File

@@ -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
}

View 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
View 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")
}

View 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
})
}

View File

@@ -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:

View File

@@ -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
View File

@@ -0,0 +1,9 @@
const config = {
trailingComma: "es5",
printWidth: 110,
tabWidth: 4,
semi: true,
singleQuote: false,
};
export default config;

View File

@@ -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;

View File

@@ -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
View 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"
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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"
}

View File

@@ -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>
);
}

View File

@@ -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>
)
}

View 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>
);
}

View 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 };

View 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 };

View 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>;
}

View 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
View File

@@ -0,0 +1,2 @@
export const BASE = "/";
export const API_BASE = `${BASE}/api/`;

View File

@@ -1,4 +0,0 @@
import { createFormHookContexts } from '@tanstack/react-form'
export const { fieldContext, useFieldContext, formContext, useFormContext } =
createFormHookContexts()

View File

@@ -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,
})

View 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,
});

View File

@@ -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" />;
}

View File

@@ -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>;
}

View 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>
</>
);
}

View File

@@ -0,0 +1,3 @@
export default function Guest({ children, className = "" }: React.ComponentProps<"div">) {
return <div className={`${className}`}>{children}</div>;
}

View 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();

View 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
View 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;
}

View File

@@ -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();

View File

@@ -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;

View File

@@ -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)

View File

@@ -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 />
</>
),
});

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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
View 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
View 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>;
}
}

View File

@@ -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/*"]
}
}
}
}

View File

@@ -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
View 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
View File

@@ -0,0 +1,6 @@
#!/bin/bash
# Load variables
source ./var.sh
cd .. && docker compose -f $file down

2
scripts/var.sh Executable file
View File

@@ -0,0 +1,2 @@
#!/bin/bash
export file="development.yml"