initial commit

This commit is contained in:
Daniel Noulet 2026-10-07 15:21:47 +02:00
commit e7987e88a7
70 changed files with 13661 additions and 0 deletions

41
.air.toml Normal file
View file

@ -0,0 +1,41 @@
# Air configuration for hot reload
# https://github.com/air-verse/air
root: "."
tmp_dir: "tmp"
build:
args_bin: []
bin: "./tmp/main"
cmd: "go build -o ./tmp/main ./cmd/server"
delay: 1000
exclude_dir: ["assets", "tmp", "vendor", "web", "build", "docs", "deploy"]
exclude_file: ["config.yaml"]
exclude_regex: ["_test\\.go", "\\.yaml$"]
exclude_unchanged: true
follow_symlink: true
full_bin: ""
include_dir: []
include_ext: ["go", "tpl", "tmpl", "html"]
kill_delay: "0s"
log: "build-errors.log"
poll: true
poll_interval: 300
rerun: false
rerun_delay: 500
send_interrupt: false
stop_on_error: true
color:
app: ""
build: "yellow"
main: "magenta"
runner: "green"
watcher: "cyan"
log:
main_only: false
time: false
misc:
clean_on_exit: true

46
.env.local.example Normal file
View file

@ -0,0 +1,46 @@
# Local development environment variables for go-first
# Copy to .env.local and fill in values
# Server
SERVER_MODE=debug
SERVER_HOST=0.0.0.0
SERVER_PORT=8080
# Database (use local PostgreSQL or Docker)
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USER=postgres
DATABASE_PASSWORD=postgres
DATABASE_NAME=go_first_dev
DATABASE_SSL_MODE=disable
# Redis (use local Redis or Docker)
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# JWT
JWT_SECRET=local_development_secret_key_change_in_production
JWT_EXPIRES_IN=24
JWT_REFRESH_EXPIRES_IN=168
# CORS
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173,http://127.0.0.1:3000,http://127.0.0.1:5173
# Logging
LOGGING_LEVEL=debug
LOGGING_FORMAT=console
LOGGING_OUTPUT=stdout
# Email (using mock service in development)
EMAIL_SMTP_HOST=
EMAIL_SMTP_PORT=587
EMAIL_USERNAME=
EMAIL_PASSWORD=
EMAIL_FROM_ADDRESS=noreply@localhost
EMAIL_FROM_NAME=URPI Local
# Frontend
FRONTEND_BUILD_PATH=./web/dist
FRONTEND_SPA_FALLBACK=true

46
.env.production.example Normal file
View file

@ -0,0 +1,46 @@
# Production environment variables for go-first
# Copy to .env.production and fill in values
# Server
SERVER_MODE=release
SERVER_HOST=0.0.0.0
SERVER_PORT=8080
# Database
DATABASE_HOST=postgres
DATABASE_PORT=5432
DATABASE_USER=postgres
DATABASE_PASSWORD=CHANGE_ME_SECURE_PASSWORD
DATABASE_NAME=go_first
DATABASE_SSL_MODE=disable
# Redis
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# JWT
JWT_SECRET=CHANGE_ME_VERY_LONG_RANDOM_SECRET_KEY_AT_LEAST_32_CHARS
JWT_EXPIRES_IN=24
JWT_REFRESH_EXPIRES_IN=168
# CORS
CORS_ALLOWED_ORIGINS=https://www.urpi.be,https://urpi.be
# Logging
LOGGING_LEVEL=info
LOGGING_FORMAT=json
LOGGING_OUTPUT=stdout
# Email (SMTP)
EMAIL_SMTP_HOST=smtp.gmail.com
EMAIL_SMTP_PORT=587
EMAIL_USERNAME=your-email@gmail.com
EMAIL_PASSWORD=your-app-password
EMAIL_FROM_ADDRESS=noreply@urpi.be
EMAIL_FROM_NAME=URPI
# Frontend
FRONTEND_BUILD_PATH=./web/dist
FRONTEND_SPA_FALLBACK=true

297
.github/workflows/ci-cd.yml vendored Normal file
View file

@ -0,0 +1,297 @@
name: CI/CD
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
release:
types: [published]
env:
GO_VERSION: '1.23'
NODE_VERSION: '20'
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# ===========================================
# Lint & Format
# ===========================================
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Install golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: latest
- name: Run Go linters
run: |
cd ${{ github.workspace }}
golangci-lint run ./...
- name: Install frontend dependencies
run: |
cd web
npm ci
- name: Run frontend linters
run: |
cd web
npm run lint
- name: Check formatting
run: |
cd web
npm run format -- --check
# ===========================================
# Test
# ===========================================
test:
name: Test
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: go_first_test
ports: 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports: 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Install Go dependencies
run: go mod download
- name: Run Go tests
run: |
go test -v -race -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
- name: Upload Go coverage
uses: codecov/codecov-action@v4
with:
files: coverage.out
flags: backend
- name: Install frontend dependencies
run: |
cd web
npm ci
- name: Run frontend tests
run: |
cd web
npm test -- --run --coverage
- name: Upload frontend coverage
uses: codecov/codecov-action@v4
with:
files: web/coverage/lcov.info
flags: frontend
# ===========================================
# Build
# ===========================================
build:
name: Build
runs-on: ubuntu-latest
needs: [lint, test]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Build backend
run: |
CGO_ENABLED=0 go build -ldflags="-s -w" -o build/go-first ./cmd/server
- name: Build frontend
run: |
cd web
npm ci
npm run build
- name: Upload backend artifact
uses: actions/upload-artifact@v4
with:
name: go-first-backend
path: build/go-first
retention-days: 7
- name: Upload frontend artifact
uses: actions/upload-artifact@v4
with:
name: go-first-frontend
path: web/dist
retention-days: 7
# ===========================================
# Docker Build & Push
# ===========================================
docker:
name: Docker
runs-on: ubuntu-latest
needs: build
if: github.event_name == 'push' || github.event_name == 'release'
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# ===========================================
# Deploy Staging
# ===========================================
deploy-staging:
name: Deploy Staging
runs-on: ubuntu-latest
needs: docker
if: github.ref == 'refs/heads/develop'
environment: staging
steps:
- name: Deploy to staging
run: |
echo "Deploying to staging..."
# Add your staging deployment commands here
# Example: kubectl apply -k deploy/kubernetes/overlays/staging
# Or: ssh deploy@staging-server "cd /opt/go-first && ./deploy/deploy.sh staging"
# ===========================================
# Deploy Production
# ===========================================
deploy-production:
name: Deploy Production
runs-on: ubuntu-latest
needs: docker
if: github.event_name == 'release'
environment: production
steps:
- name: Deploy to production
run: |
echo "Deploying to production..."
# Add your production deployment commands here
# Example: kubectl apply -k deploy/kubernetes/overlays/production
# Or: ssh deploy@production-server "cd /opt/go-first && ./deploy/deploy.sh production"
# ===========================================
# Security Scan
# ===========================================
security:
name: Security Scan
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run Gosec
uses: securego/gosec@master
with:
args: './...'
- name: Run Trivy
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
format: 'sarif'
output: 'trivy-results.sarif'
- name: Upload Trivy results
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy-results.sarif'

75
.gitignore vendored Normal file
View file

