Frontend form handling done

This commit is contained in:
Leons Aleksandrovs
2025-07-05 21:11:09 +03:00
parent 58d53b17cb
commit a42e61fb48
22 changed files with 338 additions and 35 deletions

View File

@@ -7,6 +7,9 @@ WORKDIR /app
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
# ---- Production mode ----
FROM base AS prod
# Copy code, and compile # Copy code, and compile
COPY . . COPY . .
RUN go build -o server main.go RUN go build -o server main.go
@@ -17,3 +20,10 @@ EXPOSE 8080
# Run server binary on start # Run server binary on start
CMD ["./server"] CMD ["./server"]
# ---- Development mode ----
FROM base AS dev
# Install air for hot reloading
RUN go install github.com/air-verse/air@latest

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

View File

@@ -0,0 +1,34 @@
package controllers
import (
"log"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
)
type User struct{}
type RegisterForm struct {
Email string `json:"email" validate:"required,email"`
Name string `json:"name" validate:"required"`
Password string `json:"password" validate:"required"`
RepeatPassword string `json:"repeatPassword" validate:"required"`
}
func (u *User) 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 {
// TODO: Handle error
}
// Validate data
validate := validator.New()
if err := validate.Struct(data); err != nil {
// Handle error
log.Fatalf("Error: %v", err.Error())
}
// fmt.Println(data)
}

View File

@@ -1,7 +1,8 @@
-- Create users table if it doesn't exist -- Create users table if it doesn't exist
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INT PRIMARY KEY, id INT PRIMARY KEY,
username TEXT NOT NULL UNIQUE, email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
password TEXT NOT NULL, password TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );

View File

@@ -28,6 +28,6 @@ func main() {
routes := routes.SetupRoutes() routes := routes.SetupRoutes()
// Listen on port smth // Listen on port smth
log.Printf("Starting server on %s PORT\n", env["port"]) log.Printf("Starting server ...")
log.Fatal(routes.Run(":8080")) log.Fatal(routes.Run(":8080"))
} }

13
backend/models/users.go Normal file
View File

@@ -0,0 +1,13 @@
package models
type User struct {
ID int
email string
name string
password string
createdAt string
}
func Create(email string, name string, hash string) {
// TODO: Insert user into database
}

View File

@@ -1,7 +1,7 @@
package routes package routes
import ( import (
"net/http" "backend/controllers"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -9,11 +9,11 @@ import (
func SetupRoutes() *gin.Engine { func SetupRoutes() *gin.Engine {
r := gin.Default() r := gin.Default()
r.GET("/ping", func(c *gin.Context) { // Controllers
c.JSON(http.StatusOK, gin.H{ users := controllers.User{}
"message": "pong",
}) // Guest routes (Register, Login, check auth)
}) r.POST("/register", users.Register)
return r return r
} }

View File

@@ -0,0 +1 @@
package utils

View File

@@ -61,8 +61,8 @@ services:
- ./data/db:/var/lib/postgresql/data - ./data/db:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"] test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s interval: 2s
timeout: 5s timeout: 2s
retries: 5 retries: 5
networks: networks:

View File

@@ -1,6 +1,6 @@
const config = { const config = {
trailingComma: "es5", trailingComma: "es5",
printWidth: 120, printWidth: 110,
tabWidth: 4, tabWidth: 4,
semi: true, semi: true,
singleQuote: false, singleQuote: false,

View File

@@ -17,6 +17,7 @@
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hot-toast": "^2.5.2",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss": "^4.0.6", "tailwindcss": "^4.0.6",
"zod": "^3.24.2", "zod": "^3.24.2",
@@ -536,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-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-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],

View File

@@ -22,6 +22,7 @@
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hot-toast": "^2.5.2",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss": "^4.0.6", "tailwindcss": "^4.0.6",
"zod": "^3.24.2" "zod": "^3.24.2"

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

2
frontend/src/consts.ts Normal file
View File

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

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

@@ -0,0 +1,63 @@
import { API_BASE } from "@/consts";
import { normalizeLink } from "./utils";
import type { ApiResponse } from "@/types/api";
import toast from "react-hot-toast";
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>;
}
class Requests {
constructor() {}
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),
});
// Get response data
const data = (await res.json()) as ApiResponse<T>;
// 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);
}
// Otherwise return response data
props.success?.(data.data);
return data.data;
} 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

