From a42e61fb4868ff29860ef34186bff19fd3d4e0af Mon Sep 17 00:00:00 2001 From: Leons Aleksandrovs <58330666+Skrazzo@users.noreply.github.com> Date: Sat, 5 Jul 2025 21:11:09 +0300 Subject: [PATCH] Frontend form handling done --- Dockerfile.backend | 10 ++++ backend/.air.toml | 52 +++++++++++++++++ backend/controllers/users.go | 34 +++++++++++ backend/db/migrations.sql | 3 +- backend/main.go | 2 +- backend/models/users.go | 13 +++++ backend/routes/routes.go | 12 ++-- backend/utils/responses.go | 1 + development.yml | 4 +- frontend/.prettierrc.mjs | 2 +- frontend/bun.lock | 3 + frontend/package.json | 1 + frontend/src/components/forms/TextField.tsx | 48 ++++++++++++++++ frontend/src/consts.ts | 2 + frontend/src/hooks/formHook.tsx | 14 +++++ frontend/src/lib/requests.ts | 63 +++++++++++++++++++++ frontend/src/lib/utils.ts | 17 +++++- frontend/src/main.tsx | 11 ++-- frontend/src/routes/register.tsx | 61 ++++++++++++++------ frontend/src/styles.css | 1 + frontend/src/types/api.ts | 13 +++++ scripts/start.sh | 6 ++ 22 files changed, 338 insertions(+), 35 deletions(-) create mode 100644 backend/.air.toml create mode 100644 backend/controllers/users.go create mode 100644 backend/models/users.go create mode 100644 backend/utils/responses.go create mode 100644 frontend/src/components/forms/TextField.tsx create mode 100644 frontend/src/consts.ts create mode 100644 frontend/src/hooks/formHook.tsx create mode 100644 frontend/src/lib/requests.ts create mode 100644 frontend/src/types/api.ts create mode 100755 scripts/start.sh diff --git a/Dockerfile.backend b/Dockerfile.backend index 181ee11..aad3aa2 100644 --- a/Dockerfile.backend +++ b/Dockerfile.backend @@ -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,10 @@ 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 + + diff --git a/backend/.air.toml b/backend/.air.toml new file mode 100644 index 0000000..498951f --- /dev/null +++ b/backend/.air.toml @@ -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 diff --git a/backend/controllers/users.go b/backend/controllers/users.go new file mode 100644 index 0000000..a040558 --- /dev/null +++ b/backend/controllers/users.go @@ -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) +} diff --git a/backend/db/migrations.sql b/backend/db/migrations.sql index 0bad934..7a01c29 100644 --- a/backend/db/migrations.sql +++ b/backend/db/migrations.sql @@ -1,7 +1,8 @@ -- Create users table if it doesn't exist CREATE TABLE IF NOT EXISTS users ( id INT PRIMARY KEY, - username TEXT NOT NULL UNIQUE, + email TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, password TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); diff --git a/backend/main.go b/backend/main.go index 59d9d37..ff36a84 100644 --- a/backend/main.go +++ b/backend/main.go @@ -28,6 +28,6 @@ func main() { 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")) } diff --git a/backend/models/users.go b/backend/models/users.go new file mode 100644 index 0000000..fbe12d4 --- /dev/null +++ b/backend/models/users.go @@ -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 +} diff --git a/backend/routes/routes.go b/backend/routes/routes.go index 3b786d2..7e3d096 100644 --- a/backend/routes/routes.go +++ b/backend/routes/routes.go @@ -1,7 +1,7 @@ package routes import ( - "net/http" + "backend/controllers" "github.com/gin-gonic/gin" ) @@ -9,11 +9,11 @@ import ( func SetupRoutes() *gin.Engine { r := gin.Default() - r.GET("/ping", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{ - "message": "pong", - }) - }) + // Controllers + users := controllers.User{} + + // Guest routes (Register, Login, check auth) + r.POST("/register", users.Register) return r } diff --git a/backend/utils/responses.go b/backend/utils/responses.go new file mode 100644 index 0000000..d4b585b --- /dev/null +++ b/backend/utils/responses.go @@ -0,0 +1 @@ +package utils diff --git a/development.yml b/development.yml index ef6f9f4..35dc95e 100644 --- a/development.yml +++ b/development.yml @@ -61,8 +61,8 @@ services: - ./data/db:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] - interval: 5s - timeout: 5s + interval: 2s + timeout: 2s retries: 5 networks: diff --git a/frontend/.prettierrc.mjs b/frontend/.prettierrc.mjs index b949921..2057d6f 100644 --- a/frontend/.prettierrc.mjs +++ b/frontend/.prettierrc.mjs @@ -1,6 +1,6 @@ const config = { trailingComma: "es5", - printWidth: 120, + printWidth: 110, tabWidth: 4, semi: true, singleQuote: false, diff --git a/frontend/bun.lock b/frontend/bun.lock index 5f0ca4d..d71b688 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -17,6 +17,7 @@ "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", @@ -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-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=="], diff --git a/frontend/package.json b/frontend/package.json index 6e84050..8a590db 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "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" diff --git a/frontend/src/components/forms/TextField.tsx b/frontend/src/components/forms/TextField.tsx new file mode 100644 index 0000000..671b0f6 --- /dev/null +++ b/frontend/src/components/forms/TextField.tsx @@ -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(); + + // Render custom field + return ( +
+ {label && ( + + )} + + field.handleChange(e.target.value)} + type={type} + placeholder={placeholder || `${type} input`} + /> + + {!field.state.meta.isValid && ( + + {field.state.meta.errors.map((e) => e.message).join(", ")} + + )} +
+ ); +} diff --git a/frontend/src/consts.ts b/frontend/src/consts.ts new file mode 100644 index 0000000..b771345 --- /dev/null +++ b/frontend/src/consts.ts @@ -0,0 +1,2 @@ +export const BASE = "/"; +export const API_BASE = `${BASE}/api/`; diff --git a/frontend/src/hooks/formHook.tsx b/frontend/src/hooks/formHook.tsx new file mode 100644 index 0000000..ebad8d5 --- /dev/null +++ b/frontend/src/hooks/formHook.tsx @@ -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, +}); diff --git a/frontend/src/lib/requests.ts b/frontend/src/lib/requests.ts new file mode 100644 index 0000000..8a57d8e --- /dev/null +++ b/frontend/src/lib/requests.ts @@ -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 { + error?: (err: Error) => void; + success?: (data: T) => void; + before?: () => void; + finally?: () => void; +} + +interface PostProps extends RequestProps { + data: Record; +} + +class Requests { + constructor() {} + + async post(url: string, props: PostProps): Promise { + 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; + + // 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(); diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index bd0c391..ae1acef 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1,6 +1,17 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; 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; } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 0658f23..1500f3c 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -9,6 +9,7 @@ import { routeTree } from "./routeTree.gen"; import "./styles.css"; import reportWebVitals from "./reportWebVitals.ts"; +import { Toaster } from "react-hot-toast"; // Create a new router instance const router = createRouter({ @@ -34,11 +35,11 @@ const rootElement = document.getElementById("app"); if (rootElement && !rootElement.innerHTML) { const root = ReactDOM.createRoot(rootElement); root.render( - - - - - + + {/* Toaster for notifications */} + + + ); } diff --git a/frontend/src/routes/register.tsx b/frontend/src/routes/register.tsx index bf2c0ba..2f4a061 100644 --- a/frontend/src/routes/register.tsx +++ b/frontend/src/routes/register.tsx @@ -1,8 +1,7 @@ import { Button } from "@/components/ui/button"; 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 { useForm } from "@tanstack/react-form"; import { createFileRoute, Link } from "@tanstack/react-router"; import * as z from "zod/v4"; @@ -13,8 +12,9 @@ export const Route = createFileRoute("/register")({ const registerSchema = z .object({ email: z.string().email(), - password: z.string().min(8), - repeatPassword: z.string().min(8), + name: z.string().min(2, "Name is too short"), + password: z.string().min(8, "Password is too short"), + repeatPassword: z.string().min(8, "Password is too short"), }) .refine((data) => data.password === data.repeatPassword, { message: "Passwords don't match", @@ -22,10 +22,11 @@ const registerSchema = z }); function RouteComponent() { - const form = useForm({ + const form = useAppForm({ defaultValues: { email: "", password: "", + name: "", repeatPassword: "", }, validators: { @@ -36,6 +37,10 @@ function RouteComponent() { }, }); + // useEffect(() => { + // requests.post<{ message: string }>("/ping", { data: { hello: "world" } }); + // }, []); + return ( @@ -48,23 +53,47 @@ function RouteComponent() { - - + ( - field.handleChange(e.target.value)} + )} /> - - + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + diff --git a/frontend/src/styles.css b/frontend/src/styles.css index c8c5a93..1c2dfe4 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -112,6 +112,7 @@ /* My own variables */ --color-body: var(--background); --color-panel: var(--card); + --color-danger: var(--destructive); } @layer base { diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts new file mode 100644 index 0000000..c3869b8 --- /dev/null +++ b/frontend/src/types/api.ts @@ -0,0 +1,13 @@ +// Data structure for successful responses +export interface SuccessResponse { + success: true; + data: T; +} + +// Data structure for error responses +export interface ErrorResponse { + success: false; + error: string; +} + +export type ApiResponse = SuccessResponse | ErrorResponse; diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100755 index 0000000..e88d277 --- /dev/null +++ b/scripts/start.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +# Load variables +source ./var.sh + +cd .. && docker compose -f $file up -d --build