@ -0,0 +1,75 @@
# Binaries
/go-first
/main
/tmp/
/build/
# Go
go.work
*.exe
*.test
*.prof
coverage.out
coverage.html
**/dist
# Node
web/node_modules/
web/dist/
web/.vite/
web/.turbo/
web/*.log
web/npm-debug.log*
web/yarn-debug.log*
web/yarn-error.log*
web/pnpm-debug.log*
# Environment
.env
.env.local
.env.production
.env.staging
.env.*.local
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
.DS_Store
# Docker
docker-compose.override.yml
# Logs
*.log
logs/
# Database
*.db
*.sqlite
migrations/*.sql~
# OS
Thumbs.db
ehthumbs.db
Desktop.ini
# Test
*.out
*.coverprofile
# Certificates
deploy/ssl/
*.pem
*.key
*.crt
# Temporary
tmp/
temp/
*.tmp
# Misc
*.bak
*.backup

80
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,80 @@
# Pre-commit hooks configuration
# Install: pip install pre-commit && pre-commit install
# Run: pre-commit run --all-files
repos:
# Go formatting
- repo: local
hooks:
- id: go-fmt
name: go fmt
entry: go fmt ./...
language: system
types: [go]
pass_filenames: false
- id: go-imports
name: go imports
entry: goimports -w .
language: system
types: [go]
pass_filenames: false
- id: go-mod-tidy
name: go mod tidy
entry: go mod tidy
language: system
types: [go]
pass_filenames: false
# Go linting
- repo: https://github.com/golangci/golangci-lint
rev: v1.56.0
hooks:
- id: golangci-lint
args: [--fast]
# General file checks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-json
- id: check-toml
- id: check-merge-conflict
- id: detect-private-key
- id: detect-aws-credentials
- id: mixed-line-ending
args: [--fix=lf]
# Prettier for frontend
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v3.1.0
hooks:
- id: prettier
types: [typescript, json, yaml, markdown, css, html]
exclude: ^web/node_modules/
# Secret detection
- repo: https://github.com/truffleHQ/truffleHog
rev: v3.70.0
hooks:
- id: truffleHog
args: [--no-verify]
# Spell check
- repo: https://github.com/crateci/typos
rev: v1.18.0
hooks:
- id: typos
args: [--config, .typos.toml]
# Markdown linting
- repo: https://github.com/igorshubovych/markdownlint-cli
rev: v0.39.0
hooks:
- id: markdownlint
args: [--fix]
exclude: ^web/node_modules/

32
.typos.toml Normal file
View file

@ -0,0 +1,32 @@
# Typos configuration
# https://github.com/crateci/typos
[default]
extend-ignore-regex = [
"node_modules",
"dist",
"build",
"tmp",
".git",
"vendor",
]
[files]
extend-exclude = [
"*.svg",
"*.png",
"*.jpg",
"*.jpeg",
"*.gif",
"*.ico",
"*.woff",
"*.woff2",
"*.ttf",
"*.eot",
]
[default.extend-identifiers]
# Project-specific terms
urpi = "urpi"
gobananas = "go-bananas"
gofirst = "go-first"

68
Dockerfile Normal file
View file

@ -0,0 +1,68 @@
# Build stage for Go backend
FROM golang:1.23-alpine AS backend-builder
WORKDIR /app
# Install build dependencies
RUN apk add --no-cache git make
# Copy go mod files
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Build backend
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /go-first ./cmd/server
# Build stage for React frontend
FROM node:20-alpine AS frontend-builder
WORKDIR /web
# Copy package files
COPY web/package*.json ./
RUN npm ci
# Copy source and build
COPY web/ ./
RUN npm run build
# Final stage
FROM alpine:3.20
# Install runtime dependencies
RUN apk add --no-cache ca-certificates tzdata
# Create non-root user
RUN addgroup -g 1000 -S appgroup && \
adduser -u 1000 -S appuser -G appgroup
WORKDIR /app
# Copy backend binary
COPY --from=backend-builder /go-first .
# Copy frontend build
COPY --from=frontend-builder /web/dist ./web/dist
# Copy config and the project catalog used as the initial import.
COPY config.yaml .
COPY projects.json .
# Create directories for uploads, logs, etc.
RUN mkdir -p /app/uploads /app/logs && \
chown -R appuser:appgroup /app
USER appuser
# Expose port
EXPOSE 8080
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/api/v1/health || exit 1
# Run
ENTRYPOINT ["./go-first"]

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 URPI
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

252
Makefile Normal file
View file

@ -0,0 +1,252 @@
# go-first Makefile
# Web application for URPI - https://www.urpi.be
.PHONY: help build run dev test lint fmt vet tidy docker-build docker-run docker-push deploy clean
# Variables
APP_NAME := go-first
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
BUILD_TIME := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
GO_VERSION := 1.23
NODE_VERSION := 20
# Go variables
GO := go
GOFLAGS := -ldflags="-s -w -X main.version=$(VERSION) -X main.buildTime=$(BUILD_TIME) -X main.gitCommit=$(GIT_COMMIT)"
CGO_ENABLED := 0
# Docker variables
DOCKER_REGISTRY := ghcr.io
DOCKER_IMAGE := $(DOCKER_REGISTRY)/dnoulet/$(APP_NAME)
DOCKER_TAG := $(VERSION)
# Directories
BUILD_DIR := ./build
WEB_DIR := ./web
DIST_DIR := $(WEB_DIR)/dist
SERVER_DIR := ./cmd/server
# Default target
help: ## Show this help message
@echo "$(APP_NAME) - URPI Web Application"
@echo ""
@echo "Usage: make [target]"
@echo ""
@echo "Development:"
@echo " dev Run development servers (Go + Vite)"
@echo " dev-backend Run Go backend only"
@echo " dev-frontend Run Vite frontend only"
@echo " install Install all dependencies"
@echo ""
@echo "Building:"
@echo " build Build production binaries"
@echo " build-backend Build Go backend only"
@echo " build-frontend Build React frontend only"
@echo ""
@echo "Testing & Quality:"
@echo " test Run all tests"
@echo " test-backend Run Go tests"
@echo " test-frontend Run frontend tests"
@echo " lint Run all linters"
@echo " lint-backend Run Go linters"
@echo " lint-frontend Run frontend linters"
@echo " fmt Format all code"
@echo " vet Run go vet"
@echo " tidy Run go mod tidy"
@echo ""
@echo "Docker:"
@echo " docker-build Build Docker image"
@echo " docker-run Run Docker container locally"
@echo " docker-push Push Docker image to registry"
@echo ""
@echo "Deployment:"
@echo " deploy Deploy to production (requires config)"
@echo " deploy-staging Deploy to staging"
@echo ""
@echo "Maintenance:"
@echo " clean Clean build artifacts"
@echo " clean-all Clean everything including node_modules"
@echo " generate Generate code (mocks, etc.)"
# Development
dev: ## Run both backend and frontend in development mode
@echo "Starting development servers..."
@trap 'kill %1; kill %2' INT; \
$(MAKE) dev-backend & \
$(MAKE) dev-frontend & \
wait
dev-backend: ## Run Go backend
@$(GO) run ./cmd/server
dev-frontend: ## Run Vite frontend dev server
@cd $(WEB_DIR) && npm run dev
install: ## Install all dependencies
@echo "Installing Go dependencies..."
@$(GO) mod download
@echo "Installing frontend dependencies..."
@cd $(WEB_DIR) && npm ci
# Building
build: build-backend build-frontend ## Build everything for production
build-backend: ## Build Go backend binary
@echo "Building backend $(VERSION)..."
@mkdir -p $(BUILD_DIR)
@CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(GOFLAGS) -o $(BUILD_DIR)/$(APP_NAME) $(SERVER_DIR)
@echo "Backend built: $(BUILD_DIR)/$(APP_NAME)"
build-frontend: ## Build React frontend
@echo "Building frontend..."
@cd $(WEB_DIR) && npm run build
@echo "Frontend built: $(DIST_DIR)"
# Testing
test: test-backend test-frontend ## Run all tests
test-backend: ## Run Go tests
@echo "Running Go tests..."
@$(GO) test -v -race -coverprofile=coverage.out ./...
@$(GO) tool cover -func=coverage.out | tail -1
test-frontend: ## Run frontend tests
@echo "Running frontend tests..."
@cd $(WEB_DIR) && npm test -- --run
# Linting
lint: lint-backend lint-frontend ## Run all linters
lint-backend: ## Run Go linters
@which golangci-lint > /dev/null || $(GO) install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
@golangci-lint run ./...
lint-frontend: ## Run frontend linters
@echo "Running frontend linters..."
@cd $(WEB_DIR) && npm run lint
fmt: ## Format all code
@echo "Formatting Go code..."
@$(GO) fmt ./...
@echo "Formatting frontend code..."
@cd $(WEB_DIR) && npm run format
vet: ## Run go vet
@$(GO) vet ./...
tidy: ## Run go mod tidy
@$(GO) mod tidy
# Docker
docker-build: ## Build Docker image
@echo "Building Docker image $(DOCKER_IMAGE):$(DOCKER_TAG)..."
@docker build -t $(DOCKER_IMAGE):$(DOCKER_TAG) -t $(DOCKER_IMAGE):latest .
@echo "Built: $(DOCKER_IMAGE):$(DOCKER_TAG)"
docker-run: ## Run Docker container locally
@echo "Running Docker container..."
@docker run --rm -p 8080:8080 \
-e SERVER_MODE=release \
--env-file .env.local \
$(DOCKER_IMAGE):$(DOCKER_TAG)
docker-push: ## Push Docker image to registry
@echo "Pushing Docker image..."
@docker push $(DOCKER_IMAGE):$(DOCKER_TAG)
@docker push $(DOCKER_IMAGE):latest
# Deployment
deploy: ## Deploy to production (requires kubectl/helm configured)
@echo "Deploying to production..."
@./deploy/deploy.sh production
deploy-staging: ## Deploy to staging
@echo "Deploying to staging..."
@./deploy/deploy.sh staging
# Maintenance
clean: ## Clean build artifacts
@echo "Cleaning build artifacts..."
@rm -rf $(BUILD_DIR)
@rm -rf $(DIST_DIR)
@$(GO) clean -cache
clean-all: clean ## Clean everything including dependencies
@echo "Cleaning all..."
@rm -rf $(WEB_DIR)/node_modules
@$(GO) clean -modcache
generate: ## Generate code (mocks, etc.)
@which mockgen > /dev/null || $(GO) install github.com/golang/mock/mockgen@latest
@echo "Generating mocks..."
@$(GO) generate ./...
# CI/CD helpers
ci-install: ## Install CI dependencies
@$(GO) install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
@$(GO) install github.com/air-verse/air@latest
@$(GO) install github.com/golang/mock/mockgen@latest
ci-test: ## Run CI tests
@$(MAKE) lint
@$(MAKE) test
ci-build: ## Build for CI
@$(MAKE) build
# Release
release-patch: ## Bump patch version
@git tag -a v$(shell semver bump patch) -m "Release patch version"
@git push origin --tags
release-minor: ## Bump minor version
@git tag -a v$(shell semver bump minor) -m "Release minor version"
@git push origin --tags
release-major: ## Bump major version
@git tag -a v$(shell semver bump major) -m "Release major version"
@git push origin --tags
# Database (if using migrations)
db-migrate-up: ## Run database migrations up
@which migrate > /dev/null || $(GO) install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
@migrate -path ./migrations -database "$(shell grep -A5 'database:' config.yaml | grep 'dsn' | cut -d: -f2- | xargs)" up
db-migrate-down: ## Run database migrations down
@migrate -path ./migrations -database "$(shell grep -A5 'database:' config.yaml | grep 'dsn' | cut -d: -f2- | xargs)" down
db-migrate-create: ## Create new migration (usage: make db-migrate-create name=add_users_table)
@migrate create -ext sql -dir ./migrations -seq $(name)
# Utilities
version: ## Show version info
@echo "Version: $(VERSION)"
@echo "Build Time: $(BUILD_TIME)"
@echo "Git Commit: $(GIT_COMMIT)"
check-deps: ## Check for outdated dependencies
@$(GO) list -u -m all
@cd $(WEB_DIR) && npm outdated
# Server management
start: build ## Build and start production server
@./$(BUILD_DIR)/$(APP_NAME)
stop: ## Stop running server (if using PID file)
@pkill -f $(APP_NAME) || true
restart: stop start ## Restart server
logs: ## View server logs (if using systemd)
@journalctl -u $(APP_NAME) -f
status: ## Check server status
@systemctl status $(APP_NAME) || docker ps | grep $(APP_NAME)
# SSL/Certificates (using Let's Encrypt)
cert-renew: ## Renew SSL certificates
@certbot renew --nginx
cert-status: ## Check certificate status
@certbot certificates

358
README.md Normal file
View file

@ -0,0 +1,358 @@
# go-first — URPI Web Application
[![Go Version](https://img.shields.io/badge/Go-1.23-00ADD8?logo=go)](https://golang.org)
[![React Version](https://img.shields.io/badge/React-18.3-61DAFB?logo=react)](https://react.dev)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.5-3178C6?logo=typescript)](https://typescriptlang.org)
[![Tailwind CSS](https://img.shields.io/badge/Tailwind-3.4-06B6D4?logo=tailwindcss)](https://tailwindcss.com)
[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED?logo=docker)](https://docker.com)
[![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
> **URPI** — Web Application & Backend Development Experts
> Live at: [https://www.urpi.be](https://www.urpi.be)
A modern, full-stack web application showcasing URPI's expertise in web application and backend development. Built with Go, React, TypeScript, and deployed on cloud-native infrastructure.
## ✨ Features
- **Modern Go Backend** — Clean architecture with Gin, structured logging, and middleware
- **React + TypeScript Frontend** — Vite, Tailwind CSS, and component-driven development
- **RESTful API** — Versioned endpoints for skills, projects, experience, services, and contact
- **Responsive Design** — Mobile-first, accessible, with "go-bananas" inspired bold styling
- **Docker Ready** — Multi-stage builds, docker-compose for local dev and production
- **CI/CD Friendly** — Makefile with all common tasks, GitHub Actions ready
- **Production Hardened** — Security headers, rate limiting, health checks, SSL termination
## 🚀 Quick Start
### Prerequisites
- Go 1.23+
- Node.js 20+
- Docker & Docker Compose (for containerized deployment)
- PostgreSQL 16+ & Redis 7+ (or use Docker)
### Development
```bash
# Clone and enter
git clone https://github.com/dnoulet/go-first.git
cd go-first
# Install dependencies
make install
# Start development servers (backend + frontend)
make dev
# Or run separately:
make dev-backend # Go backend on :8080 with hot reload
make dev-frontend # Vite frontend on :5173 with HMR
```
Visit:
- Frontend: http://localhost:5173
- Backend API: http://localhost:8080/api/v1
- Health check: http://localhost:8080/api/v1/health
### Environment Setup
```bash
# Copy example env files
cp .env.local.example .env.local
cp .env.production.example .env.production
# Edit with your values
vim .env.local
```
### Database (Docker)
```bash
# Start only PostgreSQL and Redis
docker-compose up -d postgres redis
# Run migrations (when implemented)
make db-migrate-up
```
## 🏗️ Project Structure
```
go-first/
├── cmd/server/ # Go application entry point
├── internal/
│ ├── config/ # Configuration loading (YAML + env)
│ ├── handler/ # HTTP handlers (Gin)
│ ├── middleware/ # Custom middleware (logging, CORS, security)
│ ├── model/ # Data models & API responses
│ └── service/ # Business logic (in-memory, swappable for DB)
├── web/ # React frontend
│ ├── src/
│ │ ├── components/ # UI, layout, sections, forms
│ │ ├── hooks/ # Custom React hooks
│ │ ├── services/ # API client
│ │ ├── types/ # TypeScript types
│ │ └── utils/ # Helpers
│ └── public/ # Static assets
├── deploy/ # Deployment configs
│ ├── nginx.conf # NGINX reverse proxy
│ └── deploy.sh # Deployment script
├── docs/ # Documentation
├── Dockerfile # Multi-stage Docker build
├── docker-compose.yml # Local/production stack
├── Makefile # Build, test, deploy commands
├── config.yaml # Application configuration
└── go.mod # Go dependencies
```
## 🛠️ Makefile Commands
```bash
# Development
make dev # Run both backend & frontend
make dev-backend # Backend only (with air hot reload)
make dev-frontend # Frontend only (Vite)
make install # Install all dependencies
# Building
make build # Build everything for production
make build-backend # Build Go binary
make build-frontend # Build React app
# Testing & Quality
make test # Run all tests
make test-backend # Go tests with coverage
make test-frontend # Frontend tests (Vitest)
make lint # Run all linters
make lint-backend # golangci-lint
make lint-frontend # ESLint + Prettier
make fmt # Format all code
make vet # go vet
make tidy # go mod tidy
# Docker
make docker-build # Build Docker image
make docker-run # Run container locally
make docker-push # Push to registry
# Deployment
make deploy # Deploy to production
make deploy-staging # Deploy to staging
# Maintenance
make clean # Clean build artifacts
make clean-all # Clean everything (including node_modules)
make generate # Generate mocks/code
```
## 📡 API Endpoints
All endpoints prefixed with `/api/v1`
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/health` | Health check |
| GET | `/skills` | List all skills (query: `category`) |
| GET | `/skills/:id` | Get single skill |
| GET | `/projects` | List projects (query: `category`, `featured`) |
| GET | `/projects/featured` | Get featured projects |
| GET | `/projects/:id` | Get single project |
| GET | `/experience` | List work experience |
| GET | `/services` | List services offered |
| POST | `/contact` | Submit contact form |
### Example Responses
**GET /api/v1/skills**
```json
{
"success": true,
"data": [
{
"id": "uuid",
"name": "Go",
"category": "Backend",
"proficiency": 5,
"icon": "go",
"order": 1
}
]
}
```
**POST /api/v1/contact**
```json
// Request
{
"name": "John Doe",
"email": "john@example.com",
"subject": "Project Inquiry",
"message": "Hello, I'd like to discuss..."
}
// Response (201)
{
"success": true,
"data": {
"id": "uuid",
"name": "John Doe",
"email": "john@example.com",
"subject": "Project Inquiry",
"message": "Hello, I'd like to discuss...",
"status": "new",
"created_at": "2024-01-15T10:30:00Z"
}
}
```
## 🎨 Styling — "go-bananas" Design System
The frontend uses a custom design system inspired by the **go-bananas** aesthetic:
- **Brand Color**: Vibrant orange (`#ed7315`) — energetic, technical, approachable
- **Typography**: Inter (UI) + Cal Sans (display) + JetBrains Mono (code)
- **Components**: Card-based, subtle animations, glass morphism accents
- **Dark Mode**: Ready (CSS variables, Tailwind dark:)
- **Accessibility**: WCAG AA, focus states, semantic HTML, ARIA labels
### Tailwind Customization
```js
// tailwind.config.js - key extensions
colors: {
brand: { 500: '#ed7315', ... }, // Primary orange
dark: { 900: '#0f172a', ... } // Slate dark mode
},
fontFamily: {
sans: ['Inter', ...],
display: ['Cal Sans', 'Inter', ...],
mono: ['JetBrains Mono', ...]
},
animation: {
'fade-in': 'fadeIn 0.5s ease-out',
'slide-up': 'slideUp 0.6s ease-out',
'float': 'float 6s ease-in-out infinite',
}
```
## 🐳 Docker Deployment
### Local Production Stack
```bash
# Build and start all services
docker-compose up -d --build
# View logs
docker-compose logs -f app
# Stop
docker-compose down
```
### Production Deployment
```bash
# Configure environment
cp .env.production.example .env.production
# Edit with production values
# Deploy
make deploy
# Or manually:
./deploy/deploy.sh production
```
### Kubernetes (Optional)
```bash
# Apply manifests
kubectl apply -k deploy/kubernetes/overlays/production
# Check status
kubectl get pods -n go-first-production
```
## 🔧 Configuration
### config.yaml
```yaml
server:
host: "0.0.0.0"
port: 8080
mode: "debug" # debug | release
read_timeout: 10
write_timeout: 10
database:
host: "localhost"
port: 5432
# ...
jwt:
secret: "your-secret"
expires_in: 24 # hours
cors:
allowed_origins:
- "https://www.urpi.be"
# ...
```
Environment variables override YAML config.
## 🧪 Testing
```bash
# Backend tests
go test -v -race -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
# Frontend tests
cd web && npm test -- --run --coverage
```
## 📦 Dependencies
### Backend (Go)
- `gin-gonic/gin` — HTTP framework
- `rs/zerolog` — Structured logging
- `joho/godotenv` — Environment loading
- `gopkg.in/yaml.v3` — YAML config
### Frontend (React)
- `react`, `react-dom`, `react-router-dom`
- `lucide-react` — Icons
- `clsx`, `tailwind-merge` — Class utilities
- `vite` — Build tool
- `typescript`, `eslint`, `prettier`
## 🤝 Contributing
1. Fork the repository
2. Create a feature branch: `git checkout -b feature/amazing-feature`
3. Commit changes: `git commit -m 'Add amazing feature'`
4. Push to branch: `git push origin feature/amazing-feature`
5. Open a Pull Request
### Code Style
- Go: `gofmt`, `golangci-lint`
- TypeScript: `eslint`, `prettier` (with Tailwind plugin)
- Conventional commits: `feat:`, `fix:`, `docs:`, `refactor:`
## 📄 License
MIT License — see [LICENSE](LICENSE) for details.
## 🌐 URPI
**URPI** — Web Application & Backend Development
Belgium | [https://www.urpi.be](https://www.urpi.be) | contact@urpi.be
> Specializing in Go, Java, React, Cloud Infrastructure, System Architecture, and API Development.
---
Built with ❤️ and lots of ☕ by the URPI team.

118
cmd/server/main.go Normal file
View file

@ -0,0 +1,118 @@
package main
import (
"context"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/dnoulet/go-first/internal/config"
"github.com/dnoulet/go-first/internal/handler"
"github.com/dnoulet/go-first/internal/middleware"
"github.com/dnoulet/go-first/internal/service"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
func main() {
if err := godotenv.Load(); err != nil {
log.Info().Msg("No .env file found, using config.yaml")
}
cfg, err := config.Load("config.yaml")
if err != nil {
log.Fatal().Err(err).Msg("Failed to load config")
}
setupLogging(cfg.Logging)
if cfg.Server.Mode == "release" {
gin.SetMode(gin.ReleaseMode)
}
router := setupRouter(cfg)
srv := &http.Server{
Addr: cfg.Server.Address(),
Handler: router,
ReadTimeout: cfg.Server.ReadTimeoutDuration(),
WriteTimeout: cfg.Server.WriteTimeoutDuration(),
IdleTimeout: cfg.Server.IdleTimeoutDuration(),
}
go func() {
log.Info().Str("address", cfg.Server.Address()).Msg("Starting server")
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal().Err(err).Msg("Failed to start server")
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Info().Msg("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatal().Err(err).Msg("Server forced to shutdown")
}
log.Info().Msg("Server exited")
}
func setupLogging(cfg config.LoggingConfig) {
level, err := zerolog.ParseLevel(cfg.Level)
if err != nil {
level = zerolog.InfoLevel
}
zerolog.SetGlobalLevel(level)
if cfg.Format == "console" || cfg.Output == "stdout" {
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339})
}
}
func setupRouter(cfg *config.Config) *gin.Engine {
router := gin.New()
router.Use(middleware.Recovery())
router.Use(middleware.Logger())
router.Use(middleware.SecurityHeaders())
router.Use(middleware.CORS(cfg.CORS.AllowedOrigins))
skillService := service.NewSkillService()
projectService := service.NewProjectService()
experienceService := service.NewExperienceService()
serviceService := service.NewServiceService()
emailService := service.NewMockEmailService()
contactService := service.NewContactService(emailService)
h := handler.NewHandler(
skillService,
projectService,
experienceService,
serviceService,
contactService,
)
h.RegisterRoutes(router)
router.NoRoute(func(c *gin.Context) {
c.JSON(404, gin.H{
"success": false,
"error": gin.H{
"code": "NOT_FOUND",
"message": "Route not found",
},
})
})
return router
}

68
config.yaml Normal file
View file

@ -0,0 +1,68 @@
server:
host: "0.0.0.0"
port: 8081
mode: "debug"
read_timeout: 10
write_timeout: 10
idle_timeout: 60
database:
host: "localhost"
port: 5432
user: "postgres"
password: "postgres"
name: "go_first"
ssl_mode: "disable"
max_open_conns: 25
max_idle_conns: 5
conn_max_lifetime: 300
redis:
host: "localhost"
port: 6379
password: ""
db: 0
jwt:
secret: "your-super-secret-jwt-key-change-in-production"
expires_in: 24
refresh_expires_in: 168
cors:
allowed_origins:
- "https://www.urpi.be"
- "https://urpi.be"
- "http://localhost:3000"
- "http://localhost:5173"
allowed_methods:
- "GET"
- "POST"
- "PUT"
- "PATCH"
- "DELETE"
- "OPTIONS"
allowed_headers:
- "Origin"
- "Content-Type"
- "Accept"
- "Authorization"
- "X-Requested-With"
allow_credentials: true
max_age: 86400
logging:
level: "debug"
format: "console"
output: "stdout"
email:
smtp_host: "smtp.gmail.com"
smtp_port: 587
username: ""
password: ""
from_address: "noreply@urpi.be"
from_name: "URPI"
frontend:
build_path: "./web/dist"
spa_fallback: true

132
deploy/deploy.sh Executable file
View file

@ -0,0 +1,132 @@
#!/bin/bash
# Deployment script for go-first / URPI
# Usage: ./deploy.sh [production|staging]
set -euo pipefail
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
ENVIRONMENT="${1:-staging}"
PROJECT_NAME="go-first"
DOCKER_IMAGE="ghcr.io/dnoulet/${PROJECT_NAME}"
VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev")
log_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
check_requirements() {
log_info "Checking requirements..."
command -v docker >/dev/null 2>&1 || { log_error "docker is required but not installed."; exit 1; }
command -v docker-compose >/dev/null 2>&1 || { log_error "docker-compose is required but not installed."; exit 1; }
if [[ "$ENVIRONMENT" == "production" ]]; then
command -v kubectl >/dev/null 2>&1 || log_warn "kubectl not found, using docker-compose"
fi
}
build_and_push() {
log_info "Building Docker image: ${DOCKER_IMAGE}:${VERSION}"
docker build -t "${DOCKER_IMAGE}:${VERSION}" -t "${DOCKER_IMAGE}:latest" .
log_info "Pushing Docker image..."
docker push "${DOCKER_IMAGE}:${VERSION}"
docker push "${DOCKER_IMAGE}:latest"
}
deploy_docker_compose() {
log_info "Deploying with docker-compose (${ENVIRONMENT})..."
ENV_FILE=".env.${ENVIRONMENT}"
if [[ ! -f "$ENV_FILE" ]]; then
log_error "Environment file ${ENV_FILE} not found!"
exit 1
fi
# Pull latest images
docker-compose --env-file "$ENV_FILE" pull
# Start services
docker-compose --env-file "$ENV_FILE" up -d --remove-orphans
# Wait for health checks
log_info "Waiting for services to be healthy..."
sleep 10
# Check health
if docker-compose --env-file "$ENV_FILE" ps | grep -q "unhealthy"; then
log_error "Some services are unhealthy!"
docker-compose --env-file "$ENV_FILE" ps
exit 1
fi
log_info "Deployment successful!"
}
deploy_kubernetes() {
log_info "Deploying to Kubernetes (${ENVIRONMENT})..."
# Apply Kubernetes manifests
kubectl apply -k "deploy/kubernetes/overlays/${ENVIRONMENT}"
# Wait for rollout
kubectl rollout status deployment/${PROJECT_NAME} -n ${PROJECT_NAME}-${ENVIRONMENT} --timeout=300s
log_info "Kubernetes deployment successful!"
}
run_migrations() {
log_info "Running database migrations..."
if [[ "$ENVIRONMENT" == "production" ]]; then
# Run migrations in production
docker-compose --env-file ".env.${ENVIRONMENT}" exec -T app ./go-first migrate up
else
docker-compose --env-file ".env.${ENVIRONMENT}" exec -T app ./go-first migrate up
fi
}
cleanup() {
log_info "Cleaning up old Docker images..."
docker image prune -f
}
main() {
log_info "Starting deployment for ${ENVIRONMENT} (version: ${VERSION})"
check_requirements
if [[ "$ENVIRONMENT" != "production" && "$ENVIRONMENT" != "staging" ]]; then
log_error "Invalid environment. Use 'production' or 'staging'"
exit 1
fi
build_and_push
if command -v kubectl >/dev/null 2>&1 && [[ "$ENVIRONMENT" == "production" ]]; then
deploy_kubernetes
else
deploy_docker_compose
fi
run_migrations
cleanup
log_info "Deployment completed successfully!"
log_info "Application available at: https://www.urpi.be"
}
main "$@"

186
deploy/nginx.conf Normal file
View file

@ -0,0 +1,186 @@
# NGINX Configuration for go-first / URPI
# Deploy to /etc/nginx/nginx.conf
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
use epoll;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for" '
'rt=$request_time uct="$upstream_connect_time" '
'uht="$upstream_header_time" urt="$upstream_response_time"';
access_log /var/log/nginx/access.log main;
# Performance
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
server_tokens off;
# Gzip
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 1000;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/atom+xml
image/svg+xml;
# Brotli (if available)
# brotli on;
# brotli_comp_level 6;
# brotli_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
# Rate limiting
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
# Upstream
upstream go_first_backend {
server app:8080;
keepalive 32;
}
# HTTP -> HTTPS redirect
server {
listen 80;
listen [::]:80;
server_name www.urpi.be urpi.be;
# ACME challenge for Let's Encrypt
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Redirect all other traffic to HTTPS
location / {
return 301 https://www.urpi.be$request_uri;
}
}
# HTTPS server
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name www.urpi.be urpi.be;
# SSL Configuration
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
# Modern SSL config
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# HSTS
add_header Strict-Transport-Security "max-age=31536000" always;
# OCSP Stapling
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
# Security headers
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';" always;
# Frontend static files
root /var/www/html;
index index.html;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# API routes
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://go_first_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
# Timeouts
proxy_connect_timeout 5s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
# Buffering
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
}
# Health check endpoint (no rate limit)
location /api/v1/health {
proxy_pass http://go_first_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
access_log off;
}
# SPA fallback - serve index.html for all other routes
location / {
try_files $uri $uri/ /index.html;
# Cache HTML for short time
add_header Cache-Control "no-cache, must-revalidate";
}
# Block access to hidden files
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
# Block access to backup/config files
location ~* \.(env|log|conf|bak|backup|sql)$ {
deny all;
access_log off;
log_not_found off;
}
}
}

95
docker-compose.yml Normal file
View file

@ -0,0 +1,95 @@
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: go-first-app
ports:
- "8080:8080"
environment:
- SERVER_MODE=release
- SERVER_HOST=0.0.0.0
- SERVER_PORT=8080
env_file:
- .env.production
volumes:
- ./config.yaml:/app/config.yaml:ro
- app_uploads:/app/uploads
- app_logs:/app/logs
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/v1/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
- go-first-network
postgres:
image: postgres:16-alpine
container_name: go-first-postgres
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=go_first
volumes:
- postgres_data:/var/lib/postgresql/data
- ./migrations:/docker-entrypoint-initdb.d:ro
ports:
- "5432:5432"
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
networks:
- go-first-network
redis:
image: redis:7-alpine
container_name: go-first-redis
command: redis-server --appendonly yes
volumes:
- redis_data:/data
ports:
- "6379:6379"
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- go-first-network
nginx:
image: nginx:alpine
container_name: go-first-nginx
ports:
- "80:80"
- "443:443"
volumes:
- ./deploy/nginx.conf:/etc/nginx/nginx.conf:ro
- ./deploy/ssl:/etc/nginx/ssl:ro
- ./web/dist:/var/www/html:ro
- nginx_cache:/var/cache/nginx
depends_on:
- app
restart: unless-stopped
networks:
- go-first-network
volumes:
postgres_data:
redis_data:
app_uploads:
app_logs:
nginx_cache:
networks:
go-first-network:
driver: bridge

405
docs/API.md Normal file
View file

@ -0,0 +1,405 @@
# API Documentation — go-first / URPI
Base URL: `https://www.urpi.be/api/v1`
Local: `http://localhost:8080/api/v1`
All responses follow a consistent envelope format.
---
## Response Format
### Success
```json
{
"success": true,
"data": {},
"meta": {
"page": 1,
"page_size": 20,
"total": 100,
"total_pages": 5
}
}
```
### Error
```json
{
"success": false,
"error": {
"code": "ERROR_CODE",
"message": "Human readable message",
"details": "Optional technical details"
}
}
```
---
## Common Error Codes
| Code | HTTP Status | Description |
|------|-------------|-------------|
| `VALIDATION_ERROR` | 400 | Request body validation failed |
| `INVALID_ID` | 400 | UUID format invalid |
| `NOT_FOUND` | 404 | Resource not found |
| `UNAUTHORIZED` | 401 | Authentication required |
| `FORBIDDEN` | 403 | Insufficient permissions |
| `CONFLICT` | 409 | Resource conflict |
| `RATE_LIMITED` | 429 | Too many requests |
| `INTERNAL_ERROR` | 500 | Server error |
| `SERVICE_UNAVAILABLE` | 503 | Service temporarily unavailable |
---
## Endpoints
### Health Check
**GET** `/health`
No authentication required.
#### Response
```json
{
"success": true,
"data": {
"status": "ok",
"service": "go-first",
"version": "1.0.0"
}
}
```
---
### Skills
#### List Skills
**GET** `/skills`
| Query Param | Type | Description |
|-------------|------|-------------|
| `category` | string | Filter by category (Backend, Frontend, Architecture, DevOps, Database, API) |
#### Response
```json
{
"success": true,
"data": [
{
"id": "uuid",
"name": "Go",
"category": "Backend",
"proficiency": 5,
"icon": "go",
"order": 1,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
]
}
```
#### Get Skill
**GET** `/skills/:id`
| Path Param | Type | Description |
|------------|------|-------------|
| `id` | uuid | Skill UUID |
#### Response
```json
{
"success": true,
"data": {
"id": "uuid",
"name": "Go",
"category": "Backend",
"proficiency": 5,
"icon": "go",
"order": 1,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
}
```
---
### Projects
#### List Projects
**GET** `/projects`
| Query Param | Type | Description |
|-------------|------|-------------|
| `category` | string | Filter by category |
| `featured` | boolean | Filter featured projects only |
#### Response
```json
{
"success": true,
"data": [
{
"id": "uuid",
"title": "E-Commerce Platform",
"description": "Full description...",
"short_desc": "Scalable e-commerce platform...",
"tech_stack": ["Go", "React", "PostgreSQL", "Redis", "Kubernetes"],
"image_url": "/images/projects/ecommerce.jpg",
"demo_url": "https://demo.urpi.be/ecommerce",
"repo_url": "https://github.com/urpi/ecommerce-platform",
"category": "Full Stack",
"featured": true,
"order": 1,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
]
}
```
#### Get Featured Projects
**GET** `/projects/featured`
Returns only projects with `featured: true`.
#### Get Project
**GET** `/projects/:id`
| Path Param | Type | Description |
|------------|------|-------------|
| `id` | uuid | Project UUID |
---
### Experience
#### List Experience
**GET** `/experience`
Returns all work experience entries ordered by `order` field.
#### Response
```json
{
"success": true,
"data": [
{
"id": "uuid",
"company": "URPI",
"role": "Founder & Lead Developer",
"location": "Belgium",
"start_date": "2020-01-01T00:00:00Z",
"end_date": null,
"current": true,
"description": "Founded URPI to provide web application...",
"tech_stack": ["Go", "Java", "React", "Kubernetes", "AWS"],
"order": 1,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
]
}
```
---
### Services
#### List Services
**GET** `/services`
Returns all active services offered.
#### Response
```json
{
"success": true,
"data": [
{
"id": "uuid",
"title": "Web Application Development",
"description": "Custom web applications built with modern technologies...",
"icon": "code",
"features": [
"React/Next.js Frontend",
"Go/Java Backend",
"REST/GraphQL APIs"
],
"order": 1,
"active": true,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
]
}
```
---
### Contact
#### Submit Contact Form
**POST** `/contact`
Content-Type: `application/json`
| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `name` | string | Yes | 2-100 chars |
| `email` | string | Yes | Valid email format |
| `subject` | string | Yes | 5-200 chars |
| `message` | string | Yes | 10-5000 chars |
#### Request
```json
{
"name": "John Doe",
"email": "john@example.com",
"subject": "Project Inquiry",
"message": "Hello, I'd like to discuss a potential project..."
}
```
#### Response (201 Created)
```json
{
"success": true,
"data": {
"id": "uuid",
"name": "John Doe",
"email": "john@example.com",
"subject": "Project Inquiry",
"message": "Hello, I'd like to discuss a potential project...",
"status": "new",
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}
}
```
#### Validation Error (400)
```json
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request body",
"details": "Key: 'ContactForm.Email' Error:Field validation for 'Email' failed on the 'email' tag"
}
}
```
---
## Rate Limiting
| Endpoint | Limit |
|----------|-------|
| `/api/v1/*` (general) | 10 req/s per IP |
| `/api/v1/contact` | 5 req/min per IP |
| `/api/v1/health` | No limit |
Headers returned:
- `X-RateLimit-Limit`
- `X-RateLimit-Remaining`
- `X-RateLimit-Reset`
---
## CORS
Allowed origins (configured in `config.yaml`):
- `https://www.urpi.be`
- `https://urpi.be`
- `http://localhost:3000`
- `http://localhost:5173`
Allowed methods: `GET, POST, PUT, PATCH, DELETE, OPTIONS`
Allowed headers: `Origin, Content-Type, Accept, Authorization, X-Requested-With`
Credentials: `true`
---
## Versioning
API version in URL path: `/api/v1/`
Breaking changes will result in `/api/v2/` etc.
Non-breaking additions (new fields, new endpoints) may be added to v1.
---
## Authentication
Currently **no authentication required** for public endpoints.
Future plans:
- JWT-based auth for admin endpoints
- API keys for programmatic access
- OAuth2 for third-party integrations
---
## SDKs / Clients
### JavaScript/TypeScript
```typescript
const API_BASE = '/api/v1';
async function fetchAPI<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(`${API_BASE}${endpoint}`, {
headers: { 'Content-Type': 'application/json', ...options.headers },
...options,
});
const data = await res.json();
if (!res.ok || !data.success) throw new Error(data.error?.message || 'Error');
return data.data;
}
// Usage
const skills = await fetchAPI<Skill[]>('/skills');
const project = await fetchAPI<Project>('/projects/uuid');
await fetchAPI<ContactSubmission>('/contact', {
method: 'POST',
body: JSON.stringify({ name, email, subject, message })
});
```
### Go
```go
type APIResponse struct {
Success bool `json:"success"`
Data interface{} `json:"data,omitempty"`
Error *APIError `json:"error,omitempty"`
}
func GetSkills(ctx context.Context) ([]Skill, error) {
req, _ := http.NewRequestWithContext(ctx, "GET", "https://www.urpi.be/api/v1/skills", nil)
resp, err := http.DefaultClient.Do(req)
// ... parse response
}
```
---
## Changelog
### v1.0.0 (2024-01-15)
- Initial API release
- Skills, Projects, Experience, Services, Contact endpoints
- Rate limiting and CORS configuration
- Health check endpoint

425
docs/DEPLOYMENT.md Normal file
View file

@ -0,0 +1,425 @@
# Deployment Guide — go-first / URPI
This guide covers deploying the go-first application to production at **https://www.urpi.be**.
---
## 🏗️ Architecture Overview
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Users │────▶│ NGINX │────▶│ Go App │
│ (HTTPS) │ │ (SSL/TLS) │ │ (Port 8080)│
└─────────────┘ └─────────────┘ └──────┬──────┘
│
┌─────────────┐ ┌──────▼──────┐
│ PostgreSQL │◀───▶│ Redis │
│ (Port 5432)│ │ (Port 6379)│
└─────────────┘ └─────────────┘
```
---
## 📋 Prerequisites
### Server Requirements
- **OS**: Ubuntu 22.04+ / Debian 12+ / Alpine Linux
- **CPU**: 2+ vCPUs
- **RAM**: 4GB+ (2GB minimum for app + DB)
- **Disk**: 20GB+ SSD
- **Network**: Public IPv4/IPv6, ports 80, 443, 22
### Software
- Docker 24+ & Docker Compose 2+
- Git
- Certbot (Let's Encrypt)
- (Optional) kubectl for Kubernetes
---
## 🔧 Server Setup
### 1. Initial Server Hardening
```bash
# Update system
sudo apt update && sudo apt upgrade -y
# Create deploy user
sudo adduser deploy --gecos "" --disabled-password
sudo usermod -aG docker deploy
sudo usermod -aG sudo deploy
# Setup SSH keys (from your local machine)
ssh-copy-id deploy@your-server-ip
# Configure firewall
sudo ufw allow 22/tcp # SSH
sudo ufw allow 80/tcp # HTTP
sudo ufw allow 443/tcp # HTTPS
sudo ufw enable
```
### 2. Install Docker
```bash
# Install Docker
curl -fsSL https://get.docker.com | sh
# Install Docker Compose
sudo apt install -y docker-compose-plugin
# Verify
docker --version
docker compose version
```
### 3. Install Certbot
```bash
sudo apt install -y certbot python3-certbot-nginx
```
---
## 📦 Application Deployment
### 1. Clone Repository
```bash
cd /opt
sudo git clone https://github.com/dnoulet/go-first.git
sudo chown -R deploy:deploy go-first
cd go-first
```
### 2. Configure Environment
```bash
# Copy production template
cp .env.production.example .env.production
# Edit with your values (use strong passwords!)
vim .env.production
# Required changes:
# - DATABASE_PASSWORD: Strong random password
# - JWT_SECRET: 32+ char random string
# - EMAIL_*: Your SMTP credentials
```
### 3. Generate SSL Certificates
```bash
# Stop any service on port 80
sudo systemctl stop nginx 2>/dev/null || true
# Get certificates
sudo certbot certonly --standalone \
-d www.urpi.be \
-d urpi.be \
--email contact@urpi.be \
--agree-tos --non-interactive
# Certificates will be at:
# /etc/letsencrypt/live/www.urpi.be/fullchain.pem
# /etc/letsencrypt/live/www.urpi.be/privkey.pem
```
### 4. Setup NGINX SSL Directory
```bash
sudo mkdir -p /opt/go-first/deploy/ssl
sudo cp /etc/letsencrypt/live/www.urpi.be/fullchain.pem /opt/go-first/deploy/ssl/
sudo cp /etc/letsencrypt/live/www.urpi.be/privkey.pem /opt/go-first/deploy/ssl/
sudo chown -R deploy:deploy /opt/go-first/deploy/ssl
```
### 5. Deploy with Docker Compose
```bash
cd /opt/go-first
# Build and start
docker compose --env-file .env.production up -d --build
# Verify
docker compose --env-file .env.production ps
docker compose --env-file .env.production logs -f app
```
### 6. Verify Deployment
```bash
# Health check
curl -f https://www.urpi.be/api/v1/health
# Check frontend
curl -f https://www.urpi.be/ | grep -c "URPI"
```
---
## 🔐 SSL Certificate Renewal
### Automatic Renewal (systemd timer)
```bash
# Check certbot timer
sudo systemctl status certbot.timer
# Test renewal
sudo certbot renew --dry-run
```
### Manual Renewal Hook
```bash
# Create renewal hook
sudo tee /etc/letsencrypt/renewal-hooks/deploy/nginx-reload.sh > /dev/null <<'EOF'
#!/bin/bash
cp /etc/letsencrypt/live/www.urpi.be/fullchain.pem /opt/go-first/deploy/ssl/
cp /etc/letsencrypt/live/www.urpi.be/privkey.pem /opt/go-first/deploy/ssl/
docker compose -f /opt/go-first/docker-compose.yml --env-file /opt/go-first/.env.production exec nginx nginx -s reload
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/nginx-reload.sh
```
---
## 🗄️ Database Management
### Backup
```bash
# Create backup script
cat > /opt/go-first/backup.sh <<'EOF'
#!/bin/bash
set -euo pipefail
BACKUP_DIR="/opt/backups/go-first"
DATE=$(date +%Y%m%d_%H%M%S)
mkdir -p "$BACKUP_DIR"
docker compose -f /opt/go-first/docker-compose.yml --env-file /opt/go-first/.env.production \
exec -T postgres pg_dump -U postgres go_first | gzip > "$BACKUP_DIR/go_first_$DATE.sql.gz"
# Keep last 30 days
find "$BACKUP_DIR" -name "*.sql.gz" -mtime +30 -delete
echo "Backup saved to $BACKUP_DIR/go_first_$DATE.sql.gz"
EOF
chmod +x /opt/go-first/backup.sh
# Add to crontab (daily at 2 AM)
(crontab -l 2>/dev/null; echo "0 2 * * * /opt/go-first/backup.sh") | crontab -
```
### Restore
```bash
# Stop app
docker compose -f /opt/go-first/docker-compose.yml --env-file /opt/go-first/.env.production stop app
# Restore
gunzip -c /opt/backups/go-first/go_first_20240115_020000.sql.gz | \
docker compose -f /opt/go-first/docker-compose.yml --env-file /opt/go-first/.env.production exec -T postgres psql -U postgres go_first
# Start app
docker compose -f /opt/go-first/docker-compose.yml --env-file /opt/go-first/.env.production start app
```
---
## 📊 Monitoring & Logging
### Log Aggregation (Optional: Loki + Promtail)
```yaml
# Add to docker-compose.yml
services:
promtail:
image: grafana/promtail:2.9
volumes:
- /var/log:/var/log
- /opt/go-first/deploy/promtail.yml:/etc/promtail/config.yml
depends_on:
- loki
loki:
image: grafana/loki:2.9
volumes:
- loki_data:/loki
```
### Health Checks
```bash
# Application health
curl https://www.urpi.be/api/v1/health
# Database
docker compose exec postgres pg_isready -U postgres
# Redis
docker compose exec redis redis-cli ping
# NGINX
docker compose exec nginx nginx -t
```
### Metrics (Optional: Prometheus + Grafana)
Add to `docker-compose.yml`:
```yaml
prometheus:
image: prom/prometheus:v2.47
volumes:
- ./deploy/prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus_data:/prometheus
ports:
- "9090:9090"
grafana:
image: grafana/grafana:10.2
ports:
- "3000:3000"
volumes:
- grafana_data:/var/lib/grafana
```
---
## 🔄 Updates & Rollbacks
### Update Application
```bash
cd /opt/go-first
# Pull latest code
git pull origin main
# Rebuild and deploy
docker compose --env-file .env.production up -d --build --remove-orphans
# Verify
curl -f https://www.urpi.be/api/v1/health
```
### Rollback
```bash
# List images
docker images ghcr.io/dnoulet/go-first
# Rollback to previous tag
docker tag ghcr.io/dnoulet/go-first:previous-tag ghcr.io/dnoulet/go-first:latest
docker compose --env-file .env.production up -d --no-deps app
```
### Zero-Downtime Deploy (with multiple replicas)
```yaml
# In docker-compose.yml, add:
deploy:
replicas: 2
update_config:
parallelism: 1
delay: 10s
order: start-first
restart_policy:
condition: on-failure
```
---
## 🛡️ Security Checklist
- [ ] Strong DATABASE_PASSWORD (32+ chars)
- [ ] Strong JWT_SECRET (32+ chars, random)
- [ ] SMTP credentials secured
- [ ] Firewall: only 22, 80, 443 open
- [ ] SSH key-only authentication
- [ ] Fail2ban configured for SSH
- [ ] Regular security updates (`apt update && apt upgrade`)
- [ ] Docker images scanned for vulnerabilities (`docker scout cves`)
- [ ] NGINX security headers enabled
- [ ] Rate limiting configured
- [ ] CSP headers configured
- [ ] HSTS enabled
- [ ] Database not exposed publicly
- [ ] Redis not exposed publicly
- [ ] Backup encryption at rest
- [ ] Audit logging enabled
---
## 🚨 Troubleshooting
### Application Won't Start
```bash
# Check logs
docker compose --env-file .env.production logs app
# Common issues:
# - Port 8080 already in use
# - Database connection failed
# - Config file missing
# - Permission denied on volumes
```
### 502 Bad Gateway
```bash
# Check if app is healthy
docker compose --env-file .env.production ps app
# Check NGINX config
docker compose --env-file .env.production exec nginx nginx -t
# Check upstream
docker compose --env-file .env.production exec nginx curl -f http://app:8080/api/v1/health
```
### SSL Certificate Issues
```bash
# Check certificate validity
openssl x509 -in /etc/letsencrypt/live/www.urpi.be/fullchain.pem -text -noout
# Check NGINX SSL config
docker compose --env-file .env.production exec nginx nginx -T | grep ssl
```
### Database Connection Issues
```bash
# Test connection
docker compose --env-file .env.production exec postgres psql -U postgres -d go_first -c "SELECT 1;"
# Check credentials in .env.production match docker-compose.yml
```
---
## 📞 Support
For deployment issues:
- Check logs: `docker compose logs -f`
- Review this guide
- Contact: contact@urpi.be
---
## 📝 Maintenance Windows
Recommended: **Sunday 02:00-04:00 UTC**
- System updates
- Docker image updates
- Database maintenance
- Certificate renewal verification

596
docs/DEVELOPMENT.md Normal file
View file

@ -0,0 +1,596 @@
# Development Guide — go-first / URPI
This guide covers local development setup, workflows, and best practices.
---
## 🛠️ Prerequisites
### Required Tools
| Tool | Version | Install |
|------|---------|---------|
| Go | 1.23+ | `brew install go` / `apt install golang` |
| Node.js | 20+ | `nvm install 20` / `fnm install 20` |
| Docker | 24+ | [docker.com](https://docker.com) |
| Git | 2.40+ | `brew install git` / `apt install git` |
### Recommended Tools
- **IDE**: VS Code with Go + TypeScript extensions
- **API Client**: HTTPie, Postman, or VS Code REST Client
- **Database**: TablePlus, DBeaver, or pgAdmin
- **Terminal**: Warp, Ghostty, or Kitty
---
## 🚀 Getting Started
### 1. Clone & Setup
```bash
git clone https://github.com/dnoulet/go-first.git
cd go-first
# Install all dependencies
make install
# This runs:
# - go mod download
# - cd web && npm ci
```
### 2. Start Development Databases
```bash
# Start PostgreSQL + Redis only
docker compose up -d postgres redis
# Verify
docker compose ps
docker compose exec postgres pg_isready -U postgres
docker compose exec redis redis-cli ping
```
### 3. Configure Local Environment
```bash
cp .env.local.example .env.local
# Edit if needed (defaults work for Docker databases)
vim .env.local
```
### 4. Run Development Servers
```bash
# Option A: Both together (recommended)
make dev
# Option B: Separate terminals
# Terminal 1 - Backend
make dev-backend
# Terminal 2 - Frontend
make dev-frontend
```
### 5. Verify
| Service | URL |
|---------|-----|
| Frontend | http://localhost:5173 |
| Backend API | http://localhost:8080/api/v1 |
| Health Check | http://localhost:8080/api/v1/health |
| PostgreSQL | localhost:5432 |
| Redis | localhost:6379 |
---
## 🔄 Development Workflow
### Backend Development
```bash
# Hot reload with air (already configured in .air.toml)
make dev-backend
# Or manually:
go run ./cmd/server
```
#### Adding a New Endpoint
1. **Model** — Add types in `internal/model/models.go`
2. **Service** — Create service in `internal/service/`
3. **Handler** — Add handler in `internal/handler/handler.go`
4. **Routes** — Register in `handler.RegisterRoutes()`
5. **Tests** — Add tests in `*_test.go` files
### Frontend Development
```bash
# Hot module replacement (Vite)
make dev-frontend
# Or manually:
cd web && npm run dev
```
#### Adding a New Page/Section
1. **Types** — Add to `web/src/types/index.ts`
2. **API** — Add endpoint to `web/src/services/api.ts`
3. **Hook** — Create custom hook in `web/src/hooks/`
4. **Component** — Create in `web/src/components/sections/`
5. **Register** — Import and add to `web/src/App.tsx`
### Code Generation
```bash
# Generate mocks for testing
make generate
# This runs: go generate ./...
# Uses: github.com/golang/mock/mockgen
```
---
## 🧪 Testing
### Backend Tests
```bash
# Run all tests with coverage
make test-backend
# Run specific package
go test -v ./internal/service/...
# Run with race detector
go test -race ./...
# Generate coverage report
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
```
### Frontend Tests
```bash
# Run tests
make test-frontend
# Or manually:
cd web && npm test
# With UI
cd web && npm test -- --ui
# Coverage
cd web && npm test -- --coverage
```
### E2E Tests (Optional)
```bash
# Install Playwright
cd web && npx playwright install
# Run E2E tests
cd web && npm run test:e2e
```
---
## 🔍 Code Quality
### Linting
```bash
# All linters
make lint
# Backend only
make lint-backend
# Runs: golangci-lint run ./...
# Frontend only
make lint-frontend
# Runs: eslint + prettier check
```
### Formatting
```bash
# Format all code
make fmt
# Backend
go fmt ./...
goimports -w .
# Frontend
cd web && npm run format
```
### Pre-commit Hooks
```bash
# Install pre-commit
pip install pre-commit
pre-commit install
# Run manually
pre-commit run --all-files
```
#### `.pre-commit-config.yaml`
```yaml
repos:
- repo: https://github.com/golangci/golangci-lint
rev: v1.56.0
hooks:
- id: golangci-lint
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v3.1.0
hooks:
- id: prettier
types: [typescript, json, yaml, markdown]
- repo: local
hooks:
- id: go-fmt
name: go fmt
entry: go fmt ./...
language: system
types: [go]
```
---
## 🗃️ Database Migrations
### Creating Migrations
```bash
# Create new migration
make db-migrate-create name=add_users_table
# This creates:
# migrations/000001_add_users_table.up.sql
# migrations/000001_add_users_table.down.sql
```
### Running Migrations
```bash
# Apply all pending
make db-migrate-up
# Rollback last
make db-migrate-down
# Check status
migrate -path ./migrations -database "$DSN" version
```
### Migration Best Practices
- **Up migrations**: Add columns, tables, indexes
- **Down migrations**: Reverse exactly (drop what you added)
- **Never modify** applied migrations
- **Test** both up and down locally
- **Name clearly**: `add_user_email_index`, not `fix_db`
---
## 🐳 Docker Development
### Build Locally
```bash
# Build production image
make docker-build
# Run production image locally
make docker-run
# Or with docker-compose (includes DB)
docker compose up -d --build
```
### Debugging in Container
```bash
# Run with shell
docker run -it --rm -p 8080:8080 ghcr.io/dnoulet/go-first:latest sh
# Or attach to running
docker exec -it go-first-app sh
```
### Development with Docker Compose Override
Create `docker-compose.override.yml`:
```yaml
services:
app:
build:
context: .
dockerfile: Dockerfile.dev
volumes:
- .:/app
- /app/web/node_modules
command: air -c .air.toml
environment:
- SERVER_MODE=debug
```
---
## 🎨 Frontend Architecture
### Component Structure
```
web/src/components/
├── ui/ # Reusable primitives (Button, Card, Input, Badge)
├── layout/ # Page structure (Header, Footer)
├── sections/ # Page sections (Hero, Services, Projects, etc.)
└── forms/ # Form-specific components
```
### State Management
- **Server state**: `useApi` hook (React Query pattern)
- **Local state**: `useState`, `useReducer`
- **Forms**: Controlled components with validation
- **No global store** (Redux/Zustand) — keep it simple
### Styling
- **Tailwind CSS** utility-first
- **Custom design system** in `tailwind.config.js`
- **CSS variables** for theming (dark mode ready)
- **Component variants** via `clsx` + `tailwind-merge`
### Adding Icons
```bash
# Use lucide-react (already installed)
import { IconName } from 'lucide-react'
# Or add to public/ and use <img>
```
---
## 🔧 Backend Architecture
### Clean Architecture Layers
```
cmd/server/ # Entry point, wiring
internal/
├── config/ # Configuration (YAML + env)
├── handler/ # HTTP layer (Gin)
├── middleware/ # Cross-cutting concerns
├── model/ # Domain models, DTOs
└── service/ # Business logic
```
### Dependency Injection
Services are instantiated in `main.go` and passed to handlers:
```go
skillService := service.NewSkillService()
h := handler.NewHandler(skillService, ...)
```
### Adding Database Support
1. Add DB config to `config.yaml`
2. Create `internal/repository/` package
3. Implement interfaces in `service/`
4. Use `database/sql` or `sqlx` or `gorm`
5. Add migrations
### Middleware Chain
```go
router.Use(
middleware.Recovery(), // Panic recovery
middleware.Logger(), // Request logging
middleware.SecurityHeaders(), // Security headers
middleware.CORS(...), // CORS
)
```
---
## 📝 Git Workflow
### Branch Naming
| Type | Pattern | Example |
|------|---------|---------|
| Feature | `feat/short-description` | `feat/contact-form-validation` |
| Fix | `fix/short-description` | `fix/cors-header-missing` |
| Docs | `docs/short-description` | `docs/update-api-docs` |
| Refactor | `refactor/short-description` | `refactor/service-layer` |
| Chore | `chore/short-description` | `chore/update-dependencies` |
### Commit Messages
Follow [Conventional Commits](https://www.conventionalcommits.org/):
```
feat: add contact form validation
fix: resolve CORS issue on production
docs: update API documentation
refactor: extract service interfaces
chore: update Go dependencies
```
### Pull Requests
1. Create feature branch from `main`
2. Make changes with tests
3. Run `make lint && make test`
4. Push and open PR
5. Require: CI passing, 1 approval
6. Squash merge to `main`
---
## 🐛 Debugging
### Backend Debugging
```bash
# Delve debugger
dlv debug ./cmd/server
# VS Code launch.json
{
"type": "go",
"request": "launch",
"mode": "debug",
"program": "${workspaceFolder}/cmd/server",
"env": {
"SERVER_MODE": "debug"
}
}
```
### Frontend Debugging
```bash
# React DevTools (browser extension)
# Redux DevTools (if using)
# VS Code debug config:
{
"type": "chrome",
"request": "launch",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}/web/src"
}
```
### Database Debugging
```bash
# Connect to PostgreSQL
docker compose exec postgres psql -U postgres -d go_first
# Common queries
\dt # List tables
\d users # Describe table
SELECT * FROM skills; # Query data
```
---
## 📦 Dependency Management
### Go Dependencies
```bash
# Add dependency
go get github.com/new/package
# Update all
go get -u ./...
go mod tidy
# Check outdated
make check-deps
```
### Frontend Dependencies
```bash
# Add dependency
cd web && npm install package-name
# Add dev dependency
cd web && npm install -D package-name
# Update
cd web && npm update
# Audit
cd web && npm audit
```
---
## 🚀 Performance Tips
### Backend
- Use connection pooling (configured in config)
- Add Redis caching for expensive queries
- Enable gzip compression (NGINX)
- Use `pprof` for profiling: `import _ "net/http/pprof"`
### Frontend
- Code splitting (Vite does this automatically)
- Lazy load sections: `const Section = lazy(() => import('./Section'))`
- Optimize images (WebP, proper sizing)
- Use `React.memo` for expensive components
---
## 🔐 Environment Variables
### Required for Development
| Variable | Description | Default |
|----------|-------------|---------|
| `SERVER_MODE` | `debug` or `release` | `debug` |
| `DATABASE_HOST` | PostgreSQL host | `localhost` |
| `DATABASE_PASSWORD` | PostgreSQL password | `postgres` |
| `JWT_SECRET` | JWT signing key | `dev-secret` |
| `CORS_ALLOWED_ORIGINS` | Comma-separated origins | `http://localhost:3000,http://localhost:5173` |
### Optional
| Variable | Description |
|----------|-------------|
| `EMAIL_SMTP_*` | SMTP config for contact form emails |
| `LOGGING_LEVEL` | `debug`, `info`, `warn`, `error` |
| `REDIS_*` | Redis connection (for caching/sessions) |
---
## 📚 Resources
- [Go Project Layout](https://github.com/golang-standards/project-layout)
- [Effective Go](https://go.dev/doc/effective_go)
- [React Beta Docs](https://react.dev)
- [Tailwind CSS Docs](https://tailwindcss.com/docs)
- [Gin Framework](https://gin-gonic.com/docs/)
- [Zerolog](https://github.com/rs/zerolog)
- [Vite Guide](https://vitejs.dev/guide/)
---
## ❓ FAQ
**Q: How do I add a new API field?**
A: Update model → service → handler → frontend types → component
**Q: Why is my frontend not updating?**
A: Check Vite HMR, clear browser cache, check console for errors
**Q: How do I test email sending locally?**
A: Use mock email service (default) or configure Mailhog/SMTP
**Q: Can I use a different database?**
A: Yes, implement repository interface in `internal/repository/`
**Q: How do I deploy a preview environment?**
A: Use `make deploy-staging` or create PR preview with GitHub Actions

40
go.mod Normal file
View file

@ -0,0 +1,40 @@
module github.com/dnoulet/go-first
go 1.23
require (
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/rs/zerolog v1.33.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
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/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // 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/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
google.golang.org/protobuf v1.34.1 // indirect
)

105
go.sum Normal file
View file

@ -0,0 +1,105 @@
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
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/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
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/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
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/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-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
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=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
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/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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.12.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/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/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=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

134
internal/config/config.go Normal file
View file

@ -0,0 +1,134 @@
package config
import (
"os"
"strconv"
"time"
"gopkg.in/yaml.v3"
)
type Config struct {
Server ServerConfig `yaml:"server"`
Database DatabaseConfig `yaml:"database"`
Redis RedisConfig `yaml:"redis"`
JWT JWTConfig `yaml:"jwt"`
CORS CORSConfig `yaml:"cors"`
Logging LoggingConfig `yaml:"logging"`
Email EmailConfig `yaml:"email"`
Frontend FrontendConfig `yaml:"frontend"`
}
type ServerConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
Mode string `yaml:"mode"`
ReadTimeout int `yaml:"read_timeout"`
WriteTimeout int `yaml:"write_timeout"`
IdleTimeout int `yaml:"idle_timeout"`
}
func (s *ServerConfig) Address() string {
return s.Host + ":" + strconv.Itoa(s.Port)
}
func (s *ServerConfig) ReadTimeoutDuration() time.Duration {
return time.Duration(s.ReadTimeout) * time.Second
}
func (s *ServerConfig) WriteTimeoutDuration() time.Duration {
return time.Duration(s.WriteTimeout) * time.Second
}
func (s *ServerConfig) IdleTimeoutDuration() time.Duration {
return time.Duration(s.IdleTimeout) * time.Second
}
type DatabaseConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
User string `yaml:"user"`
Password string `yaml:"password"`
Name string `yaml:"name"`
SSLMode string `yaml:"ssl_mode"`
MaxOpenConns int `yaml:"max_open_conns"`
MaxIdleConns int `yaml:"max_idle_conns"`
ConnMaxLifetime int `yaml:"conn_max_lifetime"`
}
func (d *DatabaseConfig) DSN() string {
return "host=" + d.Host + " port=" + strconv.Itoa(d.Port) + " user=" + d.User + " password=" + d.Password + " dbname=" + d.Name + " sslmode=" + d.SSLMode
}
type RedisConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
Password string `yaml:"password"`
DB int `yaml:"db"`
}
func (r *RedisConfig) Address() string {
return r.Host + ":" + strconv.Itoa(r.Port)
}
type JWTConfig struct {
Secret string `yaml:"secret"`
ExpiresIn int `yaml:"expires_in"`
RefreshExpiresIn int `yaml:"refresh_expires_in"`
}
func (j *JWTConfig) ExpiresInDuration() time.Duration {
return time.Duration(j.ExpiresIn) * time.Hour
}
func (j *JWTConfig) RefreshExpiresInDuration() time.Duration {
return time.Duration(j.RefreshExpiresIn) * time.Hour
}
type CORSConfig struct {
AllowedOrigins []string `yaml:"allowed_origins"`
AllowedMethods []string `yaml:"allowed_methods"`
AllowedHeaders []string `yaml:"allowed_headers"`
AllowCredentials bool `yaml:"allow_credentials"`
MaxAge int `yaml:"max_age"`
}
type LoggingConfig struct {
Level string `yaml:"level"`
Format string `yaml:"format"`
Output string `yaml:"output"`
}
type EmailConfig struct {
SMTPHost string `yaml:"smtp_host"`
SMTPPort int `yaml:"smtp_port"`
Username string `yaml:"username"`
Password string `yaml:"password"`
FromAddress string `yaml:"from_address"`
FromName string `yaml:"from_name"`
}
type FrontendConfig struct {
BuildPath string `yaml:"build_path"`
SPAFallback bool `yaml:"spa_fallback"`
}
var cfg *Config
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
cfg = &Config{}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, err
}
return cfg, nil
}
func Get() *Config {
return cfg
}

162
internal/handler/handler.go Normal file
View file

@ -0,0 +1,162 @@
package handler
import (
"net/http"
"github.com/dnoulet/go-first/internal/model"
"github.com/dnoulet/go-first/internal/service"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
)
type Handler struct {
skillService *service.SkillService
projectService *service.ProjectService
experienceService *service.ExperienceService
serviceService *service.ServiceService
contactService *service.ContactService
}
func NewHandler(
skillService *service.SkillService,
projectService *service.ProjectService,
experienceService *service.ExperienceService,
serviceService *service.ServiceService,
contactService *service.ContactService,
) *Handler {
return &Handler{
skillService: skillService,
projectService: projectService,
experienceService: experienceService,
serviceService: serviceService,
contactService: contactService,
}
}
func (h *Handler) RegisterRoutes(r *gin.Engine) {
api := r.Group("/api/v1")
{
api.GET("/health", h.HealthCheck)
api.GET("/skills", h.GetSkills)
api.GET("/skills/:id", h.GetSkill)
api.GET("/projects", h.GetProjects)
api.GET("/projects/featured", h.GetFeaturedProjects)
api.GET("/projects/:id", h.GetProject)
api.GET("/experience", h.GetExperience)
api.GET("/services", h.GetServices)
api.POST("/contact", h.SubmitContact)
}
}
func (h *Handler) HealthCheck(c *gin.Context) {
c.JSON(http.StatusOK, model.SuccessResponse(gin.H{
"status": "ok",
"service": "go-first",
"version": "1.0.0",
}))
}
func (h *Handler) GetSkills(c *gin.Context) {
category := c.Query("category")
skills, err := h.skillService.GetAll(c.Request.Context(), category)
if err != nil {
log.Error().Err(err).Msg("Failed to get skills")
c.JSON(http.StatusInternalServerError, model.ErrorResponse("INTERNAL_ERROR", "Failed to retrieve skills", err.Error()))
return
}
c.JSON(http.StatusOK, model.SuccessResponse(skills))
}
func (h *Handler) GetSkill(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, model.ErrorResponse("INVALID_ID", "Invalid skill ID", err.Error()))
return
}
skill, err := h.skillService.GetByID(c.Request.Context(), id)
if err != nil {
log.Error().Err(err).Str("id", idStr).Msg("Failed to get skill")
c.JSON(http.StatusNotFound, model.ErrorResponse("NOT_FOUND", "Skill not found", err.Error()))
return
}
c.JSON(http.StatusOK, model.SuccessResponse(skill))
}
func (h *Handler) GetProjects(c *gin.Context) {
category := c.Query("category")
featured := c.Query("featured") == "true"
projects, err := h.projectService.GetAll(c.Request.Context(), category, featured)
if err != nil {
log.Error().Err(err).Msg("Failed to get projects")
c.JSON(http.StatusInternalServerError, model.ErrorResponse("INTERNAL_ERROR", "Failed to retrieve projects", err.Error()))
return
}
c.JSON(http.StatusOK, model.SuccessResponse(projects))
}
func (h *Handler) GetFeaturedProjects(c *gin.Context) {
projects, err := h.projectService.GetFeatured(c.Request.Context())
if err != nil {
log.Error().Err(err).Msg("Failed to get featured projects")
c.JSON(http.StatusInternalServerError, model.ErrorResponse("INTERNAL_ERROR", "Failed to retrieve featured projects", err.Error()))
return
}
c.JSON(http.StatusOK, model.SuccessResponse(projects))
}
func (h *Handler) GetProject(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, model.ErrorResponse("INVALID_ID", "Invalid project ID", err.Error()))
return
}
project, err := h.projectService.GetByID(c.Request.Context(), id)
if err != nil {
log.Error().Err(err).Str("id", idStr).Msg("Failed to get project")
c.JSON(http.StatusNotFound, model.ErrorResponse("NOT_FOUND", "Project not found", err.Error()))
return
}
c.JSON(http.StatusOK, model.SuccessResponse(project))
}
func (h *Handler) GetExperience(c *gin.Context) {
experience, err := h.experienceService.GetAll(c.Request.Context())
if err != nil {
log.Error().Err(err).Msg("Failed to get experience")
c.JSON(http.StatusInternalServerError, model.ErrorResponse("INTERNAL_ERROR", "Failed to retrieve experience", err.Error()))
return
}
c.JSON(http.StatusOK, model.SuccessResponse(experience))
}
func (h *Handler) GetServices(c *gin.Context) {
services, err := h.serviceService.GetAll(c.Request.Context())
if err != nil {
log.Error().Err(err).Msg("Failed to get services")
c.JSON(http.StatusInternalServerError, model.ErrorResponse("INTERNAL_ERROR", "Failed to retrieve services", err.Error()))
return
}
c.JSON(http.StatusOK, model.SuccessResponse(services))
}
func (h *Handler) SubmitContact(c *gin.Context) {
var req model.ContactForm
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.ErrorResponse("VALIDATION_ERROR", "Invalid request body", err.Error()))
return
}
submission, err := h.contactService.Create(c.Request.Context(), &req)
if err != nil {
log.Error().Err(err).Msg("Failed to create contact submission")
c.JSON(http.StatusInternalServerError, model.ErrorResponse("INTERNAL_ERROR", "Failed to submit contact form", err.Error()))
return
}
c.JSON(http.StatusCreated, model.SuccessResponse(submission))
}

View file

@ -0,0 +1,100 @@
package middleware
import (
"time"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
)
func Logger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
raw := c.Request.URL.RawQuery
c.Next()
latency := time.Since(start)
clientIP := c.ClientIP()
method := c.Request.Method
statusCode := c.Writer.Status()
bodySize := c.Writer.Size()
if raw != "" {
path = path + "?" + raw
}
log.Info().
Str("method", method).
Str("path", path).
Int("status", statusCode).
Dur("latency", latency).
Str("client_ip", clientIP).
Int("body_size", bodySize).
Msg("HTTP request")
}
}
func Recovery() gin.HandlerFunc {
return gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {
log.Error().
Interface("error", recovered).
Str("path", c.Request.URL.Path).
Msg("Panic recovered")
c.JSON(500, gin.H{
"success": false,
"error": gin.H{
"code": "INTERNAL_ERROR",
"message": "Internal server error",
},
})
c.Abort()
})
}
func CORS(allowedOrigins []string) gin.HandlerFunc {
return func(c *gin.Context) {
origin := c.Request.Header.Get("Origin")
allowed := false
for _, o := range allowedOrigins {
if o == "*" || o == origin {
allowed = true
break
}
}
if allowed {
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
}
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, PATCH, DELETE")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
func SecurityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set("X-Content-Type-Options", "nosniff")
c.Writer.Header().Set("X-Frame-Options", "DENY")
c.Writer.Header().Set("X-XSS-Protection", "1; mode=block")
c.Writer.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
c.Writer.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'")
c.Next()
}
}
func RateLimiter(requests int, window time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
}
}

128
internal/model/models.go Normal file
View file

@ -0,0 +1,128 @@
package model
import (
"time"
"github.com/google/uuid"
)
type Skill struct {
ID uuid.UUID `json:"id" db:"id"`
Name string `json:"name" db:"name" validate:"required"`
Category string `json:"category" db:"category" validate:"required"`
Proficiency int `json:"proficiency" db:"proficiency" validate:"min=1,max=5"`
Icon string `json:"icon,omitempty" db:"icon"`
Order int `json:"order" db:"sort_order"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type Project struct {
ID uuid.UUID `json:"id" db:"id"`
Title string `json:"title" db:"title" validate:"required"`
Description string `json:"description" db:"description" validate:"required"`
ShortDesc string `json:"short_desc" db:"short_desc"`
TechStack []string `json:"tech_stack" db:"tech_stack"`
ImageURL string `json:"image_url,omitempty" db:"image_url"`
DemoURL string `json:"demo_url,omitempty" db:"demo_url"`
RepoURL string `json:"repo_url,omitempty" db:"repo_url"`
Category string `json:"category" db:"category"`
Featured bool `json:"featured" db:"featured"`
Order int `json:"order" db:"sort_order"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type Experience struct {
ID uuid.UUID `json:"id" db:"id"`
Company string `json:"company" db:"company" validate:"required"`
Role string `json:"role" db:"role" validate:"required"`
Location string `json:"location" db:"location"`
StartDate time.Time `json:"start_date" db:"start_date" validate:"required"`
EndDate *time.Time `json:"end_date,omitempty" db:"end_date"`
Current bool `json:"current" db:"current"`
Description string `json:"description" db:"description"`
TechStack []string `json:"tech_stack" db:"tech_stack"`
Order int `json:"order" db:"sort_order"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type Service struct {
ID uuid.UUID `json:"id" db:"id"`
Title string `json:"title" db:"title" validate:"required"`
Description string `json:"description" db:"description" validate:"required"`
Icon string `json:"icon,omitempty" db:"icon"`
Features []string `json:"features" db:"features"`
Order int `json:"order" db:"sort_order"`
Active bool `json:"active" db:"active"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type ContactForm struct {
Name string `json:"name" validate:"required,min=2,max=100"`
Email string `json:"email" validate:"required,email"`
Subject string `json:"subject" validate:"required,min=5,max=200"`
Message string `json:"message" validate:"required,min=10,max=5000"`
}
type ContactSubmission struct {
ID uuid.UUID `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Email string `json:"email" db:"email"`
Subject string `json:"subject" db:"subject"`
Message string `json:"message" db:"message"`
Status string `json:"status" db:"status"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type APIResponse struct {
Success bool `json:"success"`
Data interface{} `json:"data,omitempty"`
Error *APIError `json:"error,omitempty"`
Meta *Meta `json:"meta,omitempty"`
}
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
Details string `json:"details,omitempty"`
}
type Meta struct {
Page int `json:"page,omitempty"`
PageSize int `json:"page_size,omitempty"`
Total int `json:"total,omitempty"`
TotalPages int `json:"total_pages,omitempty"`
}
func SuccessResponse(data interface{}) *APIResponse {
return &APIResponse{Success: true, Data: data}
}
func ErrorResponse(code, message, details string) *APIResponse {
return &APIResponse{
Success: false,
Error: &APIError{
Code: code,
Message: message,
Details: details,
},
}
}
func PaginatedResponse(data interface{}, page, pageSize, total int) *APIResponse {
totalPages := (total + pageSize - 1) / pageSize
return &APIResponse{
Success: true,
Data: data,
Meta: &Meta{
Page: page,
PageSize: pageSize,
Total: total,
TotalPages: totalPages,
},
}
}

View file

@ -0,0 +1,105 @@
package service
import (
"context"
"sync"
"time"
"github.com/dnoulet/go-first/internal/model"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
)
type ContactService struct {
mu sync.RWMutex
submissions map[uuid.UUID]*model.ContactSubmission
emailService EmailService
}
type EmailService interface {
SendContactNotification(submission *model.ContactSubmission) error
SendAutoReply(submission *model.ContactSubmission) error
}
func NewContactService(emailService EmailService) *ContactService {
return &ContactService{
submissions: make(map[uuid.UUID]*model.ContactSubmission),
emailService: emailService,
}
}
func (s *ContactService) Create(ctx context.Context, req *model.ContactForm) (*model.ContactSubmission, error) {
submission := &model.ContactSubmission{
ID: uuid.New(),
Name: req.Name,
Email: req.Email,
Subject: req.Subject,
Message: req.Message,
Status: "new",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
s.mu.Lock()
s.submissions[submission.ID] = submission
s.mu.Unlock()
if s.emailService != nil {
go func() {
if err := s.emailService.SendContactNotification(submission); err != nil {
log.Error().Err(err).Msg("Failed to send contact notification email")
}
if err := s.emailService.SendAutoReply(submission); err != nil {
log.Error().Err(err).Msg("Failed to send auto-reply email")
}
}()
}
return submission, nil
}
func (s *ContactService) GetAll(ctx context.Context) ([]*model.ContactSubmission, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var result []*model.ContactSubmission
for _, sub := range s.submissions {
result = append(result, sub)
}
return result, nil
}
func (s *ContactService) GetByID(ctx context.Context, id uuid.UUID) (*model.ContactSubmission, error) {
s.mu.RLock()
defer s.mu.RUnlock()
sub, ok := s.submissions[id]
if !ok {
return nil, ErrNotFound
}
return sub, nil
}
func (s *ContactService) UpdateStatus(ctx context.Context, id uuid.UUID, status string) (*model.ContactSubmission, error) {
s.mu.Lock()
defer s.mu.Unlock()
sub, ok := s.submissions[id]
if !ok {
return nil, ErrNotFound
}
sub.Status = status
sub.UpdatedAt = time.Now()
return sub, nil
}
func (s *ContactService) Delete(ctx context.Context, id uuid.UUID) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.submissions[id]; !ok {
return ErrNotFound
}
delete(s.submissions, id)
return nil
}

View file

@ -0,0 +1,77 @@
package service
import (
"context"
"fmt"
"github.com/dnoulet/go-first/internal/model"
"github.com/rs/zerolog/log"
)
type MockEmailService struct{}
func NewMockEmailService() *MockEmailService {
return &MockEmailService{}
}
func (m *MockEmailService) SendContactNotification(submission *model.ContactSubmission) error {
log.Info().
Str("to", "contact@urpi.be").
Str("subject", "New Contact Form Submission: "+submission.Subject).
Str("from_name", submission.Name).
Str("from_email", submission.Email).
Msg("Sending contact notification email (mock)")
return nil
}
func (m *MockEmailService) SendAutoReply(submission *model.ContactSubmission) error {
log.Info().
Str("to", submission.Email).
Str("subject", "Re: "+submission.Subject).
Msg("Sending auto-reply email (mock)")
return nil
}
type SMTPEmailService struct {
host string
port int
username string
password string
from string
fromName string
}
func NewSMTPEmailService(host string, port int, username, password, from, fromName string) *SMTPEmailService {
return &SMTPEmailService{
host: host,
port: port,
username: username,
password: password,
from: from,
fromName: fromName,
}
}
func (s *SMTPEmailService) SendContactNotification(submission *model.ContactSubmission) error {
log.Info().
Str("to", "contact@urpi.be").
Str("subject", "New Contact Form Submission: "+submission.Subject).
Msg("Sending contact notification email via SMTP")
return nil
}
func (s *SMTPEmailService) SendAutoReply(submission *model.ContactSubmission) error {
log.Info().
Str("to", submission.Email).
Str("subject", "Re: "+submission.Subject).
Msg("Sending auto-reply email via SMTP")
return nil
}
func (s *SMTPEmailService) sendEmail(ctx context.Context, to, subject, body string) error {
log.Debug().
Str("to", to).
Str("subject", subject).
Msg("Sending email via SMTP")
return fmt.Errorf("SMTP not implemented")
}

View file

@ -0,0 +1,16 @@
package service
import "errors"
var (
ErrNotFound = errors.New("resource not found")
ErrAlreadyExists = errors.New("resource already exists")
ErrInvalidInput = errors.New("invalid input")
ErrUnauthorized = errors.New("unauthorized")
ErrForbidden = errors.New("forbidden")
ErrInternal = errors.New("internal server error")
ErrValidation = errors.New("validation failed")
ErrConflict = errors.New("conflict")
ErrRateLimited = errors.New("rate limited")
ErrServiceUnavailable = errors.New("service unavailable")
)

View file

@ -0,0 +1,137 @@
package service
import (
"context"
"sync"
"time"
"github.com/dnoulet/go-first/internal/model"
"github.com/google/uuid"
)
type ExperienceService struct {
mu sync.RWMutex
experiences map[uuid.UUID]*model.Experience
}
func NewExperienceService() *ExperienceService {
s := &ExperienceService{
experiences: make(map[uuid.UUID]*model.Experience),
}
s.seedData()
return s
}
func (s *ExperienceService) seedData() {
experiences := []*model.Experience{
{
ID: uuid.New(),
Company: "URPI",
Role: "Founder & Lead Developer",
Location: "Belgium",
StartDate: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC),
EndDate: nil,
Current: true,
Description: "Founded URPI to provide web application and backend development services. Specializing in Go, Java, and cloud-native architectures. Delivered projects for clients in fintech, e-commerce, and SaaS sectors.",
TechStack: []string{"Go", "Java", "React", "Kubernetes", "AWS", "PostgreSQL", "Redis", "gRPC"},
Order: 1,
},
{
ID: uuid.New(),
Company: "TechCorp Solutions",
Role: "Senior Backend Engineer",
Location: "Brussels, Belgium",
StartDate: time.Date(2017, 6, 1, 0, 0, 0, 0, time.UTC),
EndDate: ptrTime(time.Date(2019, 12, 31, 0, 0, 0, 0, time.UTC)),
Current: false,
Description: "Led backend development for high-scale financial services platform. Designed and implemented microservices architecture, CI/CD pipelines, and monitoring infrastructure. Mentored junior developers and conducted code reviews.",
TechStack: []string{"Java", "Spring Boot", "Kotlin", "PostgreSQL", "Apache Kafka", "Docker", "Kubernetes", "AWS"},
Order: 2,
},
{
ID: uuid.New(),
Company: "StartupXYZ",
Role: "Full Stack Developer",
Location: "Antwerp, Belgium",
StartDate: time.Date(2015, 3, 1, 0, 0, 0, 0, time.UTC),
EndDate: ptrTime(time.Date(2017, 5, 31, 0, 0, 0, 0, time.UTC)),
Current: false,
Description: "Built and maintained full-stack web applications for various clients. Worked with React, Node.js, Python, and Ruby on Rails. Implemented REST APIs, real-time features, and automated testing pipelines.",
TechStack: []string{"React", "Node.js", "Python", "Ruby on Rails", "PostgreSQL", "MongoDB", "AWS", "Docker"},
Order: 3,
},
{
ID: uuid.New(),
Company: "Digital Agency",
Role: "Junior Developer",
Location: "Ghent, Belgium",
StartDate: time.Date(2013, 9, 1, 0, 0, 0, 0, time.UTC),
EndDate: ptrTime(time.Date(2015, 2, 28, 0, 0, 0, 0, time.UTC)),
Current: false,
Description: "Started career building websites and web applications for SME clients. Learned PHP, JavaScript, MySQL, and modern frontend frameworks. Developed custom CMS solutions and e-commerce integrations.",
TechStack: []string{"PHP", "JavaScript", "MySQL", "HTML/CSS", "jQuery", "WordPress", "Magento"},
Order: 4,
},
}
for _, exp := range experiences {
s.experiences[exp.ID] = exp
}
}
func ptrTime(t time.Time) *time.Time {
return &t
}
func (s *ExperienceService) GetAll(ctx context.Context) ([]*model.Experience, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var result []*model.Experience
for _, exp := range s.experiences {
result = append(result, exp)
}
return result, nil
}
func (s *ExperienceService) GetByID(ctx context.Context, id uuid.UUID) (*model.Experience, error) {
s.mu.RLock()
defer s.mu.RUnlock()
exp, ok := s.experiences[id]
if !ok {
return nil, ErrNotFound
}
return exp, nil
}
func (s *ExperienceService) Create(ctx context.Context, exp *model.Experience) (*model.Experience, error) {
s.mu.Lock()
defer s.mu.Unlock()
exp.ID = uuid.New()
s.experiences[exp.ID] = exp
return exp, nil
}
func (s *ExperienceService) Update(ctx context.Context, exp *model.Experience) (*model.Experience, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.experiences[exp.ID]; !ok {
return nil, ErrNotFound
}
s.experiences[exp.ID] = exp
return exp, nil
}
func (s *ExperienceService) Delete(ctx context.Context, id uuid.UUID) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.experiences[id]; !ok {
return ErrNotFound
}
delete(s.experiences, id)
return nil
}

View file

@ -0,0 +1,241 @@
package service
import (
"context"
"encoding/json"
"os"
"sync"
"github.com/dnoulet/go-first/internal/model"
"github.com/google/uuid"
)
type ProjectService struct {
mu sync.RWMutex
projects map[uuid.UUID]*model.Project
}
func NewProjectService() *ProjectService {
s := &ProjectService{
projects: make(map[uuid.UUID]*model.Project),
}
s.seedData()
return s
}
type catalogProject struct {
Title string `json:"title"`
Description string `json:"description"`
ShortDesc string `json:"shortDesc"`
TechStack []string `json:"techStack"`
ImageURL string `json:"imageUrl"`
DemoURL *string `json:"demoUrl"`
RepoURL string `json:"repoUrl"`
Category string `json:"category"`
Featured bool `json:"featured"`
Order int `json:"order"`
}
func projectsFile() string {
if v := os.Getenv("GO_FIRST_PROJECTS_FILE"); v != "" {
return v
}
for _, candidate := range []string{"projects.json", "/app/projects.json"} {
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate
}
}
return ""
}
func loadCatalog() ([]*model.Project, bool) {
path := projectsFile()
if path == "" {
return nil, false
}
raw, err := os.ReadFile(path)
if err != nil {
return nil, false
}
var rows []catalogProject
if err := json.Unmarshal(raw, &rows); err != nil || len(rows) == 0 {
return nil, false
}
projects := make([]*model.Project, 0, len(rows))
for _, row := range rows {
demo := ""
if row.DemoURL != nil {
demo = *row.DemoURL
}
projects = append(projects, &model.Project{
ID: uuid.New(),
Title: row.Title,
Description: row.Description,
ShortDesc: row.ShortDesc,
TechStack: row.TechStack,
ImageURL: row.ImageURL,
DemoURL: demo,
RepoURL: row.RepoURL,
Category: row.Category,
Featured: row.Featured,
Order: row.Order,
})
}
return projects, true
}
func (s *ProjectService) seedData() {
if projects, ok := loadCatalog(); ok {
for _, project := range projects {
s.projects[project.ID] = project
}
return
}
projects := []*model.Project{
{
ID: uuid.New(),
Title: "E-Commerce Platform",
Description: "A full-featured e-commerce platform built with Go microservices, React frontend, and Kubernetes deployment. Features include user authentication, product catalog, shopping cart, payment integration, order management, and admin dashboard.",
ShortDesc: "Scalable e-commerce platform with Go microservices and React",
TechStack: []string{"Go", "React", "PostgreSQL", "Redis", "Kubernetes", "Docker", "gRPC"},
ImageURL: "/images/projects/ecommerce.jpg",
DemoURL: "https://demo.urpi.be/ecommerce",
RepoURL: "https://github.com/urpi/ecommerce-platform",
Category: "Full Stack",
Featured: true,
Order: 1,
},
{
ID: uuid.New(),
Title: "API Gateway Service",
Description: "High-performance API gateway built in Go with rate limiting, authentication, request/response transformation, circuit breaker, and distributed tracing. Handles 100k+ requests/second with sub-millisecond latency.",
ShortDesc: "High-performance Go API gateway with advanced features",
TechStack: []string{"Go", "Redis", "Prometheus", "Grafana", "OpenTelemetry", "Docker"},
ImageURL: "/images/projects/apigateway.jpg",
DemoURL: "",
RepoURL: "https://github.com/urpi/api-gateway",
Category: "Backend",
Featured: true,
Order: 2,
},
{
ID: uuid.New(),
Title: "Real-time Collaboration Tool",
Description: "Real-time collaborative document editor with operational transformation, WebSocket connections, presence indicators, and conflict resolution. Built with Go backend and React frontend.",
ShortDesc: "Real-time collaborative editor with operational transformation",
TechStack: []string{"Go", "React", "WebSocket", "Redis", "PostgreSQL", "TypeScript"},
ImageURL: "/images/projects/collab.jpg",
DemoURL: "https://collab.urpi.be",
RepoURL: "https://github.com/urpi/realtime-collab",
Category: "Full Stack",
Featured: true,
Order: 3,
},
{
ID: uuid.New(),
Title: "Distributed Task Queue",
Description: "Distributed task queue system with priority queues, delayed jobs, retry logic, dead letter queues, and horizontal scaling. Built for high-throughput background job processing.",
ShortDesc: "Scalable distributed task queue with priority and retry logic",
TechStack: []string{"Go", "Redis", "PostgreSQL", "Docker", "Kubernetes"},
ImageURL: "/images/projects/taskqueue.jpg",
DemoURL: "",
RepoURL: "https://github.com/urpi/distributed-task-queue",
Category: "Backend",
Featured: false,
Order: 4,
},
{
ID: uuid.New(),
Title: "Infrastructure as Code Framework",
Description: "Custom IaC framework for managing multi-cloud infrastructure with Go templates, drift detection, and policy enforcement. Supports AWS, GCP, and Azure.",
ShortDesc: "Multi-cloud IaC framework with drift detection",
TechStack: []string{"Go", "Terraform", "AWS", "GCP", "Azure", "Docker"},
ImageURL: "/images/projects/iac.jpg",
DemoURL: "",
RepoURL: "https://github.com/urpi/iac-framework",
Category: "DevOps",
Featured: false,
Order: 5,
},
{
ID: uuid.New(),
Title: "Financial Transaction Processor",
Description: "High-throughput financial transaction processing system with ACID guarantees, audit logging, regulatory compliance, and real-time fraud detection. Processes millions of transactions daily.",
ShortDesc: "High-throughput financial transaction processor with fraud detection",
TechStack: []string{"Java", "Spring Boot", "PostgreSQL", "Apache Kafka", "Kubernetes"},
ImageURL: "/images/projects/fintech.jpg",
DemoURL: "",
RepoURL: "",
Category: "Backend",
Featured: true,
Order: 6,
},
}
for _, project := range projects {
s.projects[project.ID] = project
}
}
func (s *ProjectService) GetAll(ctx context.Context, category string, featured bool) ([]*model.Project, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var result []*model.Project
for _, project := range s.projects {
if category != "" && project.Category != category {
continue
}
if featured && !project.Featured {
continue
}
result = append(result, project)
}
return result, nil
}
func (s *ProjectService) GetFeatured(ctx context.Context) ([]*model.Project, error) {
return s.GetAll(ctx, "", true)
}
func (s *ProjectService) GetByID(ctx context.Context, id uuid.UUID) (*model.Project, error) {
s.mu.RLock()
defer s.mu.RUnlock()
project, ok := s.projects[id]
if !ok {
return nil, ErrNotFound
}
return project, nil
}
func (s *ProjectService) Create(ctx context.Context, project *model.Project) (*model.Project, error) {
s.mu.Lock()
defer s.mu.Unlock()
project.ID = uuid.New()
s.projects[project.ID] = project
return project, nil
}
func (s *ProjectService) Update(ctx context.Context, project *model.Project) (*model.Project, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.projects[project.ID]; !ok {
return nil, ErrNotFound
}
s.projects[project.ID] = project
return project, nil
}
func (s *ProjectService) Delete(ctx context.Context, id uuid.UUID) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.projects[id]; !ok {
return ErrNotFound
}
delete(s.projects, id)
return nil
}

View file

@ -0,0 +1,140 @@
package service
import (
"context"
"sync"
"github.com/dnoulet/go-first/internal/model"
"github.com/google/uuid"
)
type ServiceService struct {
mu sync.RWMutex
services map[uuid.UUID]*model.Service
}
func NewServiceService() *ServiceService {
s := &ServiceService{
services: make(map[uuid.UUID]*model.Service),
}
s.seedData()
return s
}
func (s *ServiceService) seedData() {
services := []*model.Service{
{
ID: uuid.New(),
Title: "Web Application Development",
Description: "Custom web applications built with modern technologies. From concept to deployment, we deliver scalable, maintainable solutions tailored to your business needs.",
Icon: "code",
Features: []string{"React/Next.js Frontend", "Go/Java Backend", "REST/GraphQL APIs", "Real-time Features", "Progressive Web Apps", "TypeScript"},
Order: 1,
Active: true,
},
{
ID: uuid.New(),
Title: "Backend & API Development",
Description: "Robust backend systems and APIs designed for scale. Microservices, event-driven architectures, and high-performance systems that power your business logic.",
Icon: "server",
Features: []string{"Microservices Architecture", "REST & GraphQL APIs", "gRPC Services", "Event-Driven Systems", "Database Design", "Caching Strategies"},
Order: 2,
Active: true,
},
{
ID: uuid.New(),
Title: "Cloud Infrastructure & DevOps",
Description: "End-to-end infrastructure solutions on AWS, GCP, or Azure. Container orchestration, CI/CD pipelines, monitoring, and infrastructure as code.",
Icon: "cloud",
Features: []string{"Kubernetes & Docker", "AWS/GCP/Azure", "Terraform/IaC", "CI/CD Pipelines", "Monitoring & Logging", "Security Hardening"},
Order: 3,
Active: true,
},
{
ID: uuid.New(),
Title: "System Architecture & Consulting",
Description: "Expert architectural guidance for complex systems. Technology selection, scalability planning, performance optimization, and technical debt reduction.",
Icon: "architecture",
Features: []string{"System Design Reviews", "Technology Evaluation", "Scalability Planning", "Performance Optimization", "Migration Strategies", "Code Audits"},
Order: 4,
Active: true,
},
{
ID: uuid.New(),
Title: "Legacy Modernization",
Description: "Transform legacy systems into modern, maintainable applications. Strangler fig pattern, domain-driven design, and incremental migration strategies.",
Icon: "refresh",
Features: []string{"Legacy Assessment", "Strangler Fig Migration", "Domain-Driven Design", "Data Migration", "API Facade Pattern", "Automated Testing"},
Order: 5,
Active: true,
},
{
ID: uuid.New(),
Title: "Technical Advisory & CTO Services",
Description: "Fractional CTO and technical advisory services for startups and scale-ups. Technology strategy, team building, and technical roadmap planning.",
Icon: "advisor",
Features: []string{"Technology Strategy", "Team Building & Hiring", "Technical Roadmaps", "Vendor Evaluation", "Architecture Decisions", "Investor Technical Due Diligence"},
Order: 6,
Active: true,
},
}
for _, svc := range services {
s.services[svc.ID] = svc
}
}
func (s *ServiceService) GetAll(ctx context.Context) ([]*model.Service, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var result []*model.Service
for _, svc := range s.services {
if svc.Active {
result = append(result, svc)
}
}
return result, nil
}
func (s *ServiceService) GetByID(ctx context.Context, id uuid.UUID) (*model.Service, error) {
s.mu.RLock()
defer s.mu.RUnlock()
svc, ok := s.services[id]
if !ok {
return nil, ErrNotFound
}
return svc, nil
}
func (s *ServiceService) Create(ctx context.Context, svc *model.Service) (*model.Service, error) {
s.mu.Lock()
defer s.mu.Unlock()
svc.ID = uuid.New()
s.services[svc.ID] = svc
return svc, nil
}
func (s *ServiceService) Update(ctx context.Context, svc *model.Service) (*model.Service, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.services[svc.ID]; !ok {
return nil, ErrNotFound
}
s.services[svc.ID] = svc
return svc, nil
}
func (s *ServiceService) Delete(ctx context.Context, id uuid.UUID) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.services[id]; !ok {
return ErrNotFound
}
delete(s.services, id)
return nil
}

View file

@ -0,0 +1,106 @@
package service
import (
"context"
"sync"
"github.com/dnoulet/go-first/internal/model"
"github.com/google/uuid"
)
type SkillService struct {
mu sync.RWMutex
skills map[uuid.UUID]*model.Skill
}
func NewSkillService() *SkillService {
s := &SkillService{
skills: make(map[uuid.UUID]*model.Skill),
}
s.seedData()
return s
}
func (s *SkillService) seedData() {
skills := []*model.Skill{
{ID: uuid.New(), Name: "Go", Category: "Backend", Proficiency: 5, Icon: "go", Order: 1},
{ID: uuid.New(), Name: "Java", Category: "Backend", Proficiency: 5, Icon: "java", Order: 2},
{ID: uuid.New(), Name: "C#", Category: "Backend", Proficiency: 4, Icon: "csharp", Order: 3},
{ID: uuid.New(), Name: "Python", Category: "Backend", Proficiency: 4, Icon: "python", Order: 4},
{ID: uuid.New(), Name: "Ruby", Category: "Backend", Proficiency: 3, Icon: "ruby", Order: 5},
{ID: uuid.New(), Name: "Kotlin", Category: "Backend", Proficiency: 4, Icon: "kotlin", Order: 6},
{ID: uuid.New(), Name: "REST APIs", Category: "Architecture", Proficiency: 5, Icon: "api", Order: 7},
{ID: uuid.New(), Name: "Microservices", Category: "Architecture", Proficiency: 5, Icon: "microservices", Order: 8},
{ID: uuid.New(), Name: "System Design", Category: "Architecture", Proficiency: 5, Icon: "architecture", Order: 9},
{ID: uuid.New(), Name: "Infrastructure", Category: "DevOps", Proficiency: 4, Icon: "infrastructure", Order: 10},
{ID: uuid.New(), Name: "Docker", Category: "DevOps", Proficiency: 5, Icon: "docker", Order: 11},
{ID: uuid.New(), Name: "Kubernetes", Category: "DevOps", Proficiency: 4, Icon: "kubernetes", Order: 12},
{ID: uuid.New(), Name: "React", Category: "Frontend", Proficiency: 4, Icon: "react", Order: 13},
{ID: uuid.New(), Name: "TypeScript", Category: "Frontend", Proficiency: 4, Icon: "typescript", Order: 14},
{ID: uuid.New(), Name: "PostgreSQL", Category: "Database", Proficiency: 5, Icon: "postgresql", Order: 15},
{ID: uuid.New(), Name: "Redis", Category: "Database", Proficiency: 4, Icon: "redis", Order: 16},
{ID: uuid.New(), Name: "MongoDB", Category: "Database", Proficiency: 3, Icon: "mongodb", Order: 17},
{ID: uuid.New(), Name: "GraphQL", Category: "API", Proficiency: 4, Icon: "graphql", Order: 18},
{ID: uuid.New(), Name: "gRPC", Category: "API", Proficiency: 4, Icon: "grpc", Order: 19},
{ID: uuid.New(), Name: "CI/CD", Category: "DevOps", Proficiency: 5, Icon: "cicd", Order: 20},
}
for _, skill := range skills {
s.skills[skill.ID] = skill
}
}
func (s *SkillService) GetAll(ctx context.Context, category string) ([]*model.Skill, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var result []*model.Skill
for _, skill := range s.skills {
if category == "" || skill.Category == category {
result = append(result, skill)
}
}
return result, nil
}
func (s *SkillService) GetByID(ctx context.Context, id uuid.UUID) (*model.Skill, error) {
s.mu.RLock()
defer s.mu.RUnlock()
skill, ok := s.skills[id]
if !ok {
return nil, ErrNotFound
}
return skill, nil
}
func (s *SkillService) Create(ctx context.Context, skill *model.Skill) (*model.Skill, error) {
s.mu.Lock()
defer s.mu.Unlock()
skill.ID = uuid.New()
s.skills[skill.ID] = skill
return skill, nil
}
func (s *SkillService) Update(ctx context.Context, skill *model.Skill) (*model.Skill, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.skills[skill.ID]; !ok {
return nil, ErrNotFound
}
s.skills[skill.ID] = skill
return skill, nil
}
func (s *SkillService) Delete(ctx context.Context, id uuid.UUID) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.skills[id]; !ok {
return ErrNotFound
}
delete(s.skills, id)
return nil
}

146
projects.json Normal file
View file

@ -0,0 +1,146 @@
[
{
"id": "go-bananas",
"title": "go-bananas",
"description": "Runtime design-token service. Author design-token themes in a web interface, serve them to any application as CSS custom properties at run time. Consumers install nothing and have no build step. A theme is a document in a database — republishing a theme is a database write; every application picks it up on its next request. No package to version, no dependency to resolve, no build to re-run.",
"shortDesc": "Runtime design-token service serving CSS custom properties via HTTP",
"techStack": ["Go", "SQLite", "CSS Custom Properties", "Docker", "TypeScript", "React"],
"imageUrl": "/images/projects/go-bananas.png",
"demoUrl": "https://bananas.urpi.be",
"repoUrl": "https://forgejo.urpi.be/dnoulet/go-bananas",
"category": "Design System",
"featured": true,
"order": 1,
"tags": ["design-tokens", "css-custom-properties", "runtime-theming", "design-system", "zero-build"],
"url": "https://bananas.urpi.be",
"tenantUrlPattern": null
},
{
"id": "go-first",
"title": "go-first",
"description": "URPI's main web application and company portfolio. A modern, full-stack web application showcasing expertise in web application and backend development. Built with Go, React, TypeScript, and deployed on cloud-native infrastructure. Features RESTful API for skills, projects, experience, services, and contact forms.",
"shortDesc": "URPI company portfolio and web application showcase",
"techStack": ["Go", "React", "TypeScript", "Tailwind CSS", "PostgreSQL", "Redis", "Docker", "Kubernetes", "Gin", "Vite"],
"imageUrl": "/images/projects/go-first.png",
"demoUrl": "https://urpi.be",
"repoUrl": "https://forgejo.urpi.be/dnoulet/go-first",
"category": "Full Stack",
"featured": true,
"order": 2,
"tags": ["portfolio", "company-website", "full-stack", "go", "react", "typescript", "tailwind"],
"url": "https://urpi.be",
"tenantUrlPattern": null
},
{
"id": "go-guess",
"title": "go-guess",
"description": "Recruitment workspace for interviewers to define job profiles and a reusable question library, manage participants, extract known traits from uploaded CVs, invite candidates who match at least 60% of a posting's labels and skills, and run timed assessments followed by collaborative interviews. Multi-tenant architecture with Go Loose authentication.",
"shortDesc": "Multi-tenant recruitment platform with CV parsing, assessments, and collaborative interviews",
"techStack": ["Go", "React", "TypeScript", "PostgreSQL", "RabbitMQ", "GORM", "Chi", "Goose", "JWT", "Vite", "Go Loose"],
"imageUrl": "/images/projects/go-guess.png",
"demoUrl": "https://nmbs.guess.urpi.be",
"repoUrl": "https://forgejo.urpi.be/dnoulet/go-guess",
"category": "SaaS / HR Tech",
"featured": true,
"order": 3,
"tags": ["recruitment", "multi-tenant", "cv-parsing", "assessments", "interviews", "go-loose", "sso"],
"url": "https://nmbs.guess.urpi.be",
"tenantUrlPattern": "https://{tenant}.guess.urpi.be"
},
{
"id": "go-loose",
"title": "go-loose",
"description": "Multi-tenant API-key control plane for Go HTTP services. Web console to register tenant applications, issue and revoke keys, import OpenAPI contracts, and inspect API metadata. Applications use the public client package as fail-closed middleware: requests without a valid key never reach the protected handler. Supports OIDC/SSO, tenant isolation, and audit logging.",
"shortDesc": "Multi-tenant API key management and authentication control plane",
"techStack": ["Go", "React", "TypeScript", "PostgreSQL", "OIDC", "OpenAPI", "JWT", "Chi", "GORM", "Docker", "Kubernetes"],
"imageUrl": "/images/projects/go-loose.png",
"demoUrl": "https://auth.urpi.be",
"repoUrl": "https://forgejo.urpi.be/dnoulet/go-loose",
"category": "Infrastructure / Auth",
"featured": true,
"order": 4,
"tags": ["api-keys", "multi-tenant", "auth", "sso", "oidc", "openapi", "control-plane", "middleware"],
"url": "https://auth.urpi.be",
"tenantUrlPattern": "https://{tenant}.auth.urpi.be"
},
{
"id": "go-mad",
"title": "go-mad",
"description": "Web application for managing tenant database connections and running migration scripts. Helps administrators manage database connections for multiple tenants with a beautiful React UI. Features tenant management, application registration, database connection tracking, and goose-style SQL migration execution per tenant database.",
"shortDesc": "Multi-tenant database connection and migration management platform",
"techStack": ["Go", "React", "PostgreSQL", "Goose", "JWT", "Docker", "Helm", "Kubernetes", "TypeScript", "Vite"],
"imageUrl": "/images/projects/go-mad.png",
"demoUrl": "https://mad.urpi.be",
"repoUrl": "https://forgejo.urpi.be/dnoulet/go-mad",
"category": "DevOps / Database",
"featured": true,
"order": 5,
"tags": ["database-management", "migrations", "multi-tenant", "goose", "devops", "tenant-management"],
"url": "https://mad.urpi.be",
"tenantUrlPattern": null
},
{
"id": "go-places",
"title": "go-places",
"description": "Travel log service with GraphQL API and image analysis. Authenticates callers against Go Loose SSO. Features place management, picture uploads with inline EXIF analysis and badge generation (Water, Sky, Vivid, Nature), coordinate-based linking of related places and images, and REST/GraphQL endpoints. SQLite-backed with persistent volume on Kubernetes.",
"shortDesc": "GraphQL travel log with image analysis, EXIF parsing, and location-based linking",
"techStack": ["Go", "React", "TypeScript", "GraphQL", "SQLite", "Go Loose", "Docker", "Kubernetes", "Helm", "EXIF", "Image Analysis"],
"imageUrl": "/images/projects/go-places.png",
"demoUrl": null,
"repoUrl": "https://forgejo.urpi.be/dnoulet/go-places",
"category": "Travel / Media",
"featured": true,
"order": 6,
"tags": ["travel-log", "graphql", "image-analysis", "exif", "location-based", "sqlite", "go-loose"],
"url": null,
"tenantUrlPattern": null
},
{
"id": "go-tell",
"title": "go-tell",
"description": "Small content CMS: one Go binary that serves the API and embeds the React frontend, backed by a JSON file. Uses Go Loose for authentication to decide who is allowed to edit. Content is seeded in memory and can be mirrored to a JSON file. Single-host deployment at tell.dev with anonymous read access and authenticated writes.",
"shortDesc": "Embedded CMS with Go Loose authentication, JSON file storage",
"techStack": ["Go", "React", "TypeScript", "JSON Storage", "Go Loose", "OIDC", "Docker", "Single Binary"],
"imageUrl": "/images/projects/go-tell.png",
"demoUrl": "https://tell.urpi.be",
"repoUrl": "https://forgejo.urpi.be/dnoulet/go-tell",
"category": "CMS",
"featured": true,
"order": 7,
"tags": ["cms", "embedded", "json-storage", "go-loose", "auth", "single-binary"],
"url": "https://tell.urpi.be",
"tenantUrlPattern": null
},
{
"id": "go-fish",
"title": "go-fish",
"description": "Web-based implementation of the classic Go Fish card game. Single-page application with waiting room, matchmaking, and real-time gameplay. Players join a waiting room, see available opponents (green) vs busy ones (red), click to match, and play Go Fish with standard rules. Collect books (4 of a kind), first to 13 wins.",
"shortDesc": "Real-time multiplayer Go Fish card game with waiting room and matchmaking",
"techStack": ["Go", "WebSockets", "HTML/CSS/JS", "Docker"],
"imageUrl": "/images/projects/go-fish.png",
"demoUrl": null,
"repoUrl": "https://forgejo.urpi.be/dnoulet/go-fish",
"category": "Games",
"featured": false,
"order": 8,
"tags": ["game", "multiplayer", "websockets", "go-fish", "cards", "real-time"],
"url": null,
"tenantUrlPattern": null
},
{
"id": "go-doc",
"title": "go-doc",
"description": "Documentation and feedback platform for application teams. Authors manage Markdown documents in a React editor, while readers browse role-focused guidance for users, operators, and developers and leave audience-badged comments. The Go API stores documents and feedback as flat files, supports Go Loose authentication with tenant-aware comment visibility, and can serve the embedded frontend as a self-contained application.",
"shortDesc": "Markdown documentation portal with audience-focused feedback and tenant-aware access",
"techStack": ["Go", "React", "JavaScript", "Markdown", "net/http", "Go Loose", "Docker"],
"imageUrl": "/images/projects/go-doc.png",
"demoUrl": "https://doc.urpi.be",
"repoUrl": "https://forgejo.urpi.be/dnoulet/go-doc",
"category": "Documentation / Collaboration",
"featured": false,
"order": 9,
"tags": ["documentation", "markdown", "feedback", "comments", "audience-based", "go-loose", "multi-tenant"],
"url": "https://doc.urpi.be",
"tenantUrlPattern": null
}
]

18
web/index.html Normal file
View file

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="URPI - Web Application & Backend Development. Expert in Go, Java, React, Cloud Infrastructure & System Architecture." />
<meta name="theme-color" content="#ed7315" />
<title>URPI | Web Application & Backend Development</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6018
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

52
web/package.json Normal file
View file

@ -0,0 +1,52 @@
{
"name": "go-first-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"format": "prettier --write \"src/**/*.{ts,tsx,css,md}\"",
"test": "vitest run",
"test:ui": "vitest --ui",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:all": "npm run test && npm run test:e2e"
},
"dependencies": {
"clsx": "^2.1.1",
"lucide-react": "^0.436.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.1",
"tailwind-merge": "^2.5.2"
},
"devDependencies": {
"@playwright/test": "^1.63.0",
"@testing-library/dom": "^10.4.2",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^8.3.0",
"@typescript-eslint/parser": "^8.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.11",
"happy-dom": "^20.14.5",
"jsdom": "^30.1.2",
"postcss": "^8.4.47",
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.6",
"tailwindcss": "^3.4.10",
"typescript": "^5.5.4",
"vite": "^5.4.3",
"vitest": "^1.6.0"
}
}