@@ -1,6 +1,17 @@
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) 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

@@ -9,6 +9,7 @@ import { routeTree } from "./routeTree.gen";
import "./styles.css"; import "./styles.css";
import reportWebVitals from "./reportWebVitals.ts"; import reportWebVitals from "./reportWebVitals.ts";
import { Toaster } from "react-hot-toast";
// Create a new router instance // Create a new router instance
const router = createRouter({ const router = createRouter({
@@ -34,11 +35,11 @@ const rootElement = document.getElementById("app");
if (rootElement && !rootElement.innerHTML) { if (rootElement && !rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement); const root = ReactDOM.createRoot(rootElement);
root.render( root.render(
<StrictMode> <TanStackQueryProvider.Provider>
<TanStackQueryProvider.Provider> {/* Toaster for notifications */}
<RouterProvider router={router} /> <Toaster position="top-right" reverseOrder={false} />
</TanStackQueryProvider.Provider> <RouterProvider router={router} />
</StrictMode> </TanStackQueryProvider.Provider>
); );
} }

View File

@@ -1,8 +1,7 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { useAppForm } from "@/hooks/formHook";
import Guest from "@/layouts/Guest"; import Guest from "@/layouts/Guest";
import { useForm } from "@tanstack/react-form";
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute, Link } from "@tanstack/react-router";
import * as z from "zod/v4"; import * as z from "zod/v4";
@@ -13,8 +12,9 @@ export const Route = createFileRoute("/register")({
const registerSchema = z const registerSchema = z
.object({ .object({
email: z.string().email(), email: z.string().email(),
password: z.string().min(8), name: z.string().min(2, "Name is too short"),
repeatPassword: z.string().min(8), password: z.string().min(8, "Password is too short"),
repeatPassword: z.string().min(8, "Password is too short"),
}) })
.refine((data) => data.password === data.repeatPassword, { .refine((data) => data.password === data.repeatPassword, {
message: "Passwords don't match", message: "Passwords don't match",
@@ -22,10 +22,11 @@ const registerSchema = z
}); });
function RouteComponent() { function RouteComponent() {
const form = useForm({ const form = useAppForm({
defaultValues: { defaultValues: {
email: "", email: "",
password: "", password: "",
name: "",
repeatPassword: "", repeatPassword: "",
}, },
validators: { validators: {
@@ -36,6 +37,10 @@ function RouteComponent() {
}, },
}); });
// useEffect(() => {
// requests.post<{ message: string }>("/ping", { data: { hello: "world" } });
// }, []);
return ( return (
<Guest className="h-screen w-screen grid place-items-center"> <Guest className="h-screen w-screen grid place-items-center">
<Card className="w-full max-w-[400px]"> <Card className="w-full max-w-[400px]">
@@ -48,23 +53,47 @@ function RouteComponent() {
</Button> </Button>
</CardAction> </CardAction>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-3">
<form.Field <form.AppField
name="email" name="email"
children={(field) => ( children={(field) => (
<Input <field.TextField
id={field.name} label="Email address"
name={field.name} placeholder="Your email address"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
type="email" type="email"
placeholder="Email address"
/> />
)} )}
/> />
<Input type="password" placeholder="Password" />
<Input type="password" placeholder="Repeat password" /> <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 onClick={form.handleSubmit} className="w-full"> <Button onClick={form.handleSubmit} className="w-full">
Register Register
</Button> </Button>

View File

@@ -112,6 +112,7 @@
/* My own variables */ /* My own variables */
--color-body: var(--background); --color-body: var(--background);
--color-panel: var(--card); --color-panel: var(--card);
--color-danger: var(--destructive);
} }
@layer base { @layer base {

13
frontend/src/types/api.ts Normal file
View File

@@ -0,0 +1,13 @@
// 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;

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