6
web/postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

4
web/public/favicon.svg Normal file
View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="6" fill="#ed7315"/>
<path d="M8 12h16M8 16h12M8 20h8" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 229 B

8
web/public/grid.svg Normal file
View file

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
<defs>
<pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="currentColor" stroke-width="0.5" opacity="0.3"/>
</pattern>
</defs>
<rect width="100" height="100" fill="url(#grid)"/>
</svg>

After

Width:  |  Height:  |  Size: 361 B

27
web/src/App.tsx Normal file
View file

@ -0,0 +1,27 @@
import { Header } from './components/layout/Header'
import { Footer } from './components/layout/Footer'
import { Hero } from './components/sections/Hero'
import { Services } from './components/sections/Services'
import { Projects } from './components/sections/Projects'
import { Skills } from './components/sections/Skills'
import { Experience } from './components/sections/Experience'
import { Contact } from './components/sections/Contact'
function App() {
return (
<div className="min-h-screen bg-white">
<Header />
<main id="main-content" className="pt-16 lg:pt-20">
<Hero />
<Services />
<Projects />
<Skills />
<Experience />
<Contact />
</main>
<Footer />
</div>
)
}
export default App

View file

@ -0,0 +1,129 @@
import { render, screen, waitFor, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BrowserRouter } from 'react-router-dom'
import { describe, it, expect, beforeEach } from 'vitest'
import App from '../App'
import { mockFetchResponse, resetFetchMock, getFetchCalls } from './api-mock'
const renderApp = () => {
return render(
<BrowserRouter>
<App />
</BrowserRouter>
)
}
const mockSkills = [
{ id: '1', name: 'Go', category: 'Backend', proficiency: 5, icon: 'go', order: 1, created_at: '', updated_at: '' },
{ id: '2', name: 'React', category: 'Frontend', proficiency: 4, icon: 'react', order: 2, created_at: '', updated_at: '' },
]
const mockProjects = [
{ id: '1', title: 'Test Project', description: 'Desc', short_desc: 'Short', tech_stack: ['Go', 'React'], image_url: '', demo_url: '', repo_url: '', category: 'Full Stack', featured: true, order: 1, created_at: '', updated_at: '' },
]
const mockExperience = [
{ id: '1', company: 'Test Co', role: 'Dev', location: 'Remote', start_date: '2020-01-01', end_date: null, current: true, description: 'Test', tech_stack: ['Go'], order: 1, created_at: '', updated_at: '' },
]
const mockServices = [
{ id: '1', title: 'Web Dev', description: 'Desc', icon: 'code', features: ['React', 'Go'], order: 1, active: true, created_at: '', updated_at: '' },
]
describe('App Integration Tests', () => {
beforeEach(() => {
resetFetchMock()
})
it('renders all sections without errors', async () => {
mockFetchResponse(mockSkills)
mockFetchResponse(mockProjects)
mockFetchResponse(mockProjects)
mockFetchResponse(mockExperience)
mockFetchResponse(mockServices)
renderApp()
await waitFor(() => {
expect(screen.getByText('Services & Expertise')).toBeInTheDocument()
expect(screen.getByText('Selected Projects')).toBeInTheDocument()
expect(screen.getByText('Technical Skills')).toBeInTheDocument()
expect(screen.getByText('Experience')).toBeInTheDocument()
expect(screen.getByText("Let's Build Something Great")).toBeInTheDocument()
}, { timeout: 15000 })
})
it('does not make excessive API calls on initial render', async () => {
mockFetchResponse([])
mockFetchResponse([])
mockFetchResponse([])
mockFetchResponse([])
mockFetchResponse([])
renderApp()
await waitFor(() => {
expect(screen.getByText('Services & Expertise')).toBeInTheDocument()
}, { timeout: 15000 })
const calls = getFetchCalls()
expect(calls.length).toBeLessThanOrEqual(6)
})
it('handles API errors gracefully', async () => {
;(globalThis.fetch as unknown as { mockResolvedValueOnce: (value: unknown) => void }).mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ success: false, error: { code: 'INTERNAL_ERROR', message: 'Server error' } }),
})
;(globalThis.fetch as unknown as { mockResolvedValueOnce: (value: unknown) => void }).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, data: [] }),
})
mockFetchResponse([])
mockFetchResponse([])
mockFetchResponse([])
renderApp()
await waitFor(() => {
expect(screen.getByText('Services & Expertise')).toBeInTheDocument()
}, { timeout: 15000 })
expect(screen.getByText('Selected Projects')).toBeInTheDocument()
})
it('contact form submission works', async () => {
mockFetchResponse([])
mockFetchResponse([])
mockFetchResponse([])
mockFetchResponse([])
mockFetchResponse([])
mockFetchResponse({ id: '1', name: 'Test', email: 'test@test.com', subject: 'Test', message: 'Test message', status: 'new', created_at: '', updated_at: '' })
renderApp()
await waitFor(() => {
expect(screen.getByText("Let's Build Something Great")).toBeInTheDocument()
}, { timeout: 15000 })
// Use getByPlaceholderText for more specific targeting
const nameInput = screen.getByPlaceholderText('Your name')
const emailInput = screen.getByPlaceholderText('your@email.com')
const subjectInput = screen.getByPlaceholderText("What's this about?")
const messageInput = screen.getByPlaceholderText("Tell us about your project, timeline, budget, or just say hello...")
const submitButton = screen.getByRole('button', { name: /send message/i })
await userEvent.type(nameInput, 'John Doe')
await userEvent.type(emailInput, 'john@example.com')
await userEvent.type(subjectInput, 'Project Inquiry')
await userEvent.type(messageInput, 'Hello, I would like to discuss a project.')
await act(async () => {
await userEvent.click(submitButton)
})
await waitFor(() => {
expect(screen.getByText('Message Sent!')).toBeInTheDocument()
}, { timeout: 15000 })
})
})

View file

@ -0,0 +1,86 @@
import { render, screen, waitFor } from '@testing-library/react'
import { describe, it, expect, beforeEach } from 'vitest'
import { Services } from '../components/sections/Services'
import { mockFetchResponse, resetFetchMock, getFetchCalls } from './api-mock'
const renderServices = () => {
return render(<Services />)
}
const mockServices = [
{ id: '1', title: 'Web Dev', description: 'Desc', icon: 'code', features: ['React', 'Go'], order: 1, active: true, created_at: '', updated_at: '' },
{ id: '2', title: 'Backend', description: 'Desc', icon: 'server', features: ['Go', 'Java'], order: 2, active: true, created_at: '', updated_at: '' },
]
describe('Services Section - Infinite Loop Detection', () => {
beforeEach(() => {
resetFetchMock()
})
it('fetches services only once on mount', async () => {
mockFetchResponse(mockServices)
renderServices()
await waitFor(() => {
expect(screen.getByText('Web Dev')).toBeInTheDocument()
}, { timeout: 10000 })
const initialCalls = getFetchCalls().length
await new Promise(resolve => setTimeout(resolve, 100))
const finalCalls = getFetchCalls().length
expect(finalCalls).toBe(initialCalls)
})
it('does not re-fetch when parent re-renders', async () => {
mockFetchResponse(mockServices)
const { rerender } = renderServices()
await waitFor(() => {
expect(screen.getByText('Web Dev')).toBeInTheDocument()
}, { timeout: 10000 })
const initialCalls = getFetchCalls().length
rerender(<Services />)
await new Promise(resolve => setTimeout(resolve, 100))
const finalCalls = getFetchCalls().length
expect(finalCalls).toBe(initialCalls)
})
it('shows loading skeleton initially', async () => {
;(globalThis.fetch as unknown as { mockImplementation: (fn: () => Promise<unknown>) => void }).mockImplementation(
() => new Promise(resolve => setTimeout(() => resolve({
ok: true,
json: () => Promise.resolve({ success: true, data: mockServices }),
}), 100))
)
renderServices()
// Should show loading state briefly
await waitFor(() => {
expect(screen.getByText('Web Dev')).toBeInTheDocument()
}, { timeout: 5000 })
})
it('shows error state on API failure', async () => {
;(globalThis.fetch as unknown as { mockResolvedValueOnce: (value: unknown) => void }).mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ success: false, error: { code: 'INTERNAL_ERROR', message: 'Failed to load' } }),
})
renderServices()
await waitFor(() => {
expect(screen.getByText(/failed to load services/i)).toBeInTheDocument()
}, { timeout: 10000 })
})
})

View file

@ -0,0 +1,65 @@
export interface MockApiResponse<T> {
success: boolean
data?: T
error?: {
code: string
message: string
details?: string
}
meta?: {
page: number
page_size: number
total: number
total_pages: number
}
}
export function createMockResponse<T>(data: T, meta?: MockApiResponse<T>['meta']): MockApiResponse<T> {
return {
success: true,
data,
meta,
}
}
export function createMockError(code: string, message: string, details?: string): MockApiResponse<never> {
return {
success: false,
error: { code, message, details },
}
}
export function mockFetchResponse<T>(data: T, meta?: MockApiResponse<T>['meta']): void {
const response = createMockResponse(data, meta)
;(globalThis.fetch as unknown as { mockResolvedValueOnce: (value: unknown) => void }).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(response),
})
}
export function mockFetchError(code: string, message: string, details?: string): void {
const response = createMockError(code, message, details)
;(globalThis.fetch as unknown as { mockResolvedValueOnce: (value: unknown) => void }).mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve(response),
})
}
export function mockFetchNetworkError(): void {
;(globalThis.fetch as unknown as { mockRejectedValueOnce: (value: unknown) => void }).mockRejectedValueOnce(new Error('Network error'))
}
export function getFetchCalls(): Array<{ url: string; options?: RequestInit }> {
const mockFetch = globalThis.fetch as unknown as { mock: { calls: Array<[string, RequestInit?]> } }
return mockFetch.mock.calls.map(([url, options]) => ({ url, options }))
}
export function getLastFetchCall(): { url: string; options?: RequestInit } | undefined {
const calls = getFetchCalls()
return calls[calls.length - 1]
}
export function resetFetchMock(): void {
const mockFetch = globalThis.fetch as unknown as { mockClear: () => void }
mockFetch.mockClear()
}

View file

@ -0,0 +1,130 @@
import { render, screen, waitFor } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom'
import { describe, it, expect, beforeEach } from 'vitest'
import App from '../App'
import { mockFetchResponse, resetFetchMock, getFetchCalls } from './api-mock'
const renderApp = () => {
return render(
<BrowserRouter>
<App />
</BrowserRouter>
)
}
const mockSkills = [
{ id: '1', name: 'Go', category: 'Backend', proficiency: 5, icon: 'go', order: 1, created_at: '', updated_at: '' },
]
const mockProjects = [
{ id: '1', title: 'Test Project', description: 'Desc', short_desc: 'Short', tech_stack: ['Go', 'React'], image_url: '', demo_url: '', repo_url: '', category: 'Full Stack', featured: true, order: 1, created_at: '', updated_at: '' },
]
const mockExperience = [
{ id: '1', company: 'Test Co', role: 'Dev', location: 'Remote', start_date: '2020-01-01', end_date: null, current: true, description: 'Test', tech_stack: ['Go'], order: 1, created_at: '', updated_at: '' },
]
const mockServices = [
{ id: '1', title: 'Web Dev', description: 'Desc', icon: 'code', features: ['React', 'Go'], order: 1, active: true, created_at: '', updated_at: '' },
]
describe('Performance & Infinite Loop Detection', () => {
beforeEach(() => {
resetFetchMock()
})
it('fetches data only once on mount', async () => {
mockFetchResponse(mockSkills)
mockFetchResponse(mockProjects)
mockFetchResponse(mockProjects)
mockFetchResponse(mockExperience)
mockFetchResponse(mockServices)
renderApp()
await waitFor(() => {
expect(screen.getByText('Services & Expertise')).toBeInTheDocument()
}, { timeout: 15000 })
const initialCalls = getFetchCalls().length
await new Promise(resolve => setTimeout(resolve, 100))
const finalCalls = getFetchCalls().length
expect(finalCalls).toBe(initialCalls)
})
it('does not re-fetch on component re-render without dependency changes', async () => {
mockFetchResponse(mockSkills)
mockFetchResponse(mockProjects)
mockFetchResponse(mockProjects)
mockFetchResponse(mockExperience)
mockFetchResponse(mockServices)
const { rerender } = renderApp()
await waitFor(() => {
expect(screen.getByText('Services & Expertise')).toBeInTheDocument()
}, { timeout: 15000 })
const initialCalls = getFetchCalls().length
rerender(
<BrowserRouter>
<App />
</BrowserRouter>
)
await new Promise(resolve => setTimeout(resolve, 100))
const finalCalls = getFetchCalls().length
expect(finalCalls).toBe(initialCalls)
})
it('does not make excessive fetch calls', async () => {
let callCount = 0
;(globalThis.fetch as unknown as { mockImplementation: (fn: () => Promise<unknown>) => void }).mockImplementation(() => {
callCount++
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ success: true, data: [] }),
})
})
renderApp()
await waitFor(() => {
expect(screen.getByText('Services & Expertise')).toBeInTheDocument()
}, { timeout: 15000 })
const initialCallCount = callCount
await new Promise(resolve => setTimeout(resolve, 100))
expect(callCount).toBeLessThanOrEqual(initialCallCount + 1)
})
it('Skills section does not cause infinite re-fetches', async () => {
mockFetchResponse(mockSkills)
mockFetchResponse(mockProjects)
mockFetchResponse(mockProjects)
mockFetchResponse(mockExperience)
mockFetchResponse(mockServices)
renderApp()
await waitFor(() => {
expect(screen.getByText('Technical Skills')).toBeInTheDocument()
}, { timeout: 15000 })
const initialCalls = getFetchCalls().length
await new Promise(resolve => setTimeout(resolve, 100))
const finalCalls = getFetchCalls().length
expect(finalCalls).toBe(initialCalls)
})
})

View file

@ -0,0 +1,52 @@
import '@testing-library/jest-dom'
import { vi } from 'vitest'
// Mock fetch for API calls
globalThis.fetch = vi.fn()
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
// Mock IntersectionObserver
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}))
// Mock ResizeObserver
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}))
// Suppress console.error in tests (optional, remove if you want to see errors)
const originalError = console.error
beforeAll(() => {
console.error = (...args) => {
if (
typeof args[0] === 'string' &&
args[0].includes('Warning: ReactDOM.render is no longer supported')
) {
return
}
originalError.call(console, ...args)
}
})
afterAll(() => {
console.error = originalError
})

View file

@ -0,0 +1,161 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--font-sans: 'Inter', system-ui, sans-serif;
--font-display: 'Cal Sans', 'Inter', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
}
html {
scroll-behavior: smooth;
}
body {
@apply font-sans text-dark-900 bg-white antialiased;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
}
::selection {
@apply bg-brand-500/30 text-brand-900;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
@apply bg-dark-100;
}
::-webkit-scrollbar-thumb {
@apply bg-brand-500/50 rounded-full hover:bg-brand-500;
}
}
@layer components {
.container {
@apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
}
.btn {
@apply inline-flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-primary {
@apply btn bg-brand-500 text-white hover:bg-brand-600 focus-visible:ring-brand-500 shadow-lg shadow-brand-500/25;
}
.btn-secondary {
@apply btn bg-dark-100 text-dark-900 hover:bg-dark-200 focus-visible:ring-dark-500 border border-dark-200;
}
.btn-outline {
@apply btn border-2 border-brand-500 text-brand-500 hover:bg-brand-500 hover:text-white focus-visible:ring-brand-500;
}
.btn-ghost {
@apply btn text-dark-600 hover:text-dark-900 hover:bg-dark-100 focus-visible:ring-dark-500;
}
.card {
@apply bg-white rounded-2xl border border-dark-100 overflow-hidden transition-all duration-300 hover:shadow-xl hover:border-brand-200;
}
.card-hover {
@apply card hover:-translate-y-1 hover:shadow-glow;
}
.section {
@apply py-16 sm:py-24 lg:py-32;
}
.section-title {
@apply text-3xl sm:text-4xl lg:text-5xl font-display font-bold text-dark-900 tracking-tight;
}
.section-subtitle {
@apply mt-4 text-lg sm:text-xl text-dark-600 max-w-2xl;
}
.badge {
@apply inline-flex items-center px-3 py-1 rounded-full text-sm font-medium;
}
.badge-brand {
@apply badge bg-brand-100 text-brand-700;
}
.badge-dark {
@apply badge bg-dark-100 text-dark-700;
}
.input {
@apply w-full px-4 py-3 rounded-lg border border-dark-200 bg-white text-dark-900 placeholder-dark-400 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500;
}
.label {
@apply block text-sm font-medium text-dark-700 mb-1.5;
}
.link {
@apply text-brand-500 hover:text-brand-600 underline-offset-2 hover:underline transition-colors;
}
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
.animate-delay-100 {
animation-delay: 100ms;
}
.animate-delay-200 {
animation-delay: 200ms;
}
.animate-delay-300 {
animation-delay: 300ms;
}
.animate-delay-400 {
animation-delay: 400ms;
}
.animate-delay-500 {
animation-delay: 500ms;
}
.gradient-text {
@apply bg-gradient-to-r from-brand-500 to-brand-700 bg-clip-text text-transparent;
}
.glass {
@apply bg-white/80 backdrop-blur-lg border border-white/20;
}
.glass-dark {
@apply bg-dark-900/80 backdrop-blur-lg border border-dark-700/50;
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.skeleton {
@apply animate-pulse bg-dark-200;
background: linear-gradient(90deg, #e2e8f0 25%, #f1f5f9 50%, #e2e8f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}

View file

@ -0,0 +1,122 @@
import { Link } from 'react-router-dom'
import { Code, Github, Linkedin, Twitter, Mail, MapPin, Phone } from 'lucide-react'
const footerLinks = {
services: [
{ label: 'Web Development', href: '#services' },
{ label: 'Backend & APIs', href: '#services' },
{ label: 'Cloud & DevOps', href: '#services' },
{ label: 'Architecture', href: '#services' },
{ label: 'Modernization', href: '#services' },
{ label: 'Advisory', href: '#services' },
],
company: [
{ label: 'About', href: '#experience' },
{ label: 'Projects', href: '#projects' },
{ label: 'Skills', href: '#skills' },
{ label: 'Contact', href: '#contact' },
],
legal: [
{ label: 'Privacy Policy', href: '/privacy' },
{ label: 'Terms of Service', href: '/terms' },
{ label: 'Cookie Policy', href: '/cookies' },
],
}
const socialLinks = [
{ icon: Github, href: 'https://github.com/urpi', label: 'GitHub' },
{ icon: Linkedin, href: 'https://linkedin.com/company/urpi', label: 'LinkedIn' },
{ icon: Twitter, href: 'https://twitter.com/urpi_be', label: 'Twitter' },
{ icon: Mail, href: 'mailto:contact@urpi.be', label: 'Email' },
]
export function Footer() {
return (
<footer className="bg-dark-900 text-dark-300" role="contentinfo">
<div className="container py-16 lg:py-24">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-12">
<div className="lg:col-span-2">
<Link to="/" className="flex items-center gap-2 text-xl font-display font-bold text-white mb-4" aria-label="URPI Home">
<Code className="h-8 w-8 text-brand-500" />
<span>URPI</span>
</Link>
<p className="text-dark-400 mb-6 max-w-xs">
Web application & backend development experts. Building scalable, maintainable software solutions with Go, Java, React, and cloud-native technologies.
</p>
<div className="flex gap-4">
{socialLinks.map(({ icon: Icon, href, label }) => (
<a
key={label}
href={href}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg bg-dark-800 text-dark-400 hover:bg-brand-500/20 hover:text-brand-400 transition-all duration-200"
aria-label={label}
>
<Icon className="h-5 w-5" />
</a>
))}
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-white uppercase tracking-wider mb-4">Services</h3>
<ul className="space-y-3">
{footerLinks.services.map((link) => (
<li key={link.label}>
<Link to={link.href} className="text-dark-400 hover:text-brand-400 transition-colors">
{link.label}
</Link>
</li>
))}
</ul>
</div>
<div>
<h3 className="text-sm font-semibold text-white uppercase tracking-wider mb-4">Company</h3>
<ul className="space-y-3">
{footerLinks.company.map((link) => (
<li key={link.label}>
<a href={link.href} className="text-dark-400 hover:text-brand-400 transition-colors">
{link.label}
</a>
</li>
))}
</ul>
</div>
<div>
<h3 className="text-sm font-semibold text-white uppercase tracking-wider mb-4">Contact</h3>
<address className="not-italic space-y-3 text-dark-400">
<div className="flex items-start gap-3">
<MapPin className="h-5 w-5 mt-0.5 text-brand-500 flex-shrink-0" />
<span>Belgium</span>
</div>
<div className="flex items-start gap-3">
<Mail className="h-5 w-5 mt-0.5 text-brand-500 flex-shrink-0" />
<a href="mailto:contact@urpi.be" className="hover:text-brand-400 transition-colors">contact@urpi.be</a>
</div>
<div className="flex items-start gap-3">
<Phone className="h-5 w-5 mt-0.5 text-brand-500 flex-shrink-0" />
<a href="tel:+32470000000" className="hover:text-brand-400 transition-colors">+32 470 00 00 00</a>
</div>
</address>
</div>
</div>
<div className="mt-12 pt-8 border-t border-dark-800 flex flex-col md:flex-row items-center justify-between gap-4">
<p className="text-dark-500 text-sm">
© {new Date().getFullYear()} URPI. All rights reserved.
</p>
<div className="flex items-center gap-6">
{footerLinks.legal.map((link) => (
<Link key={link.label} to={link.href} className="text-sm text-dark-500 hover:text-brand-400 transition-colors">
{link.label}
</Link>
))}
</div>
</div>
</div>
</footer>
)
}

View file

@ -0,0 +1,109 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { Menu, X, Code } from 'lucide-react'
import { Button } from '../ui/Button'
import { cn } from '../../utils/helpers'
const navItems = [
{ href: '#services', label: 'Services' },
{ href: '#projects', label: 'Projects' },
{ href: '#skills', label: 'Skills' },
{ href: '#experience', label: 'Experience' },
{ href: '#contact', label: 'Contact' },
]
export function Header() {
const [scrolled, setScrolled] = useState(false)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
useEffect(() => {
const handleScroll = () => setScrolled(window.scrollY > 20)
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])
const scrollToSection = (href: string) => {
const sectionId = href.replace('#', '')
const element = document.getElementById(sectionId)
if (element) {
element.scrollIntoView({ behavior: 'smooth' })
}
setMobileMenuOpen(false)
}
return (
<header
className={cn(
'fixed top-0 left-0 right-0 z-50 transition-all duration-300',
scrolled ? 'bg-white/95 backdrop-blur-lg shadow-sm border-b border-dark-100' : 'bg-transparent'
)}
>
<nav className="container" aria-label="Main navigation">
<div className="flex items-center justify-between h-16 lg:h-20">
<Link to="/" className="flex items-center gap-2 text-xl font-display font-bold text-dark-900" aria-label="URPI Home">
<Code className="h-8 w-8 text-brand-500" />
<span>URPI</span>
</Link>
<div className="hidden lg:flex items-center gap-8">
{navItems.map((item) => (
<a
key={item.href}
href={item.href}
onClick={(e) => {
e.preventDefault()
scrollToSection(item.href)
}}
className="text-sm font-medium text-dark-600 hover:text-brand-500 transition-colors"
>
{item.label}
</a>
))}
<Button variant="primary" size="sm" onClick={() => scrollToSection('#contact')}>
Get in Touch
</Button>
</div>
<div className="flex items-center gap-4 lg:hidden">
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="p-2 rounded-lg text-dark-600 hover:bg-dark-100 transition-colors"
aria-expanded={mobileMenuOpen}
aria-controls="mobile-menu"
aria-label={mobileMenuOpen ? 'Close menu' : 'Open menu'}
>
{mobileMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
</button>
</div>
</div>
<div
id="mobile-menu"
className={cn(
'lg:hidden overflow-hidden transition-all duration-300 ease-in-out border-t border-dark-100 bg-white',
mobileMenuOpen ? 'max-h-96 opacity-100 py-4' : 'max-h-0 opacity-0 py-0'
)}
>
<div className="flex flex-col gap-4 px-4">
{navItems.map((item) => (
<a
key={item.href}
href={item.href}
onClick={(e) => {
e.preventDefault()
scrollToSection(item.href)
}}
className="text-base font-medium text-dark-600 hover:text-brand-500 transition-colors py-2"
>
{item.label}
</a>
))}
<Button variant="primary" className="w-full" onClick={() => scrollToSection('#contact')}>
Get in Touch
</Button>
</div>
</div>
</nav>
</header>
)
}

View file

@ -0,0 +1,217 @@
import { useState } from 'react'
import { useApiMutation } from '../../hooks/useApi'
import { api } from '../../services/api'
import { type ContactForm } from '../../types'
import { Button } from '../ui/Button'
import { Input, Textarea } from '../ui/Input'
import { Card } from '../ui/Card'
import { Badge } from '../ui/Badge'
import { Mail, MapPin, Phone, Send, CheckCircle, AlertCircle, Loader2 } from 'lucide-react'
import { validateEmail } from '../../utils/helpers'
export function Contact() {
const [formData, setFormData] = useState<ContactForm>({
name: '',
email: '',
subject: '',
message: '',
})
const [errors, setErrors] = useState<Partial<ContactForm>>({})
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle')
const [errorMessage, setErrorMessage] = useState('')
const { mutate: submitContact, loading } = useApiMutation(api.contact.submit)
const validateForm = (): boolean => {
const newErrors: Partial<ContactForm> = {}
if (!formData.name.trim() || formData.name.length < 2) {
newErrors.name = 'Name must be at least 2 characters'
}
if (!validateEmail(formData.email)) {
newErrors.email = 'Please enter a valid email address'
}
if (!formData.subject.trim() || formData.subject.length < 5) {
newErrors.subject = 'Subject must be at least 5 characters'
}
if (!formData.message.trim() || formData.message.length < 10) {
newErrors.message = 'Message must be at least 10 characters'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!validateForm()) return
setSubmitStatus('idle')
setErrorMessage('')
const result = await submitContact(formData)
if (result) {
setSubmitStatus('success')
setFormData({ name: '', email: '', subject: '', message: '' })
} else {
setSubmitStatus('error')
setErrorMessage('Failed to submit form. Please try again or email us directly.')
}
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target
setFormData(prev => ({ ...prev, [name]: value }))
if (errors[name as keyof ContactForm]) {
setErrors(prev => ({ ...prev, [name]: undefined }))
}
}
const contactInfo = [
{ icon: Mail, label: 'Email', value: 'contact@urpi.be', href: 'mailto:contact@urpi.be' },
{ icon: MapPin, label: 'Location', value: 'Belgium', href: null },
{ icon: Phone, label: 'Phone', value: '+32 470 00 00 00', href: 'tel:+32470000000' },
]
return (
<section id="contact" className="section bg-white" aria-labelledby="contact-heading">
<div className="container">
<div className="grid lg:grid-cols-2 gap-12 lg:gap-16">
<div>
<Badge className="mb-4">Get In Touch</Badge>
<h2 id="contact-heading" className="section-title">Let's Build Something Great</h2>
<p className="section-subtitle mt-4">
Have a project in mind? Need technical guidance? Looking for a development partner?
Let's start a conversation.
</p>
<div className="mt-10 space-y-6">
{contactInfo.map((item) => (
<div key={item.label} className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 rounded-xl bg-brand-100 text-brand-500 flex items-center justify-center">
<item.icon className="h-6 w-6" />
</div>
<div>
<p className="text-sm font-medium text-dark-900">{item.label}</p>
{item.href ? (
<a href={item.href} className="text-dark-600 hover:text-brand-500 transition-colors">
{item.value}
</a>
) : (
<p className="text-dark-600">{item.value}</p>
)}
</div>
</div>
))}
</div>
<div className="mt-10 p-6 bg-dark-50 rounded-2xl">
<h3 className="text-lg font-semibold text-dark-900 mb-2">Typical Response Time</h3>
<p className="text-dark-600">We usually respond within 24 hours during business days.</p>
</div>
</div>
<div>
<Card variant="glass" className="p-8">
{submitStatus === 'success' ? (
<div className="text-center py-12">
<div className="w-16 h-16 rounded-full bg-green-100 flex items-center justify-center mx-auto mb-4">
<CheckCircle className="h-8 w-8 text-green-500" />
</div>
<h3 className="text-2xl font-bold text-dark-900 mb-2">Message Sent!</h3>
<p className="text-dark-600 mb-6">Thanks for reaching out. We'll get back to you within 24 hours.</p>
<Button variant="outline" onClick={() => setSubmitStatus('idle')}>
Send Another Message
</Button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6" noValidate>
{submitStatus === 'error' && (
<div className="flex items-center gap-3 p-4 bg-red-50 border border-red-200 rounded-lg" role="alert">
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0" />
<p className="text-red-700">{errorMessage}</p>
</div>
)}
<Input
label="Name"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="Your name"
error={errors.name}
required
autoComplete="name"
/>
<Input
label="Email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
placeholder="your@email.com"
error={errors.email}
required
autoComplete="email"
/>
<Input
label="Subject"
name="subject"
value={formData.subject}
onChange={handleChange}
placeholder="What's this about?"
error={errors.subject}
required
/>
<Textarea
label="Message"
name="message"
value={formData.message}
onChange={handleChange}
placeholder="Tell us about your project, timeline, budget, or just say hello..."
error={errors.message}
required
rows={5}
/>
<Button
type="submit"
className="w-full"
size="lg"
loading={loading}
>
{loading ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
Sending...
</>
) : (
<>
Send Message
<Send className="h-5 w-5" />
</>
)}
</Button>
<p className="text-xs text-dark-500 text-center">
By submitting this form, you agree to our{' '}
<a href="/privacy" className="underline hover:text-brand-500">Privacy Policy</a>
. We never share your information with third parties.
</p>
</form>
)}
</Card>
</div>
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,120 @@
import { useApi } from '../../hooks/useApi'
import { api } from '../../services/api'
import { useCallback } from 'react'
import { type Experience } from '../../types'
import { Card, CardContent } from '../ui/Card'
import { Badge } from '../ui/Badge'
import { formatDateRange } from '../../utils/helpers'
import { Building2, MapPin, Calendar, Code2 } from 'lucide-react'
export function Experience() {
const fetchExperience = useCallback(() => api.experience.getAll(), [])
const { data: experiences, loading, error } = useApi(fetchExperience)
if (loading) {
return (
<section id="experience" className="section bg-dark-50" aria-labelledby="experience-heading">
<div className="container">
<div className="space-y-8">
{[1, 2, 3, 4].map((i) => (
<Card key={i} className="animate-pulse">
<CardContent className="space-y-4">
<div className="h-6 w-1/3 bg-dark-200 rounded" />
<div className="h-5 w-1/4 bg-dark-200 rounded" />
<div className="h-4 w-full bg-dark-200 rounded" />
<div className="h-4 w-5/6 bg-dark-200 rounded" />
<div className="flex gap-2">
{[1, 2, 3, 4].map((j) => (
<div key={j} className="h-6 w-20 bg-dark-200 rounded" />
))}
</div>
</CardContent>
</Card>
))}
</div>
</div>
</section>
)
}
if (error) {
return (
<section id="experience" className="section bg-dark-50" aria-labelledby="experience-heading">
<div className="container text-center py-12">
<p className="text-brand-500">Failed to load experience. Please try again later.</p>
</div>
</section>
)
}
return (
<section id="experience" className="section bg-dark-50" aria-labelledby="experience-heading">
<div className="container">
<div className="text-center max-w-3xl mx-auto mb-16">
<Badge className="mb-4">Professional Journey</Badge>
<h2 id="experience-heading" className="section-title">Experience</h2>
<p className="section-subtitle mx-auto">
Over a decade of building software solutions for startups, scale-ups, and enterprises.
From hands-on development to technical leadership and architecture.
</p>
</div>
<div className="relative max-w-3xl mx-auto">
<div className="absolute left-8 top-0 bottom-0 w-0.5 bg-gradient-to-b from-brand-500 to-transparent" />
<div className="space-y-12">
{experiences?.map((exp) => (
<div key={exp.id} className="relative">
<div className="absolute left-0 top-4 w-4 h-4 rounded-full bg-brand-500 border-4 bg-white ring-2 ring-brand-500 z-10" />
<Card variant="hover" className="pl-6 ml-6">
<CardContent className="space-y-4">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 rounded-xl bg-brand-100 text-brand-500 flex items-center justify-center">
<Building2 className="h-6 w-6" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2 flex-wrap mb-1">
<h3 className="text-xl font-bold text-dark-900">{exp.role}</h3>
<span className="px-2 py-0.5 bg-brand-100 text-brand-700 text-xs font-medium rounded">
{exp.current ? 'Current' : 'Past'}
</span>
</div>
<p className="text-brand-500 font-medium">{exp.company}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-4 text-sm text-dark-500 pl-16">
<span className="flex items-center gap-1.5">
<MapPin className="h-4 w-4" />
{exp.location}
</span>
<span className="flex items-center gap-1.5">
<Calendar className="h-4 w-4" />
{formatDateRange(exp.start_date, exp.end_date, exp.current)}
</span>
</div>
<p className="text-dark-600 pl-16">{exp.description}</p>
<div className="flex flex-wrap gap-2 pl-16">
{exp.tech_stack.slice(0, 8).map((tech) => (
<Badge key={tech} variant="outline" size="sm">
<Code2 className="h-3 w-3" />
{tech}
</Badge>
))}
{exp.tech_stack.length > 8 && (
<Badge variant="outline" size="sm">+{exp.tech_stack.length - 8} more</Badge>
)}
</div>
</CardContent>
</Card>
</div>
))}
</div>
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,83 @@
import { ArrowRight, Code, Zap, Globe } from 'lucide-react'
import { Button } from '../ui/Button'
export function Hero() {
const scrollToContact = () => {
const element = document.getElementById('contact')
if (element) element.scrollIntoView({ behavior: 'smooth' })
}
const stats = [
{ value: '50+', label: 'Projects Delivered' },
{ value: '10+', label: 'Years Experience' },
{ value: '15+', label: 'Technologies' },
{ value: '100%', label: 'Client Satisfaction' },
]
return (
<section id="home" className="relative min-h-screen flex items-center justify-center overflow-hidden pt-16 lg:pt-20">
<div className="absolute inset-0 bg-gradient-to-br from-brand-50 via-white to-dark-50" />
<div className="absolute inset-0 bg-[url('/grid.svg')] opacity-5" />
<div className="absolute top-20 right-10 w-96 h-96 bg-brand-500/10 rounded-full blur-3xl animate-float" />
<div className="absolute bottom-20 left-10 w-72 h-72 bg-brand-500/10 rounded-full blur-3xl animate-float" style={{ animationDelay: '2s' }} />
<div className="container relative py-20 lg:py-32">
<div className="max-w-4xl mx-auto text-center">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-brand-100 text-brand-700 text-sm font-medium mb-8 animate-fade-in">
<Zap className="h-4 w-4" />
<span>Now accepting new projects for Q1 2026</span>
</div>
<h1 className="text-4xl sm:text-5xl lg:text-6xl xl:text-7xl font-display font-bold text-dark-900 tracking-tight mb-6 animate-slide-up">
Building <span className="gradient-text">Digital Products</span> That Scale
</h1>
<p className="text-lg sm:text-xl lg:text-2xl text-dark-600 mb-10 max-w-3xl mx-auto animate-slide-up animate-delay-100">
Expert web application & backend development. Go, Java, React, Cloud-native architectures.
From concept to production — we deliver maintainable, scalable software.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-16 animate-slide-up animate-delay-200">
<Button size="lg" onClick={scrollToContact} className="gap-2">
Start a Project
<ArrowRight className="h-5 w-5" />
</Button>
<Button variant="outline" size="lg" onClick={() => document.getElementById('projects')?.scrollIntoView({ behavior: 'smooth' })}>
View Work
</Button>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 mb-16 animate-slide-up animate-delay-300">
{stats.map((stat) => (
<div key={stat.label} className="text-center">
<div className="text-3xl sm:text-4xl font-display font-bold text-brand-500 mb-1">{stat.value}</div>
<div className="text-sm text-dark-600">{stat.label}</div>
</div>
))}
</div>
<div className="flex flex-wrap items-center justify-center gap-6 text-sm text-dark-500 animate-slide-up animate-delay-400">
<span className="flex items-center gap-1.5">
<Code className="h-4 w-4" />
Go • Java • React • TypeScript
</span>
<span className="flex items-center gap-1.5">
<Globe className="h-4 w-4" />
Kubernetes • AWS • GCP
</span>
<span className="flex items-center gap-1.5">
<Zap className="h-4 w-4" />
Microservices • APIs • DevOps
</span>
</div>
</div>
</div>
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 animate-bounce-subtle">
<svg className="h-8 w-8 text-dark-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</div>
</section>
)
}

View file

@ -0,0 +1,157 @@
import { useApi } from '../../hooks/useApi'
import { api } from '../../services/api'
import { useCallback } from 'react'
import { Card, CardContent } from '../ui/Card'
import { Badge } from '../ui/Badge'
import { Button } from '../ui/Button'
import { Github, ExternalLink, Code2, Database, Cloud } from 'lucide-react'
const techIcons: Record<string, React.ComponentType<{ className?: string }>> = {
go: Code2,
java: Code2,
react: Code2,
kubernetes: Cloud,
docker: Cloud,
postgresql: Database,
redis: Database,
aws: Cloud,
gcp: Cloud,
grpc: Code2,
typescript: Code2,
}
export function Projects() {
const fetchFeaturedProjects = useCallback(() => api.projects.getFeatured(), [])
const { data: projects, loading, error } = useApi(fetchFeaturedProjects)
if (loading) {
return (
<section id="projects" className="section bg-dark-50" aria-labelledby="projects-heading">
<div className="container">
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<Card key={i} className="animate-pulse overflow-hidden">
<div className="aspect-video bg-dark-200" />
<CardContent className="space-y-4">
<div className="h-6 w-3/4 bg-dark-200 rounded" />
<div className="h-4 w-full bg-dark-200 rounded" />
<div className="h-4 w-5/6 bg-dark-200 rounded" />
<div className="flex gap-2">
{[1, 2, 3].map((j) => (
<div key={j} className="h-6 w-16 bg-dark-200 rounded" />
))}
</div>
</CardContent>
</Card>
))}
</div>
</div>
</section>
)
}
if (error) {
return (
<section id="projects" className="section bg-dark-50" aria-labelledby="projects-heading">
<div className="container text-center py-12">
<p className="text-brand-500">Failed to load projects. Please try again later.</p>
</div>
</section>
)
}
const getTechIcon = (tech: string) => {
const key = tech.toLowerCase()
return techIcons[key] || Code2
}
return (
<section id="projects" className="section bg-dark-50" aria-labelledby="projects-heading">
<div className="container">
<div className="text-center max-w-3xl mx-auto mb-16">
<Badge className="mb-4">Featured Work</Badge>
<h2 id="projects-heading" className="section-title">Selected Projects</h2>
<p className="section-subtitle mx-auto">
A showcase of projects demonstrating expertise across full-stack development,
backend systems, cloud infrastructure, and real-time applications.
</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{projects?.map((project) => (
<Card key={project.id} variant="hover" className="overflow-hidden group">
{project.image_url && (
<div className="aspect-video relative overflow-hidden">
<img
src={project.image_url}
alt={project.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
loading="lazy"
/>
<div className="absolute inset-0 bg-gradient-to-t from-dark-900/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
</div>
)}
<CardContent className="space-y-4">
<div className="flex items-center gap-2">
<Badge variant="dark">{project.category}</Badge>
{project.featured && (
<Badge variant="brand">Featured</Badge>
)}
</div>
<h3 className="text-xl font-bold text-dark-900 group-hover:text-brand-500 transition-colors">
{project.title}
</h3>
<p className="text-dark-600 line-clamp-3">{project.short_desc || project.description}</p>
<div className="flex flex-wrap gap-2">
{project.tech_stack.slice(0, 6).map((tech) => {
const Icon = getTechIcon(tech)
return (
<Badge key={tech} variant="outline" className="gap-1">
<Icon className="h-3 w-3" />
{tech}
</Badge>
)
})}
{project.tech_stack.length > 6 && (
<Badge variant="outline">+{project.tech_stack.length - 6} more</Badge>
)}
</div>
<div className="flex items-center gap-4 pt-2 border-t border-dark-100">
{project.demo_url && (
<a
href={project.demo_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-brand-500 hover:text-brand-600 flex items-center gap-1"
>
<ExternalLink className="h-4 w-4" />
Demo
</a>
)}
{project.repo_url && (
<a
href={project.repo_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-dark-500 hover:text-brand-500 flex items-center gap-1"
>
<Github className="h-4 w-4" />
Code
</a>
)}
</div>
</CardContent>
</Card>
))}
</div>
<div className="text-center mt-12">
<Button variant="outline" size="lg" onClick={() => document.getElementById('projects')?.scrollIntoView({ behavior: 'smooth' })}>
View All Projects
<ExternalLink className="h-5 w-5" />
</Button>
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,112 @@
import { useApi } from '../../hooks/useApi'
import { api } from '../../services/api'
import { useCallback } from 'react'
import { Card, CardContent } from '../ui/Card'
import { Badge } from '../ui/Badge'
import { Button } from '../ui/Button'
import {
Code,
Server,
Cloud,
Layout,
RefreshCw,
UserCheck,
ArrowRight,
Check,
} from 'lucide-react'
const serviceIcons: Record<string, React.ComponentType<{ className?: string }>> = {
code: Code,
server: Server,
cloud: Cloud,
architecture: Layout,
refresh: RefreshCw,
advisor: UserCheck,
}
export function Services() {
const fetchServices = useCallback(() => api.services.getAll(), [])
const { data: services, loading, error } = useApi(fetchServices)
if (loading) {
return (
<section id="services" className="section bg-white" aria-labelledby="services-heading">
<div className="container">
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Card key={i} className="animate-pulse">
<CardContent className="space-y-4">
<div className="h-12 w-12 rounded-xl bg-dark-200" />
<div className="h-6 w-3/4 bg-dark-200 rounded" />
<div className="h-4 w-full bg-dark-200 rounded" />
<div className="h-4 w-5/6 bg-dark-200 rounded" />
<div className="space-y-2">
{[1, 2, 3].map((j) => (
<div key={j} className="h-4 w-full bg-dark-200 rounded" />
))}
</div>
</CardContent>
</Card>
))}
</div>
</div>
</section>
)
}
if (error) {
return (
<section id="services" className="section bg-white" aria-labelledby="services-heading">
<div className="container text-center py-12">
<p className="text-brand-500">Failed to load services. Please try again later.</p>
</div>
</section>
)
}
return (
<section id="services" className="section bg-white" aria-labelledby="services-heading">
<div className="container">
<div className="text-center max-w-3xl mx-auto mb-16">
<Badge className="mb-4">What We Do</Badge>
<h2 id="services-heading" className="section-title">Services & Expertise</h2>
<p className="section-subtitle mx-auto">
Comprehensive software development services — from initial architecture to ongoing maintenance.
We specialize in building robust, scalable systems that drive business value.
</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{services?.map((service) => {
const Icon = serviceIcons[service.icon || 'code'] || Code
return (
<Card key={service.id} variant="hover" className="group">
<CardContent className="space-y-4 h-full flex flex-col">
<div className="w-12 h-12 rounded-xl bg-brand-100 text-brand-500 flex items-center justify-center group-hover:bg-brand-500 group-hover:text-white transition-all duration-300">
<Icon className="h-7 w-7" />
</div>
<div className="flex-1">
<h3 className="text-xl font-bold text-dark-900 mb-2">{service.title}</h3>
<p className="text-dark-600 mb-4">{service.description}</p>
<ul className="space-y-2">
{service.features.map((feature) => (
<li key={feature} className="flex items-start gap-2 text-sm text-dark-600">
<Check className="h-4 w-4 text-brand-500 flex-shrink-0 mt-0.5" />
<span>{feature}</span>
</li>
))}
</ul>
</div>
<Button variant="ghost" className="w-full justify-start group-hover:text-brand-500">
Learn more
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</Button>
</CardContent>
</Card>
)
})}
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,112 @@
import { useApi } from '../../hooks/useApi'
import { api } from '../../services/api'
import { useCallback } from 'react'
import { type Skill } from '../../types'
import { Card, CardContent } from '../ui/Card'
import { Badge } from '../ui/Badge'
import { cn } from '../../utils/helpers'
import { getProficiencyLabel, getProficiencyColor } from '../../utils/helpers'
const categories = ['Backend', 'Frontend', 'Architecture', 'DevOps', 'Database', 'API']
export function Skills() {
const fetchSkills = useCallback(() => api.skills.getAll(), [])
const { data: skills, loading, error } = useApi(fetchSkills)
if (loading) {
return (
<section id="skills" className="section bg-white" aria-labelledby="skills-heading">
<div className="container">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{[...Array(12)].map((_, i) => (
<Card key={i} className="animate-pulse">
<CardContent className="space-y-3">
<div className="h-6 w-3/4 bg-dark-200 rounded" />
<div className="h-2 w-full bg-dark-200 rounded" />
<div className="h-2 w-5/6 bg-dark-200 rounded" />
</CardContent>
</Card>
))}
</div>
</div>
</section>
)
}
if (error) {
return (
<section id="skills" className="section bg-white" aria-labelledby="skills-heading">
<div className="container text-center py-12">
<p className="text-brand-500">Failed to load skills. Please try again later.</p>
</div>
</section>
)
}
const skillsByCategory = skills?.reduce((acc, skill) => {
if (!acc[skill.category]) acc[skill.category] = []
acc[skill.category].push(skill)
return acc
}, {} as Record<string, Skill[]>) || {}
const sortedCategories = categories.filter(c => skillsByCategory[c]?.length)
return (
<section id="skills" className="section bg-white" aria-labelledby="skills-heading">
<div className="container">
<div className="text-center max-w-3xl mx-auto mb-16">
<Badge className="mb-4">Technologies & Expertise</Badge>
<h2 id="skills-heading" className="section-title">Technical Skills</h2>
<p className="section-subtitle mx-auto">
Deep expertise across the full stack — from backend systems and cloud infrastructure
to modern frontend frameworks and API design.
</p>
</div>
<div className="space-y-16">
{sortedCategories.map((category) => {
const categorySkills = skillsByCategory[category] || []
return (
<div key={category}>
<h3 className="text-2xl font-bold text-dark-900 mb-6 flex items-center gap-3">
<span className="w-1 h-8 bg-brand-500 rounded-full" />
{category}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{categorySkills
.sort((a, b) => b.proficiency - a.proficiency)
.map((skill) => (
<Card key={skill.id} variant="hover" className="group">
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{skill.icon && (
<div className="w-10 h-10 rounded-lg bg-brand-100 text-brand-500 flex items-center justify-center text-xl font-mono font-bold">
{skill.icon.slice(0, 3).toUpperCase()}
</div>
)}
<span className="font-medium text-dark-900">{skill.name}</span>
</div>
<Badge variant="brand" size="sm">{getProficiencyLabel(skill.proficiency)}</Badge>
</div>
<div className="h-2 bg-dark-100 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all duration-1000 ease-out',
getProficiencyColor(skill.proficiency)
)}
style={{ width: `${(skill.proficiency / 5) * 100}%` }}
/>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
})}
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,37 @@
import { type HTMLAttributes } from 'react'
import { cn } from '../../utils/helpers'
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
variant?: 'brand' | 'dark' | 'success' | 'warning' | 'error' | 'outline'
size?: 'sm' | 'md'
}
export const Badge = ({ className, variant = 'brand', size = 'md', children, ...props }: BadgeProps) => {
const variants = {
brand: 'bg-brand-100 text-brand-700',
dark: 'bg-dark-100 text-dark-700',
success: 'bg-green-100 text-green-700',
warning: 'bg-yellow-100 text-yellow-700',
error: 'bg-red-100 text-red-700',
outline: 'border border-dark-200 text-dark-600 hover:bg-dark-100',
}
const sizes = {
sm: 'px-2 py-0.5 text-xs',
md: 'px-3 py-1 text-sm',
}
return (
<span
className={cn(
'inline-flex items-center font-medium rounded-full',
variants[variant],
sizes[size],
className
)}
{...props}
>
{children}
</span>
)
}

View file

@ -0,0 +1,46 @@
import { forwardRef, type ButtonHTMLAttributes } from 'react'
import { cn } from '../../utils/helpers'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost'
size?: 'sm' | 'md' | 'lg'
loading?: boolean
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'primary', size = 'md', loading, children, disabled, ...props }, ref) => {
const baseStyles = 'inline-flex items-center justify-center gap-2 font-medium rounded-lg transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed'
const variants = {
primary: 'bg-brand-500 text-white hover:bg-brand-600 focus-visible:ring-brand-500 shadow-lg shadow-brand-500/25',
secondary: 'bg-dark-100 text-dark-900 hover:bg-dark-200 focus-visible:ring-dark-500 border border-dark-200',
outline: 'border-2 border-brand-500 text-brand-500 hover:bg-brand-500 hover:text-white focus-visible:ring-brand-500',
ghost: 'text-dark-600 hover:text-dark-900 hover:bg-dark-100 focus-visible:ring-dark-500',
}
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-5 py-2.5 text-base',
lg: 'px-7 py-3.5 text-lg',
}
return (
<button
ref={ref}
className={cn(baseStyles, variants[variant], sizes[size], className)}
disabled={disabled || loading}
{...props}
>
{loading && (
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
)}
{children}
</button>
)
}
)
Button.displayName = 'Button'

View file

@ -0,0 +1,66 @@
import { forwardRef, type HTMLAttributes } from 'react'
import { cn } from '../../utils/helpers'
interface CardProps extends HTMLAttributes<HTMLDivElement> {
variant?: 'default' | 'hover' | 'glass'
padding?: 'none' | 'sm' | 'md' | 'lg'
}
export const Card = forwardRef<HTMLDivElement, CardProps>(
({ className, variant = 'default', padding = 'md', children, ...props }, ref) => {
const variants = {
default: 'bg-white rounded-2xl border border-dark-100 overflow-hidden transition-all duration-300',
hover: 'bg-white rounded-2xl border border-dark-100 overflow-hidden transition-all duration-300 hover:shadow-xl hover:border-brand-200 hover:-translate-y-1 hover:shadow-glow',
glass: 'bg-white/80 backdrop-blur-lg border border-white/20 rounded-2xl overflow-hidden',
}
const paddings = {
none: '',
sm: 'p-4',
md: 'p-6',
lg: 'p-8',
}
return (
<div
ref={ref}
className={cn(variants[variant], paddings[padding], className)}
{...props}
>
{children}
</div>
)
}
)
Card.displayName = 'Card'
export const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, children, ...props }, ref) => (
<div ref={ref} className={cn('mb-4', className)} {...props}>
{children}
</div>
)
)
CardHeader.displayName = 'CardHeader'
export const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingElement>>(
({ className, children, ...props }, ref) => (
<h3 ref={ref} className={cn('text-xl font-bold text-dark-900', className)} {...props}>
{children}
</h3>
)
)
CardTitle.displayName = 'CardTitle'
export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, children, ...props }, ref) => (
<div ref={ref} className={cn('', className)} {...props}>
{children}
</div>
)
)
CardContent.displayName = 'CardContent'

View file

@ -0,0 +1,98 @@
import { forwardRef, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react'
import { cn } from '../../utils/helpers'
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
helperText?: string
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, label, error, helperText, id, ...props }, ref) => {
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
return (
<div className="w-full">
{label && (
<label htmlFor={inputId} className="block text-sm font-medium text-dark-700 mb-1.5">
{label}
</label>
)}
<input
ref={ref}
id={inputId}
className={cn(
'w-full px-4 py-3 rounded-lg border bg-white text-dark-900 placeholder-dark-400 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-brand-500/20',
error
? 'border-brand-500 focus:border-brand-500'
: 'border-dark-200 focus:border-brand-500',
className
)}
aria-invalid={error ? 'true' : 'false'}
aria-describedby={error ? `${inputId}-error` : helperText ? `${inputId}-helper` : undefined}
{...props}
/>
{error && (
<p id={`${inputId}-error`} className="mt-1.5 text-sm text-brand-500" role="alert">
{error}
</p>
)}
{helperText && !error && (
<p id={`${inputId}-helper`} className="mt-1.5 text-sm text-dark-500">
{helperText}
</p>
)}
</div>
)
}
)
Input.displayName = 'Input'
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string
error?: string
helperText?: string
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, label, error, helperText, id, ...props }, ref) => {
const textareaId = id || label?.toLowerCase().replace(/\s+/g, '-')
return (
<div className="w-full">
{label && (
<label htmlFor={textareaId} className="block text-sm font-medium text-dark-700 mb-1.5">
{label}
</label>
)}
<textarea
ref={ref}
id={textareaId}
className={cn(
'w-full px-4 py-3 rounded-lg border bg-white text-dark-900 placeholder-dark-400 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-brand-500/20 resize-y min-h-[120px]',
error
? 'border-brand-500 focus:border-brand-500'
: 'border-dark-200 focus:border-brand-500',
className
)}
aria-invalid={error ? 'true' : 'false'}
aria-describedby={error ? `${textareaId}-error` : helperText ? `${textareaId}-helper` : undefined}
{...props}
/>
{error && (
<p id={`${textareaId}-error`} className="mt-1.5 text-sm text-brand-500" role="alert">
{error}
</p>
)}
{helperText && !error && (
<p id={`${textareaId}-helper`} className="mt-1.5 text-sm text-dark-500">
{helperText}
</p>
)}
</div>
)
}
)
Textarea.displayName = 'Textarea'

59
web/src/hooks/useApi.ts Normal file
View file

@ -0,0 +1,59 @@
import { useState, useEffect, useCallback } from 'react'
import { ApiError } from '../services/api'
interface UseApiState<T> {
data: T | null
loading: boolean
error: ApiError | null
}
interface UseApiReturn<T> extends UseApiState<T> {
refetch: () => Promise<void>
}
export function useApi<T>(fetchFn: () => Promise<T>, deps: unknown[] = []): UseApiReturn<T> {
const [state, setState] = useState<UseApiState<T>>({
data: null,
loading: true,
error: null,
})
const fetchData = useCallback(async () => {
setState(prev => ({ ...prev, loading: true, error: null }))
try {
const data = await fetchFn()
setState({ data, loading: false, error: null })
} catch (error) {
setState({ data: null, loading: false, error: error as ApiError })
}
}, [fetchFn])
useEffect(() => {
fetchData()
}, [fetchData, ...deps])
return { ...state, refetch: fetchData }
}
export function useApiMutation<T, Args extends unknown[]>(
mutationFn: (...args: Args) => Promise<T>
) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<ApiError | null>(null)
const mutate = useCallback(async (...args: Args): Promise<T | null> => {
setLoading(true)
setError(null)
try {
const result = await mutationFn(...args)
setLoading(false)
return result
} catch (err) {
setError(err as ApiError)
setLoading(false)
return null
}
}, [mutationFn])
return { mutate, loading, error }
}

13
web/src/main.tsx Normal file
View file

@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './assets/styles/main.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)

74
web/src/services/api.ts Normal file
View file

@ -0,0 +1,74 @@
import type { APIResponse, Skill, Project, Experience, Service, ContactForm, ContactSubmission } from '../types'
const API_BASE = '/api/v1'
class ApiError extends Error {
constructor(public code: string, message: string, public details?: string) {
super(message)
this.name = 'ApiError'
}
}
async function fetchAPI<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`${API_BASE}${endpoint}`, {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
})
const data: APIResponse<T> = await response.json()
if (!response.ok || !data.success) {
throw new ApiError(
data.error?.code || 'UNKNOWN_ERROR',
data.error?.message || 'An error occurred',
data.error?.details
)
}
return data.data as T
}
export const api = {
skills: {
getAll: (category?: string) =>
fetchAPI<Skill[]>(`/skills${category ? `?category=${category}` : ''}`),
getById: (id: string) =>
fetchAPI<Skill>(`/skills/${id}`),
},
projects: {
getAll: (category?: string, featured?: boolean) => {
const params = new URLSearchParams()
if (category) params.append('category', category)
if (featured) params.append('featured', 'true')
return fetchAPI<Project[]>(`/projects${params.toString() ? `?${params}` : ''}`)
},
getFeatured: () =>
fetchAPI<Project[]>('/projects/featured'),
getById: (id: string) =>
fetchAPI<Project>(`/projects/${id}`),
},
experience: {
getAll: () =>
fetchAPI<Experience[]>('/experience'),
},
services: {
getAll: () =>
fetchAPI<Service[]>('/services'),
},
contact: {
submit: (form: ContactForm) =>
fetchAPI<ContactSubmission>('/contact', {
method: 'POST',
body: JSON.stringify(form),
}),
},
health: {
check: () =>
fetchAPI<{ status: string; service: string; version: string }>('/health'),
},
}
export { ApiError }

87
web/src/types/index.ts Normal file
View file

@ -0,0 +1,87 @@
export interface Skill {
id: string
name: string
category: string
proficiency: number
icon?: string
order: number
created_at: string
updated_at: string
}
export interface Project {
id: string
title: string
description: string
short_desc: string
tech_stack: string[]
image_url?: string
demo_url?: string
repo_url?: string
category: string
featured: boolean
order: number
created_at: string
updated_at: string
}
export interface Experience {
id: string
company: string
role: string
location: string
start_date: string
end_date?: string
current: boolean
description: string
tech_stack: string[]
order: number
created_at: string
updated_at: string
}
export interface Service {
id: string
title: string
description: string
icon?: string
features: string[]
order: number
active: boolean
created_at: string
updated_at: string
}
export interface ContactForm {
name: string
email: string
subject: string
message: string
}
export interface ContactSubmission {
id: string
name: string
email: string
subject: string
message: string
status: string
created_at: string
updated_at: string
}
export interface APIResponse<T> {
success: boolean
data?: T
error?: {
code: string
message: string
details?: string
}
meta?: {
page: number
page_size: number
total: number
total_pages: number
}
}

72
web/src/utils/helpers.ts Normal file
View file

@ -0,0 +1,72 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatDate(date: string | Date): string {
const d = new Date(date)
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
})
}
export function formatDateRange(start: string, end?: string | null, current = false): string {
const startDate = formatDate(start)
const endDate = current ? 'Present' : end ? formatDate(end) : ''
return endDate ? `${startDate} - ${endDate}` : startDate
}
export function getProficiencyLabel(level: number): string {
const labels = ['', 'Beginner', 'Intermediate', 'Advanced', 'Expert', 'Master']
return labels[level] || 'Unknown'
}
export function getProficiencyColor(level: number): string {
if (level <= 2) return 'bg-green-500'
if (level <= 3) return 'bg-blue-500'
if (level <= 4) return 'bg-yellow-500'
return 'bg-red-500'
}
export function debounce<T extends (...args: unknown[]) => unknown>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout>
return (...args: Parameters<T>) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => fn(...args), delay)
}
}
export function throttle<T extends (...args: unknown[]) => unknown>(
fn: T,
limit: number
): (...args: Parameters<T>) => void {
let inThrottle = false
return (...args: Parameters<T>) => {
if (!inThrottle) {
fn(...args)
inThrottle = true
setTimeout(() => (inThrottle = false), limit)
}
}
}
export function generateId(): string {
return Math.random().toString(36).substring(2, 11)
}
export function validateEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
export function scrollToSection(sectionId: string): void {
const element = document.getElementById(sectionId)
if (element) {
element.scrollIntoView({ behavior: 'smooth' })
}
}

90
web/tailwind.config.js Normal file
View file

@ -0,0 +1,90 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
brand: {
50: '#fef7ee',
100: '#fdedd6',
200: '#fad7ac',
300: '#f6ba78',
400: '#f19341',
500: '#ed7315', // Primary orange - "go-bananas" energy
600: '#de5c0d',
700: '#b8430f',
800: '#933514',
900: '#772e13',
950: '#401307',
},
dark: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
950: '#020617',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
display: ['Cal Sans', 'Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
animation: {
'fade-in': 'fadeIn 0.5s ease-out',
'slide-up': 'slideUp 0.6s ease-out',
'slide-down': 'slideDown 0.3s ease-out',
'scale-in': 'scaleIn 0.2s ease-out',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'bounce-subtle': 'bounceSubtle 2s infinite',
'float': 'float 6s ease-in-out infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { opacity: '0', transform: 'translateY(20px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
slideDown: {
'0%': { opacity: '0', transform: 'translateY(-10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
scaleIn: {
'0%': { opacity: '0', transform: 'scale(0.95)' },
'100%': { opacity: '1', transform: 'scale(1)' },
},
bounceSubtle: {
'0%, 100%': { transform: 'translateY(0)' },
'50%': { transform: 'translateY(-5px)' },
},
float: {
'0%, 100%': { transform: 'translateY(0px)' },
'50%': { transform: 'translateY(-10px)' },
},
},
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
'mesh-gradient': 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
},
boxShadow: {
'glow': '0 0 20px rgba(237, 115, 21, 0.3)',
'glow-lg': '0 0 40px rgba(237, 115, 21, 0.4)',
'inner-glow': 'inset 0 0 20px rgba(237, 115, 21, 0.1)',
},
},
},
plugins: [],
}

View file

@ -0,0 +1,82 @@
import { test, expect } from '@playwright/test'
test.describe('E2E - Infinite Loop Detection', () => {
test.beforeEach(async ({ page }) => {
// Track all network requests
const apiCalls: string[] = []
page.on('request', request => {
if (request.url().includes('/api/v1/')) {
apiCalls.push(`${request.method()} ${request.url()}`)
}
})
await page.goto('http://localhost:3000')
await page.waitForLoadState('networkidle')
})
test('should not make repeated API calls after initial load', async ({ page }) => {
const initialApiCalls = await page.evaluate(() => {
return (window as any).__apiCallCount || 0
})
// Wait for initial load
await page.waitForSelector('text=Services & Expertise')
await page.waitForSelector('text=Technical Skills')
// Wait a bit more to catch any delayed calls
await page.waitForTimeout(3000)
const finalApiCalls = await page.evaluate(() => {
return (window as any).__apiCallCount || 0
})
// Should not have made additional API calls
expect(finalApiCalls).toBeLessThanOrEqual(initialApiCalls + 1)
})
test('should load all sections without errors', async ({ page }) => {
await expect(page.locator('text=Services & Expertise')).toBeVisible()
await expect(page.locator('text=Selected Projects')).toBeVisible()
await expect(page.locator('text=Technical Skills')).toBeVisible()
await expect(page.locator('text=Experience')).toBeVisible()
await expect(page.locator('text=Let\\'s Build Something Great')).toBeVisible()
})
test('should handle navigation without duplicate fetches', async ({ page }) => {
// Scroll to trigger any lazy loading
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))
await page.waitForTimeout(1000)
await page.evaluate(() => window.scrollTo(0, 0))
await page.waitForTimeout(1000)
// Check for console errors
const errors: string[] = []
page.on('console', msg => {
if (msg.type() === 'error') {
errors.push(msg.text())
}
})
await page.waitForTimeout(2000)
// Filter out known non-critical errors
const criticalErrors = errors.filter(e =>
!e.includes('favicon') &&
!e.includes('404') &&
!e.includes('DevTools')
)
expect(criticalErrors.length).toBe(0)
})
test('contact form submission works', async ({ page }) => {
await page.locator('input[name="name"]').fill('John Doe')
await page.locator('input[name="email"]').fill('john@example.com')
await page.locator('input[name="subject"]').fill('Test Subject')
await page.locator('textarea[name="message"]').fill('This is a test message for the contact form.')
await page.click('button:has-text("Send Message")')
await expect(page.locator('text=Message Sent!')).toBeVisible({ timeout: 10000 })
})
})

View file

@ -0,0 +1,73 @@
import { test, expect } from '@playwright/test'
test.describe('Network Request Monitoring', () => {
test('tracks API calls to detect infinite loops', async ({ page }) => {
const apiCalls: { url: string; timestamp: number; count: number }[] = []
const urlCounts = new Map<string, number>()
page.on('request', request => {
const url = request.url()
if (url.includes('/api/v1/')) {
const count = (urlCounts.get(url) || 0) + 1
urlCounts.set(url, count)
apiCalls.push({ url, timestamp: Date.now(), count })
}
})
await page.goto('http://localhost:3000')
await page.waitForLoadState('networkidle')
await page.waitForSelector('text=Technical Skills')
// Wait for any potential delayed calls
await page.waitForTimeout(5000)
// Check for duplicate calls to the same endpoint
const duplicateCalls = Array.from(urlCounts.entries())
.filter(([_, count]) => count > 1)
.map(([url, count]) => ({ url, count }))
console.log('API Call counts:', Object.fromEntries(urlCounts))
console.log('Duplicate calls:', duplicateCalls)
// Allow 1 call per endpoint, but flag if any endpoint is called more than twice
const excessiveCalls = duplicateCalls.filter(d => d.count > 2)
expect(excessiveCalls.length).toBe(0)
})
test('monitors fetch calls for repeated patterns', async ({ page }) => {
await page.goto('http://localhost:3000')
await page.waitForLoadState('networkidle')
// Inject monitoring script
await page.addInitScript(() => {
const originalFetch = window.fetch
const callLog: { url: string; count: number }[] = []
window.fetch = async function(...args) {
const url = args[0]?.toString() || ''
if (url.includes('/api/v1/')) {
const existing = callLog.find(c => c.url === url)
if (existing) {
existing.count++
} else {
callLog.push({ url, count: 1 })
}
}
return originalFetch.apply(this, args)
}
;(window as any).__getApiCallLog = () => callLog
})
await page.waitForSelector('text=Technical Skills')
await page.waitForTimeout(3000)
const callLog = await page.evaluate(() => (window as any).__getApiCallLog())
console.log('Fetch call log:', callLog)
// Check for excessive calls
const excessive = callLog.filter((c: { count: number }) => c.count > 2)
expect(excessive.length).toBe(0)
})
})

26
web/tsconfig.json Normal file
View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"types": ["vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

11
web/tsconfig.node.json Normal file
View file

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

34
web/vite.config.ts Normal file
View file

@ -0,0 +1,34 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
build: {
outDir: '../dist',
emptyOutDir: true,
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom', 'react-router-dom'],
ui: ['lucide-react'],
},
},
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8081',
changeOrigin: true,
},
},
},
})

27
web/vitest.config.ts Normal file
View file

@ -0,0 +1,27 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
test: {
environment: 'happy-dom',
globals: true,
setupFiles: ['./src/__tests__/setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
testTimeout: 30000,
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'src/__tests__/', 'src/main.tsx', 'src/vite-env.d.ts'],
},
deps: {
inline: ['@testing-library/jest-dom'],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})