Compare commits
14 Commits
feature/mn
...
feature/ba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef8ee0e312 | ||
|
|
eef636a2f6 | ||
|
|
1b3979316f | ||
|
|
26acbbc810 | ||
|
|
5cde4bf00a | ||
|
|
69a9b7b689 | ||
|
|
5b2f707c1a | ||
|
|
a38a1eb0eb | ||
|
|
377bf266d1 | ||
|
|
ba42e85334 | ||
|
|
70499a9f39 | ||
|
|
8a04da0dde | ||
|
|
d0f14dfca2 | ||
|
|
324e861218 |
11
.drone.yml
11
.drone.yml
@@ -19,8 +19,8 @@ steps:
|
|||||||
- docker stop counter-app || true
|
- docker stop counter-app || true
|
||||||
- docker rm counter-app || true
|
- docker rm counter-app || true
|
||||||
|
|
||||||
# Run the new container
|
# Run the new container with environment variables
|
||||||
- docker run -d --name counter-app -p 8080:8080 localhost:5000/counter:latest
|
- docker run -d --name counter-app -e ENVIRONMENT=production -e JWT_SECRET=your-super-secure-production-secret -e DATABASE_URL=postgres://postgres:password@infra-postgres-1:5432/counter_db?sslmode=disable -e GIN_MODE=release -e LOG_LEVEL=warn -p 8080:8080 --network infra_backend_nw localhost:5000/counter:latest
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- name: dockersock
|
- name: dockersock
|
||||||
@@ -31,3 +31,10 @@ trigger:
|
|||||||
branch:
|
branch:
|
||||||
- main
|
- main
|
||||||
- feature/*
|
- feature/*
|
||||||
|
event:
|
||||||
|
- push
|
||||||
|
exclude:
|
||||||
|
event:
|
||||||
|
- pull_request
|
||||||
|
- pull_request_closed
|
||||||
|
- pull_request_synchronize
|
||||||
|
|||||||
5
.env
Normal file
5
.env
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Base Configuration
|
||||||
|
DATABASE_URL=postgres://postgres:password@localhost:5432/counter_db?sslmode=disable
|
||||||
|
PORT=8080
|
||||||
|
LOG_LEVEL=info
|
||||||
|
GIN_MODE=debug
|
||||||
7
.env.production
Normal file
7
.env.production
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Production Environment
|
||||||
|
ENVIRONMENT=production
|
||||||
|
DATABASE_URL=postgres://postgres:password@postgres:5432/counter_db?sslmode=disable
|
||||||
|
JWT_SECRET=super-secure-production-secret-change-this
|
||||||
|
PORT=8080
|
||||||
|
GIN_MODE=release
|
||||||
|
LOG_LEVEL=warn
|
||||||
78
.gitignore
vendored
78
.gitignore
vendored
@@ -17,13 +17,31 @@
|
|||||||
# Go workspace file
|
# Go workspace file
|
||||||
go.work
|
go.work
|
||||||
|
|
||||||
# IDE files
|
# Environment variables (keeping all env files in git as requested)
|
||||||
|
# .env
|
||||||
|
# .env.local
|
||||||
|
# .env.development.local
|
||||||
|
# .env.test.local
|
||||||
|
# .env.production.local
|
||||||
|
|
||||||
|
# Node.js
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# React build
|
||||||
|
frontend/build/
|
||||||
|
frontend/dist/
|
||||||
|
|
||||||
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
# OS generated files
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.DS_Store?
|
.DS_Store?
|
||||||
._*
|
._*
|
||||||
@@ -31,3 +49,59 @@ go.work
|
|||||||
.Trashes
|
.Trashes
|
||||||
ehthumbs.db
|
ehthumbs.db
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# next.js build output
|
||||||
|
.next
|
||||||
|
|
||||||
|
# nuxt.js build output
|
||||||
|
.nuxt
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
36
Dockerfile
36
Dockerfile
@@ -1,5 +1,5 @@
|
|||||||
# Build stage
|
# Build stage for Go backend
|
||||||
FROM golang:1.21-alpine AS builder
|
FROM golang:1.21-alpine AS go-builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -9,12 +9,30 @@ COPY go.mod go.sum ./
|
|||||||
# Download dependencies
|
# Download dependencies
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code and environment files
|
||||||
COPY . .
|
COPY *.go ./
|
||||||
|
COPY .env* ./
|
||||||
|
|
||||||
# Build the application
|
# Build the application
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
|
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
|
||||||
|
|
||||||
|
# Build stage for React frontend
|
||||||
|
FROM node:18-alpine AS frontend-builder
|
||||||
|
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY frontend/package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies (including dev dependencies for build)
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY frontend/ ./
|
||||||
|
|
||||||
|
# Build the React app
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
# Final stage
|
# Final stage
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
|
|
||||||
@@ -23,8 +41,14 @@ RUN apk --no-cache add ca-certificates
|
|||||||
|
|
||||||
WORKDIR /root/
|
WORKDIR /root/
|
||||||
|
|
||||||
# Copy the binary from builder stage
|
# Copy the Go binary from go-builder stage
|
||||||
COPY --from=builder /app/main .
|
COPY --from=go-builder /app/main .
|
||||||
|
|
||||||
|
# Copy environment files from go-builder stage
|
||||||
|
COPY --from=go-builder /app/.env* ./
|
||||||
|
|
||||||
|
# Copy the React build from frontend-builder stage
|
||||||
|
COPY --from=frontend-builder /app/frontend/build ./frontend/build
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|||||||
230
README.md
Normal file
230
README.md
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
# Counter - Habit Tracking Application
|
||||||
|
|
||||||
|
A full-stack web application for tracking habits and activities with counters. Users can create counters, increment/decrement them, and view statistics. The app supports both authenticated users (with database persistence) and anonymous users (with local storage).
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **User Authentication**: Register and login with JWT tokens
|
||||||
|
- **Anonymous Usage**: Work without registration using browser local storage
|
||||||
|
- **Counter Management**: Create, edit, delete, and manage counters
|
||||||
|
- **Increment/Decrement**: Track positive and negative changes
|
||||||
|
- **Date-based Tracking**: Counters store entries grouped by dates
|
||||||
|
- **Statistics**: View today, week, month, and total values
|
||||||
|
- **Search & Filter**: Find counters by name or description
|
||||||
|
- **Responsive Design**: Modern UI that works on all devices
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Backend**: Go with Gin framework
|
||||||
|
- **Frontend**: React with TypeScript
|
||||||
|
- **Database**: PostgreSQL
|
||||||
|
- **Authentication**: JWT tokens
|
||||||
|
- **Deployment**: Docker with multi-stage builds
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Docker and Docker Compose
|
||||||
|
- Go 1.21+ (for local development)
|
||||||
|
- Node.js 18+ (for local development)
|
||||||
|
|
||||||
|
### Using Docker Compose (Recommended)
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd counter
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start the application:
|
||||||
|
```bash
|
||||||
|
docker-compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Open your browser and navigate to `http://localhost:8080`
|
||||||
|
|
||||||
|
The application will automatically:
|
||||||
|
- Start PostgreSQL database
|
||||||
|
- Build and run the Go backend
|
||||||
|
- Build and serve the React frontend
|
||||||
|
- Create necessary database tables
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
#### Backend Setup
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
```bash
|
||||||
|
go mod download
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start PostgreSQL (using Docker):
|
||||||
|
```bash
|
||||||
|
docker run --name counter-postgres -e POSTGRES_DB=counter_db -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=password -p 5432:5432 -d postgres:15-alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Set environment variables:
|
||||||
|
```bash
|
||||||
|
export DATABASE_URL="postgres://postgres:password@localhost:5432/counter_db?sslmode=disable"
|
||||||
|
export JWT_SECRET="your-secret-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Run the backend:
|
||||||
|
```bash
|
||||||
|
go run .
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Frontend Setup
|
||||||
|
|
||||||
|
1. Navigate to frontend directory:
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install dependencies:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Start the development server:
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Open `http://localhost:3000` in your browser
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- `POST /api/v1/auth/register` - Register a new user
|
||||||
|
- `POST /api/v1/auth/login` - Login user
|
||||||
|
- `GET /api/v1/auth/me` - Get current user (protected)
|
||||||
|
|
||||||
|
### Counters (Protected)
|
||||||
|
- `GET /api/v1/counters` - Get all counters
|
||||||
|
- `POST /api/v1/counters` - Create a new counter
|
||||||
|
- `GET /api/v1/counters/:id` - Get specific counter
|
||||||
|
- `PUT /api/v1/counters/:id` - Update counter
|
||||||
|
- `DELETE /api/v1/counters/:id` - Delete counter
|
||||||
|
- `POST /api/v1/counters/:id/increment` - Increment/decrement counter
|
||||||
|
- `GET /api/v1/counters/:id/entries` - Get counter entries
|
||||||
|
- `GET /api/v1/counters/:id/stats` - Get counter statistics
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### Users Table
|
||||||
|
- `id` (SERIAL PRIMARY KEY)
|
||||||
|
- `username` (VARCHAR(50) UNIQUE)
|
||||||
|
- `email` (VARCHAR(255) UNIQUE)
|
||||||
|
- `password` (VARCHAR(255))
|
||||||
|
- `created_at` (TIMESTAMP)
|
||||||
|
- `updated_at` (TIMESTAMP)
|
||||||
|
|
||||||
|
### Counters Table
|
||||||
|
- `id` (SERIAL PRIMARY KEY)
|
||||||
|
- `user_id` (INTEGER REFERENCES users(id))
|
||||||
|
- `name` (VARCHAR(100))
|
||||||
|
- `description` (TEXT)
|
||||||
|
- `created_at` (TIMESTAMP)
|
||||||
|
- `updated_at` (TIMESTAMP)
|
||||||
|
|
||||||
|
### Counter Entries Table
|
||||||
|
- `id` (SERIAL PRIMARY KEY)
|
||||||
|
- `counter_id` (INTEGER REFERENCES counters(id))
|
||||||
|
- `value` (INTEGER)
|
||||||
|
- `date` (DATE)
|
||||||
|
- `created_at` (TIMESTAMP)
|
||||||
|
|
||||||
|
## Anonymous User Support
|
||||||
|
|
||||||
|
Anonymous users can use the application without registration:
|
||||||
|
- Counters are stored in browser's localStorage
|
||||||
|
- Data persists across browser sessions
|
||||||
|
- No server-side storage for anonymous users
|
||||||
|
- Seamless transition to authenticated user (data remains in localStorage)
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Create a `.env` file based on `env.example`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Database Configuration
|
||||||
|
DATABASE_URL=postgres://postgres:password@localhost:5432/counter_db?sslmode=disable
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
PORT=8080
|
||||||
|
GIN_MODE=release
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Production Deployment
|
||||||
|
|
||||||
|
1. Set environment variables for production
|
||||||
|
2. Use a production PostgreSQL database
|
||||||
|
3. Set a strong JWT secret
|
||||||
|
4. Build and deploy using Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t counter-app .
|
||||||
|
docker run -p 8080:8080 \
|
||||||
|
-e DATABASE_URL="your-production-db-url" \
|
||||||
|
-e JWT_SECRET="your-production-secret" \
|
||||||
|
counter-app
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose Production
|
||||||
|
|
||||||
|
Update `docker-compose.yml` with production settings:
|
||||||
|
- Use external PostgreSQL database
|
||||||
|
- Set production environment variables
|
||||||
|
- Configure proper networking and volumes
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
counter/
|
||||||
|
├── main.go # Main application entry point
|
||||||
|
├── models.go # Data models and DTOs
|
||||||
|
├── database.go # Database connection and setup
|
||||||
|
├── auth.go # Authentication handlers and middleware
|
||||||
|
├── counters.go # Counter management handlers
|
||||||
|
├── docker-compose.yml # Local development setup
|
||||||
|
├── Dockerfile # Multi-stage build for production
|
||||||
|
├── go.mod # Go dependencies
|
||||||
|
├── go.sum # Go dependencies checksum
|
||||||
|
└── frontend/ # React frontend
|
||||||
|
├── src/
|
||||||
|
│ ├── components/ # React components
|
||||||
|
│ ├── hooks/ # Custom React hooks
|
||||||
|
│ ├── services/ # API and localStorage services
|
||||||
|
│ ├── types/ # TypeScript type definitions
|
||||||
|
│ └── App.tsx # Main React component
|
||||||
|
├── package.json # Node.js dependencies
|
||||||
|
└── public/ # Static assets
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding New Features
|
||||||
|
|
||||||
|
1. **Backend**: Add new handlers in appropriate files
|
||||||
|
2. **Frontend**: Create components in `src/components/`
|
||||||
|
3. **API Integration**: Update services in `src/services/`
|
||||||
|
4. **Types**: Add TypeScript interfaces in `src/types/`
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Make your changes
|
||||||
|
4. Add tests if applicable
|
||||||
|
5. Submit a pull request
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License.
|
||||||
7
_.env.development
Normal file
7
_.env.development
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Development Environment
|
||||||
|
ENVIRONMENT=development
|
||||||
|
DATABASE_URL=postgres://postgres:password@postgres:5432/counter_db?sslmode=disable
|
||||||
|
JWT_SECRET=dev-secret-key-change-in-production
|
||||||
|
PORT=8080
|
||||||
|
GIN_MODE=debug
|
||||||
|
LOG_LEVEL=debug
|
||||||
7
_.env.staging
Normal file
7
_.env.staging
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Staging Environment
|
||||||
|
ENVIRONMENT=staging
|
||||||
|
DATABASE_URL=postgres://postgres:password@postgres:5432/counter_db?sslmode=disable
|
||||||
|
JWT_SECRET=staging-secret-key-change-this
|
||||||
|
PORT=8080
|
||||||
|
GIN_MODE=release
|
||||||
|
LOG_LEVEL=info
|
||||||
230
auth.go
Normal file
230
auth.go
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var jwtSecret []byte
|
||||||
|
|
||||||
|
// InitJWT initializes JWT secret (legacy function)
|
||||||
|
func InitJWT() {
|
||||||
|
secret := os.Getenv("JWT_SECRET")
|
||||||
|
if secret == "" {
|
||||||
|
// Generate a random secret for development
|
||||||
|
secret = generateRandomSecret()
|
||||||
|
log.Println("Warning: Using generated JWT secret. Set JWT_SECRET environment variable for production.")
|
||||||
|
}
|
||||||
|
jwtSecret = []byte(secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitJWTWithConfig initializes JWT secret with configuration
|
||||||
|
func InitJWTWithConfig(config *Config) {
|
||||||
|
jwtSecret = []byte(config.JWTSecret)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateRandomSecret generates a random secret for JWT
|
||||||
|
func generateRandomSecret() string {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
rand.Read(b)
|
||||||
|
return fmt.Sprintf("%x", b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hashPassword hashes a password using bcrypt
|
||||||
|
func hashPassword(password string) (string, error) {
|
||||||
|
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
|
||||||
|
return string(bytes), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkPasswordHash compares a password with its hash
|
||||||
|
func checkPasswordHash(password, hash string) bool {
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateToken generates a JWT token for a user
|
||||||
|
func generateToken(userID int, username string) (string, error) {
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"user_id": userID,
|
||||||
|
"username": username,
|
||||||
|
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days
|
||||||
|
"iat": time.Now().Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString(jwtSecret)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateToken validates a JWT token and returns the claims
|
||||||
|
func validateToken(tokenString string) (jwt.MapClaims, error) {
|
||||||
|
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||||
|
}
|
||||||
|
return jwtSecret, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthMiddleware validates JWT token and sets user context
|
||||||
|
func AuthMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
authHeader := c.GetHeader("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract token from "Bearer <token>"
|
||||||
|
tokenParts := strings.Split(authHeader, " ")
|
||||||
|
if len(tokenParts) != 2 || tokenParts[0] != "Bearer" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization header format"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenString := tokenParts[1]
|
||||||
|
claims, err := validateToken(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set user information in context
|
||||||
|
c.Set("user_id", int(claims["user_id"].(float64)))
|
||||||
|
c.Set("username", claims["username"])
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterHandler handles user registration
|
||||||
|
func RegisterHandler(c *gin.Context) {
|
||||||
|
var req RegisterRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if username already exists
|
||||||
|
var existingUser User
|
||||||
|
err := db.QueryRow("SELECT id FROM users WHERE username = $1 OR email = $2", req.Username, req.Email).Scan(&existingUser.ID)
|
||||||
|
if err != sql.ErrNoRows {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "Username or email already exists"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
hashedPassword, err := hashPassword(req.Password)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
var user User
|
||||||
|
err = db.QueryRow(
|
||||||
|
"INSERT INTO users (username, email, password) VALUES ($1, $2, $3) RETURNING id, username, email, created_at, updated_at",
|
||||||
|
req.Username, req.Email, hashedPassword,
|
||||||
|
).Scan(&user.ID, &user.Username, &user.Email, &user.CreatedAt, &user.UpdatedAt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate token
|
||||||
|
token, err := generateToken(user.ID, user.Username)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, AuthResponse{
|
||||||
|
Token: token,
|
||||||
|
User: user,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginHandler handles user login
|
||||||
|
func LoginHandler(c *gin.Context) {
|
||||||
|
var req LoginRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find user
|
||||||
|
var user User
|
||||||
|
err := db.QueryRow(
|
||||||
|
"SELECT id, username, email, password, created_at, updated_at FROM users WHERE username = $1",
|
||||||
|
req.Username,
|
||||||
|
).Scan(&user.ID, &user.Username, &user.Email, &user.Password, &user.CreatedAt, &user.UpdatedAt)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials 1"})
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check password
|
||||||
|
if !checkPasswordHash(req.Password, user.Password) {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials 2"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate token
|
||||||
|
token, err := generateToken(user.ID, user.Username)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear password from response
|
||||||
|
user.Password = ""
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, AuthResponse{
|
||||||
|
Token: token,
|
||||||
|
User: user,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCurrentUserHandler returns the current authenticated user
|
||||||
|
func GetCurrentUserHandler(c *gin.Context) {
|
||||||
|
userID := c.GetInt("user_id")
|
||||||
|
|
||||||
|
var user User
|
||||||
|
err := db.QueryRow(
|
||||||
|
"SELECT id, username, email, created_at, updated_at FROM users WHERE id = $1",
|
||||||
|
userID,
|
||||||
|
).Scan(&user.ID, &user.Username, &user.Email, &user.CreatedAt, &user.UpdatedAt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, user)
|
||||||
|
}
|
||||||
216
config.go
Normal file
216
config.go
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Environment string
|
||||||
|
|
||||||
|
const (
|
||||||
|
Development Environment = "development"
|
||||||
|
Staging Environment = "staging"
|
||||||
|
Production Environment = "production"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Environment Environment
|
||||||
|
DatabaseURL string
|
||||||
|
JWTSecret string
|
||||||
|
Port string
|
||||||
|
GinMode string
|
||||||
|
LogLevel string
|
||||||
|
Debug bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadConfig loads configuration with proper environment file precedence
|
||||||
|
func LoadConfig() *Config {
|
||||||
|
// Load environment files in priority order
|
||||||
|
loadEnvironmentFiles()
|
||||||
|
|
||||||
|
// Get environment
|
||||||
|
env := getEnvironment()
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
config := &Config{
|
||||||
|
Environment: env,
|
||||||
|
DatabaseURL: getEnv("DATABASE_URL", getDefaultDatabaseURL(env)),
|
||||||
|
JWTSecret: getRequiredEnv("JWT_SECRET"),
|
||||||
|
Port: getEnv("PORT", "8080"),
|
||||||
|
GinMode: getGinMode(env),
|
||||||
|
LogLevel: getLogLevel(env),
|
||||||
|
Debug: env == Development,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log configuration (without sensitive data)
|
||||||
|
logConfig(config)
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadEnvironmentFiles loads environment files in priority order
|
||||||
|
func loadEnvironmentFiles() {
|
||||||
|
// Get environment first (from system env or default)
|
||||||
|
env := getEnvironmentFromSystem()
|
||||||
|
|
||||||
|
log.Printf("🔍 Detected environment: %s", env)
|
||||||
|
|
||||||
|
// Define file loading order (later files override earlier ones)
|
||||||
|
files := []string{
|
||||||
|
".env", // Base configuration
|
||||||
|
fmt.Sprintf(".env.%s", env), // Environment-specific
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load files in order
|
||||||
|
for _, file := range files {
|
||||||
|
if _, err := os.Stat(file); err == nil {
|
||||||
|
if err := godotenv.Load(file); err != nil {
|
||||||
|
log.Printf("⚠️ Warning: Could not load %s: %v", file, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("✅ Loaded: %s", file)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("❌ Not found: %s", file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getEnvironmentFromSystem gets environment from system variables only
|
||||||
|
func getEnvironmentFromSystem() string {
|
||||||
|
// Check if ENVIRONMENT is already set
|
||||||
|
if env := os.Getenv("ENVIRONMENT"); env != "" {
|
||||||
|
return strings.ToLower(env)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback detection
|
||||||
|
if ginMode := os.Getenv("GIN_MODE"); ginMode == "release" {
|
||||||
|
return "production"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "development"
|
||||||
|
}
|
||||||
|
|
||||||
|
// getEnvironment gets the current environment
|
||||||
|
func getEnvironment() Environment {
|
||||||
|
env := strings.ToLower(getEnv("ENVIRONMENT", "development"))
|
||||||
|
|
||||||
|
switch env {
|
||||||
|
case "development", "dev":
|
||||||
|
return Development
|
||||||
|
case "staging", "stage":
|
||||||
|
return Staging
|
||||||
|
case "production", "prod":
|
||||||
|
return Production
|
||||||
|
default:
|
||||||
|
log.Printf("⚠️ Unknown environment '%s', defaulting to development", env)
|
||||||
|
return Development
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getGinMode returns the appropriate Gin mode for the environment
|
||||||
|
func getGinMode(env Environment) string {
|
||||||
|
switch env {
|
||||||
|
case Production, Staging:
|
||||||
|
return "release"
|
||||||
|
case Development:
|
||||||
|
return "debug"
|
||||||
|
default:
|
||||||
|
return "debug"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLogLevel returns the appropriate log level for the environment
|
||||||
|
func getLogLevel(env Environment) string {
|
||||||
|
switch env {
|
||||||
|
case Production:
|
||||||
|
return "warn"
|
||||||
|
case Staging:
|
||||||
|
return "info"
|
||||||
|
case Development:
|
||||||
|
return "debug"
|
||||||
|
default:
|
||||||
|
return "debug"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDefaultDatabaseURL returns default database URL for environment
|
||||||
|
func getDefaultDatabaseURL(env Environment) string {
|
||||||
|
switch env {
|
||||||
|
case Development:
|
||||||
|
return "postgres://postgres:password@localhost:5432/counter_db?sslmode=disable"
|
||||||
|
case Staging:
|
||||||
|
return "postgres://postgres:password@postgres:5432/counter_db?sslmode=disable"
|
||||||
|
case Production:
|
||||||
|
return "postgres://postgres:password@postgres:5432/counter_db?sslmode=require"
|
||||||
|
default:
|
||||||
|
return "postgres://postgres:password@localhost:5432/counter_db?sslmode=disable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getEnv gets environment variable with default value
|
||||||
|
func getEnv(key, defaultValue string) string {
|
||||||
|
if value := os.Getenv(key); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRequiredEnv gets required environment variable
|
||||||
|
func getRequiredEnv(key string) string {
|
||||||
|
value := os.Getenv(key)
|
||||||
|
if value == "" {
|
||||||
|
log.Fatalf("❌ Required environment variable %s is not set", key)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// logConfig logs configuration (without sensitive data)
|
||||||
|
func logConfig(config *Config) {
|
||||||
|
// Environment banner
|
||||||
|
log.Printf("")
|
||||||
|
log.Printf("╔══════════════════════════════════════════════════════════════╗")
|
||||||
|
log.Printf("║ COUNTER APPLICATION ║")
|
||||||
|
log.Printf("║ ║")
|
||||||
|
log.Printf("║ 🌍 ENVIRONMENT: %-15s ║", strings.ToUpper(string(config.Environment)))
|
||||||
|
log.Printf("║ 🚀 MODE: %-20s ║", config.GinMode)
|
||||||
|
log.Printf("║ 🔧 DEBUG: %-20s ║", fmt.Sprintf("%t", config.Debug))
|
||||||
|
log.Printf("║ 📊 LOG LEVEL: %-15s ║", config.LogLevel)
|
||||||
|
log.Printf("║ 🌐 PORT: %-20s ║", config.Port)
|
||||||
|
log.Printf("║ ║")
|
||||||
|
log.Printf("║ 📁 Configuration Files Loaded: ║")
|
||||||
|
log.Printf("║ • .env (base configuration) ║")
|
||||||
|
log.Printf("║ • .env.%s (environment-specific) ║", config.Environment)
|
||||||
|
log.Printf("║ ║")
|
||||||
|
log.Printf("║ 🔐 Security: ║")
|
||||||
|
log.Printf("║ • Database: %s ║", maskDatabaseURL(config.DatabaseURL))
|
||||||
|
log.Printf("║ • JWT Secret: %s ║", maskSecret(config.JWTSecret))
|
||||||
|
log.Printf("║ ║")
|
||||||
|
log.Printf("╚══════════════════════════════════════════════════════════════╝")
|
||||||
|
log.Printf("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// maskDatabaseURL masks sensitive parts of database URL
|
||||||
|
func maskDatabaseURL(url string) string {
|
||||||
|
// Simple masking - replace password with ***
|
||||||
|
if strings.Contains(url, "://") {
|
||||||
|
parts := strings.Split(url, "://")
|
||||||
|
if len(parts) == 2 {
|
||||||
|
// Replace password in connection string
|
||||||
|
masked := strings.Replace(parts[1], ":", ":***@", 1)
|
||||||
|
return parts[0] + "://" + masked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "***"
|
||||||
|
}
|
||||||
|
|
||||||
|
// maskSecret masks JWT secret for logging
|
||||||
|
func maskSecret(secret string) string {
|
||||||
|
if len(secret) <= 8 {
|
||||||
|
return "***"
|
||||||
|
}
|
||||||
|
return secret[:4] + "***" + secret[len(secret)-4:]
|
||||||
|
}
|
||||||
356
counters.go
Normal file
356
counters.go
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateCounterHandler creates a new counter
|
||||||
|
func CreateCounterHandler(c *gin.Context) {
|
||||||
|
var req CreateCounterRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := c.GetInt("user_id")
|
||||||
|
|
||||||
|
var counter Counter
|
||||||
|
err := db.QueryRow(
|
||||||
|
"INSERT INTO counters (user_id, name, description) VALUES ($1, $2, $3) RETURNING id, user_id, name, description, created_at, updated_at",
|
||||||
|
userID, req.Name, req.Description,
|
||||||
|
).Scan(&counter.ID, &counter.UserID, &counter.Name, &counter.Description, &counter.CreatedAt, &counter.UpdatedAt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create counter"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, counter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCountersHandler retrieves all counters for the authenticated user
|
||||||
|
func GetCountersHandler(c *gin.Context) {
|
||||||
|
userID := c.GetInt("user_id")
|
||||||
|
search := c.Query("search")
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT c.id, c.user_id, c.name, c.description, c.created_at, c.updated_at,
|
||||||
|
COALESCE(SUM(ce.value), 0) as total_value,
|
||||||
|
COALESCE(SUM(CASE WHEN ce.date = CURRENT_DATE THEN ce.value ELSE 0 END), 0) as today_value,
|
||||||
|
COALESCE(SUM(CASE WHEN ce.date >= CURRENT_DATE - INTERVAL '7 days' THEN ce.value ELSE 0 END), 0) as week_value,
|
||||||
|
COALESCE(SUM(CASE WHEN ce.date >= DATE_TRUNC('month', CURRENT_DATE) THEN ce.value ELSE 0 END), 0) as month_value,
|
||||||
|
COUNT(ce.id) as entry_count
|
||||||
|
FROM counters c
|
||||||
|
LEFT JOIN counter_entries ce ON c.id = ce.counter_id
|
||||||
|
WHERE c.user_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
args := []interface{}{userID}
|
||||||
|
if search != "" {
|
||||||
|
query += " AND (c.name ILIKE $2 OR c.description ILIKE $2)"
|
||||||
|
args = append(args, "%"+search+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " GROUP BY c.id, c.user_id, c.name, c.description, c.created_at, c.updated_at ORDER BY c.updated_at DESC"
|
||||||
|
|
||||||
|
rows, err := db.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch counters"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
counters := []CounterWithStats{} // Initialize as empty slice, not nil
|
||||||
|
for rows.Next() {
|
||||||
|
var counter CounterWithStats
|
||||||
|
err := rows.Scan(
|
||||||
|
&counter.ID, &counter.UserID, &counter.Name, &counter.Description,
|
||||||
|
&counter.CreatedAt, &counter.UpdatedAt, &counter.TotalValue,
|
||||||
|
&counter.TodayValue, &counter.WeekValue, &counter.MonthValue,
|
||||||
|
&counter.EntryCount,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan counter"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
counters = append(counters, counter)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, counters)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCounterHandler retrieves a specific counter by ID
|
||||||
|
func GetCounterHandler(c *gin.Context) {
|
||||||
|
userID := c.GetInt("user_id")
|
||||||
|
counterID, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid counter ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var counter CounterWithStats
|
||||||
|
err = db.QueryRow(`
|
||||||
|
SELECT c.id, c.user_id, c.name, c.description, c.created_at, c.updated_at,
|
||||||
|
COALESCE(SUM(ce.value), 0) as total_value,
|
||||||
|
COALESCE(SUM(CASE WHEN ce.date = CURRENT_DATE THEN ce.value ELSE 0 END), 0) as today_value,
|
||||||
|
COALESCE(SUM(CASE WHEN ce.date >= CURRENT_DATE - INTERVAL '7 days' THEN ce.value ELSE 0 END), 0) as week_value,
|
||||||
|
COALESCE(SUM(CASE WHEN ce.date >= DATE_TRUNC('month', CURRENT_DATE) THEN ce.value ELSE 0 END), 0) as month_value,
|
||||||
|
COUNT(ce.id) as entry_count
|
||||||
|
FROM counters c
|
||||||
|
LEFT JOIN counter_entries ce ON c.id = ce.counter_id
|
||||||
|
WHERE c.id = $1 AND c.user_id = $2
|
||||||
|
GROUP BY c.id, c.user_id, c.name, c.description, c.created_at, c.updated_at
|
||||||
|
`, counterID, userID).Scan(
|
||||||
|
&counter.ID, &counter.UserID, &counter.Name, &counter.Description,
|
||||||
|
&counter.CreatedAt, &counter.UpdatedAt, &counter.TotalValue,
|
||||||
|
&counter.TodayValue, &counter.WeekValue, &counter.MonthValue,
|
||||||
|
&counter.EntryCount,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Counter not found"})
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch counter"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, counter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCounterHandler updates a counter
|
||||||
|
func UpdateCounterHandler(c *gin.Context) {
|
||||||
|
userID := c.GetInt("user_id")
|
||||||
|
counterID, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid counter ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req UpdateCounterRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var counter Counter
|
||||||
|
err = db.QueryRow(
|
||||||
|
"UPDATE counters SET name = $1, description = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3 AND user_id = $4 RETURNING id, user_id, name, description, created_at, updated_at",
|
||||||
|
req.Name, req.Description, counterID, userID,
|
||||||
|
).Scan(&counter.ID, &counter.UserID, &counter.Name, &counter.Description, &counter.CreatedAt, &counter.UpdatedAt)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Counter not found"})
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update counter"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, counter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCounterHandler deletes a counter
|
||||||
|
func DeleteCounterHandler(c *gin.Context) {
|
||||||
|
userID := c.GetInt("user_id")
|
||||||
|
counterID, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid counter ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := db.Exec("DELETE FROM counters WHERE id = $1 AND user_id = $2", counterID, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete counter"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check deletion"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if rowsAffected == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Counter not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Counter deleted successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrementCounterHandler increments/decrements a counter
|
||||||
|
func IncrementCounterHandler(c *gin.Context) {
|
||||||
|
userID := c.GetInt("user_id")
|
||||||
|
counterID, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid counter ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req IncrementRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify counter belongs to user
|
||||||
|
var exists bool
|
||||||
|
err = db.QueryRow("SELECT EXISTS(SELECT 1 FROM counters WHERE id = $1 AND user_id = $2)", counterID, userID).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Counter not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert counter entry
|
||||||
|
var entry CounterEntry
|
||||||
|
err = db.QueryRow(
|
||||||
|
"INSERT INTO counter_entries (counter_id, value, date) VALUES ($1, $2, CURRENT_DATE) RETURNING id, counter_id, value, date, created_at",
|
||||||
|
counterID, req.Value,
|
||||||
|
).Scan(&entry.ID, &entry.CounterID, &entry.Value, &entry.Date, &entry.CreatedAt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create counter entry"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update counter's updated_at timestamp
|
||||||
|
_, err = db.Exec("UPDATE counters SET updated_at = CURRENT_TIMESTAMP WHERE id = $1", counterID)
|
||||||
|
if err != nil {
|
||||||
|
// Log error but don't fail the request
|
||||||
|
fmt.Printf("Warning: Failed to update counter timestamp: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCounterEntriesHandler retrieves entries for a specific counter
|
||||||
|
func GetCounterEntriesHandler(c *gin.Context) {
|
||||||
|
userID := c.GetInt("user_id")
|
||||||
|
counterID, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid counter ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify counter belongs to user
|
||||||
|
var exists bool
|
||||||
|
err = db.QueryRow("SELECT EXISTS(SELECT 1 FROM counters WHERE id = $1 AND user_id = $2)", counterID, userID).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Counter not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse date range parameters
|
||||||
|
startDate := c.Query("start_date")
|
||||||
|
endDate := c.Query("end_date")
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT id, counter_id, value, date, created_at
|
||||||
|
FROM counter_entries
|
||||||
|
WHERE counter_id = $1
|
||||||
|
`
|
||||||
|
args := []interface{}{counterID}
|
||||||
|
|
||||||
|
if startDate != "" {
|
||||||
|
query += " AND date >= $2"
|
||||||
|
args = append(args, startDate)
|
||||||
|
if endDate != "" {
|
||||||
|
query += " AND date <= $3"
|
||||||
|
args = append(args, endDate)
|
||||||
|
}
|
||||||
|
} else if endDate != "" {
|
||||||
|
query += " AND date <= $2"
|
||||||
|
args = append(args, endDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " ORDER BY date DESC, created_at DESC"
|
||||||
|
|
||||||
|
rows, err := db.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch counter entries"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var entries []CounterEntry
|
||||||
|
for rows.Next() {
|
||||||
|
var entry CounterEntry
|
||||||
|
err := rows.Scan(&entry.ID, &entry.CounterID, &entry.Value, &entry.Date, &entry.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan entry"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries = append(entries, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCounterStatsHandler retrieves statistics for a counter
|
||||||
|
func GetCounterStatsHandler(c *gin.Context) {
|
||||||
|
userID := c.GetInt("user_id")
|
||||||
|
counterID, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid counter ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify counter belongs to user
|
||||||
|
var exists bool
|
||||||
|
err = db.QueryRow("SELECT EXISTS(SELECT 1 FROM counters WHERE id = $1 AND user_id = $2)", counterID, userID).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Counter not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get daily statistics for the last 30 days
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT date, SUM(value) as daily_total
|
||||||
|
FROM counter_entries
|
||||||
|
WHERE counter_id = $1 AND date >= CURRENT_DATE - INTERVAL '30 days'
|
||||||
|
GROUP BY date
|
||||||
|
ORDER BY date DESC
|
||||||
|
`, counterID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch statistics"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type DailyStat struct {
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats []DailyStat
|
||||||
|
for rows.Next() {
|
||||||
|
var stat DailyStat
|
||||||
|
err := rows.Scan(&stat.Date, &stat.Total)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan statistic"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stats = append(stats, stat)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"daily_stats": stats})
|
||||||
|
}
|
||||||
92
database.go
Normal file
92
database.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
)
|
||||||
|
|
||||||
|
var db *sql.DB
|
||||||
|
|
||||||
|
// InitDB initializes the database connection (legacy function)
|
||||||
|
func InitDB() error {
|
||||||
|
// Get database URL from environment variable
|
||||||
|
dbURL := os.Getenv("DATABASE_URL")
|
||||||
|
if dbURL == "" {
|
||||||
|
// Default for local development
|
||||||
|
dbURL = "postgres://postgres:password@localhost:5432/counter_db?sslmode=disable"
|
||||||
|
}
|
||||||
|
return initDBWithURL(dbURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitDBWithConfig initializes the database connection with configuration
|
||||||
|
func InitDBWithConfig(config *Config) error {
|
||||||
|
return initDBWithURL(config.DatabaseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// initDBWithURL initializes the database connection with a specific URL
|
||||||
|
func initDBWithURL(dbURL string) error {
|
||||||
|
|
||||||
|
var err error
|
||||||
|
db, err = sql.Open("postgres", dbURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test the connection
|
||||||
|
if err = db.Ping(); err != nil {
|
||||||
|
return fmt.Errorf("failed to ping database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("✅ Database connection established successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTables creates the necessary database tables
|
||||||
|
func CreateTables() error {
|
||||||
|
queries := []string{
|
||||||
|
`CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
username VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
password VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS counters (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS counter_entries (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
counter_id INTEGER REFERENCES counters(id) ON DELETE CASCADE,
|
||||||
|
value INTEGER NOT NULL,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_counters_user_id ON counters(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_counter_entries_counter_id ON counter_entries(counter_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_counter_entries_date ON counter_entries(date)`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, query := range queries {
|
||||||
|
if _, err := db.Exec(query); err != nil {
|
||||||
|
return fmt.Errorf("failed to execute query: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("✅ Database tables created successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDB returns the database connection
|
||||||
|
func GetDB() *sql.DB {
|
||||||
|
return db
|
||||||
|
}
|
||||||
6
docker-compose.prod.yml
Normal file
6
docker-compose.prod.yml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
environment:
|
||||||
|
- ENVIRONMENT=production
|
||||||
|
env_file:
|
||||||
|
- .env.production
|
||||||
6
docker-compose.staging.yml
Normal file
6
docker-compose.staging.yml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
environment:
|
||||||
|
- ENVIRONMENT=staging
|
||||||
|
env_file:
|
||||||
|
- .env.staging
|
||||||
33
docker-compose.yml
Normal file
33
docker-compose.yml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: counter_db
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: password
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
- ENVIRONMENT=development
|
||||||
|
env_file:
|
||||||
|
- .env.development
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./frontend/build:/app/frontend/build
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
12
env.example
Normal file
12
env.example
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Database Configuration
|
||||||
|
DATABASE_URL=postgres://postgres:password@localhost:5432/counter_db?sslmode=disable
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
PORT=8080
|
||||||
|
GIN_MODE=release
|
||||||
|
|
||||||
|
# Frontend Configuration (for development)
|
||||||
|
REACT_APP_API_URL=http://localhost:8080/api/v1
|
||||||
4
frontend/.env
Normal file
4
frontend/.env
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Base Frontend Configuration
|
||||||
|
REACT_APP_API_URL=http://localhost:8080/api/v1
|
||||||
|
REACT_APP_ENVIRONMENT=development
|
||||||
|
REACT_APP_DEBUG=true
|
||||||
5
frontend/.env.production
Normal file
5
frontend/.env.production
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Production Frontend Configuration
|
||||||
|
REACT_APP_API_URL=/api/v1
|
||||||
|
REACT_APP_ENVIRONMENT=production
|
||||||
|
REACT_APP_DEBUG=false
|
||||||
|
REACT_APP_LOG_LEVEL=warn
|
||||||
5
frontend/_.env.development
Normal file
5
frontend/_.env.development
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Development Frontend Configuration
|
||||||
|
REACT_APP_API_URL=http://localhost:8080/api/v1
|
||||||
|
REACT_APP_ENVIRONMENT=development
|
||||||
|
REACT_APP_DEBUG=true
|
||||||
|
REACT_APP_LOG_LEVEL=debug
|
||||||
5
frontend/_.env.staging
Normal file
5
frontend/_.env.staging
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Staging Frontend Configuration
|
||||||
|
REACT_APP_API_URL=https://staging-api.yourdomain.com/api/v1
|
||||||
|
REACT_APP_ENVIRONMENT=staging
|
||||||
|
REACT_APP_DEBUG=false
|
||||||
|
REACT_APP_LOG_LEVEL=info
|
||||||
17624
frontend/package-lock.json
generated
Normal file
17624
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
frontend/package.json
Normal file
51
frontend/package.json
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "counter-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "^16.18.0",
|
||||||
|
"autoprefixer": "^10.4.0",
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"date-fns": "^2.30.0",
|
||||||
|
"lucide-react": "^0.294.0",
|
||||||
|
"postcss": "^8.4.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-hook-form": "^7.48.0",
|
||||||
|
"react-query": "^3.39.0",
|
||||||
|
"react-router-dom": "^6.8.0",
|
||||||
|
"react-scripts": "5.0.1",
|
||||||
|
"tailwindcss": "^3.3.0",
|
||||||
|
"typescript": "^4.9.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "react-scripts start",
|
||||||
|
"build": "react-scripts build",
|
||||||
|
"test": "react-scripts test",
|
||||||
|
"eject": "react-scripts eject"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": [
|
||||||
|
"react-app",
|
||||||
|
"react-app/jest"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/jest": "^27.5.0",
|
||||||
|
"@types/react": "^19.2.0",
|
||||||
|
"@types/react-dom": "^19.2.0"
|
||||||
|
},
|
||||||
|
"proxy": "http://localhost:8080"
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
18
frontend/public/index.html
Normal file
18
frontend/public/index.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Counter - Track your habits and activities"
|
||||||
|
/>
|
||||||
|
<title>Counter</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
43
frontend/src/App.css
Normal file
43
frontend/src/App.css
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html {
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.btn {
|
||||||
|
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
@apply bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
@apply bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
@apply bg-red-600 text-white hover:bg-red-700 focus:ring-red-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
@apply bg-green-600 text-white hover:bg-green-700 focus:ring-green-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
@apply bg-white rounded-lg shadow-md border border-gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.counter-card {
|
||||||
|
@apply card p-6 hover:shadow-lg transition-shadow duration-200;
|
||||||
|
}
|
||||||
|
}
|
||||||
44
frontend/src/App.tsx
Normal file
44
frontend/src/App.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { AuthProvider } from './hooks/useAuth';
|
||||||
|
import { CountersProvider } from './contexts/CountersContext';
|
||||||
|
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||||
|
import { Layout } from './components/Layout';
|
||||||
|
import { Login } from './components/Login';
|
||||||
|
import { Register } from './components/Register';
|
||||||
|
import { Dashboard } from './components/Dashboard';
|
||||||
|
import { CounterDetail } from './components/CounterDetail';
|
||||||
|
import './App.css';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<CountersProvider>
|
||||||
|
<Router>
|
||||||
|
<div className="App">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/register" element={<Register />} />
|
||||||
|
<Route
|
||||||
|
path="/*"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/counter/:id" element={<CounterDetail />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</div>
|
||||||
|
</Router>
|
||||||
|
</CountersProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
131
frontend/src/components/CounterCard.tsx
Normal file
131
frontend/src/components/CounterCard.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { CounterWithStats } from '../types';
|
||||||
|
import { Plus, Minus, Edit, Trash2, MoreVertical } from 'lucide-react';
|
||||||
|
|
||||||
|
interface CounterCardProps {
|
||||||
|
counter: CounterWithStats;
|
||||||
|
onIncrement: (id: number | string, value: number) => Promise<void>;
|
||||||
|
onDelete: (id: number | string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CounterCard: React.FC<CounterCardProps> = ({ counter, onIncrement, onDelete }) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
|
|
||||||
|
const handleIncrement = async (value: number) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await onIncrement(counter.id, value);
|
||||||
|
console.log('Counter incremented:', counter.id, value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to increment counter:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (window.confirm('Are you sure you want to delete this counter?')) {
|
||||||
|
try {
|
||||||
|
await onDelete(counter.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete counter:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="counter-card">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-1">
|
||||||
|
{counter.name}
|
||||||
|
</h3>
|
||||||
|
{counter.description && (
|
||||||
|
<p className="text-sm text-gray-600 mb-2">{counter.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowMenu(!showMenu)}
|
||||||
|
className="p-1 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<MoreVertical className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
{showMenu && (
|
||||||
|
<div className="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-10 border border-gray-200">
|
||||||
|
<Link
|
||||||
|
to={`/counter/${counter.id}`}
|
||||||
|
className="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
onClick={() => setShowMenu(false)}
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4 mr-2" />
|
||||||
|
View Details
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
handleDelete();
|
||||||
|
setShowMenu(false);
|
||||||
|
}}
|
||||||
|
className="flex items-center w-full px-4 py-2 text-sm text-red-600 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Counter Value */}
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<div className="text-4xl font-bold text-primary-600 mb-2">
|
||||||
|
{counter.total_value}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">Total</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-4 mb-6 text-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-semibold text-gray-900">{counter.today_value}</div>
|
||||||
|
<div className="text-xs text-gray-500">Today</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-semibold text-gray-900">{counter.week_value}</div>
|
||||||
|
<div className="text-xs text-gray-500">Week</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-semibold text-gray-900">{counter.month_value}</div>
|
||||||
|
<div className="text-xs text-gray-500">Month</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleIncrement(-1)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="btn btn-secondary flex-1 flex items-center justify-center disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Minus className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleIncrement(1)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="btn btn-primary flex-1 flex items-center justify-center disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Entry Count */}
|
||||||
|
<div className="mt-4 text-center">
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{counter.entry_count} entries
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
228
frontend/src/components/CounterDetail.tsx
Normal file
228
frontend/src/components/CounterDetail.tsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useCountersContext } from '../contexts/CountersContext';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
import { CounterEntry } from '../types';
|
||||||
|
import { ArrowLeft, Plus, Minus, Trash2, Calendar } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
export const CounterDetail: React.FC = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
const { counters, incrementCounter, deleteCounter, getCounterEntries } = useCountersContext();
|
||||||
|
|
||||||
|
// Get the current counter from the useCounters hook
|
||||||
|
const counter = counters.find(c => c.id.toString() === id) || null;
|
||||||
|
const [entries, setEntries] = useState<CounterEntry[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isIncrementing, setIsIncrementing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Load entries when component mounts or counter changes
|
||||||
|
useEffect(() => {
|
||||||
|
const loadEntries = async () => {
|
||||||
|
if (!id || !counter) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const counterEntries = await getCounterEntries(id);
|
||||||
|
setEntries(counterEntries);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to load counter entries');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadEntries();
|
||||||
|
}, [id, counter, getCounterEntries]);
|
||||||
|
|
||||||
|
const handleIncrement = async (value: number) => {
|
||||||
|
if (!counter) return;
|
||||||
|
|
||||||
|
setIsIncrementing(true);
|
||||||
|
try {
|
||||||
|
await incrementCounter(counter.id, value);
|
||||||
|
console.log('Counter incremented:', counter.id, value);
|
||||||
|
// Entries will be reloaded automatically when counter changes
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to increment counter:', error);
|
||||||
|
} finally {
|
||||||
|
setIsIncrementing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!counter) return;
|
||||||
|
|
||||||
|
if (window.confirm('Are you sure you want to delete this counter?')) {
|
||||||
|
try {
|
||||||
|
await deleteCounter(counter.id);
|
||||||
|
navigate('/');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete counter:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !counter) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-red-600 mb-4">{error || 'Counter not found'}</div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
Back to Dashboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{counter.name}</h1>
|
||||||
|
{counter.description && (
|
||||||
|
<p className="text-gray-600 mt-1">{counter.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium text-gray-600">Total Value</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{counter.total_value}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium text-gray-600">Today</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{counter.today_value}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium text-gray-600">This Week</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{counter.week_value}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium text-gray-600">This Month</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{counter.month_value}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Increment Controls */}
|
||||||
|
<div className="card p-6">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-4">Quick Actions</h2>
|
||||||
|
<div className="flex space-x-4">
|
||||||
|
<button
|
||||||
|
onClick={() => handleIncrement(-1)}
|
||||||
|
disabled={isIncrementing}
|
||||||
|
className="btn btn-secondary flex items-center space-x-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Minus className="h-5 w-5" />
|
||||||
|
<span>-1</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleIncrement(1)}
|
||||||
|
disabled={isIncrementing}
|
||||||
|
className="btn btn-primary flex items-center space-x-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Plus className="h-5 w-5" />
|
||||||
|
<span>+1</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleIncrement(5)}
|
||||||
|
disabled={isIncrementing}
|
||||||
|
className="btn btn-primary flex items-center space-x-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Plus className="h-5 w-5" />
|
||||||
|
<span>+5</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleIncrement(10)}
|
||||||
|
disabled={isIncrementing}
|
||||||
|
className="btn btn-primary flex items-center space-x-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Plus className="h-5 w-5" />
|
||||||
|
<span>+10</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Entries */}
|
||||||
|
<div className="card p-6">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center">
|
||||||
|
<Calendar className="h-5 w-5 mr-2" />
|
||||||
|
Recent Entries
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{(entries?.length || 0) === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
No entries yet. Start by incrementing your counter!
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{entries?.slice(0, 10).map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
className="flex justify-between items-center py-2 px-4 bg-gray-50 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
{format(new Date(entry.date), 'MMM dd, yyyy')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className={`font-medium ${entry.value >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{entry.value > 0 ? '+' : ''}{entry.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
110
frontend/src/components/CreateCounterModal.tsx
Normal file
110
frontend/src/components/CreateCounterModal.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useCountersContext } from '../contexts/CountersContext';
|
||||||
|
import { CreateCounterRequest } from '../types';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface CreateCounterModalProps {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateCounterModal: React.FC<CreateCounterModalProps> = ({ onClose }) => {
|
||||||
|
const { createCounter } = useCountersContext();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<CreateCounterRequest>();
|
||||||
|
|
||||||
|
const onSubmit = async (data: CreateCounterRequest) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await createCounter(data);
|
||||||
|
onClose();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to create counter');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Create New Counter</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<X className="h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md mb-4">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||||
|
Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register('name', {
|
||||||
|
required: 'Name is required',
|
||||||
|
maxLength: { value: 100, message: 'Name must be less than 100 characters' }
|
||||||
|
})}
|
||||||
|
type="text"
|
||||||
|
className="input mt-1"
|
||||||
|
placeholder="e.g., Cigarettes Smoked"
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.name.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="description" className="block text-sm font-medium text-gray-700">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
{...register('description', {
|
||||||
|
maxLength: { value: 500, message: 'Description must be less than 500 characters' }
|
||||||
|
})}
|
||||||
|
rows={3}
|
||||||
|
className="input mt-1"
|
||||||
|
placeholder="Optional description..."
|
||||||
|
/>
|
||||||
|
{errors.description && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.description.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Creating...' : 'Create Counter'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
156
frontend/src/components/Dashboard.tsx
Normal file
156
frontend/src/components/Dashboard.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useCountersContext } from '../contexts/CountersContext';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
import { CreateCounterModal } from './CreateCounterModal';
|
||||||
|
import { CounterCard } from './CounterCard';
|
||||||
|
import { Plus, Search, TrendingUp, Calendar, Clock } from 'lucide-react';
|
||||||
|
|
||||||
|
export const Dashboard: React.FC = () => {
|
||||||
|
console.log('Dashboard: Component rendering...');
|
||||||
|
const { counters, isLoading, error, searchCounters, version, incrementCounter, deleteCounter } = useCountersContext();
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
const handleSearch = (query: string) => {
|
||||||
|
setSearchQuery(query);
|
||||||
|
searchCounters(query);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('Dashboard: Counters changed:', counters, 'version:', version);
|
||||||
|
}, [counters, version]);
|
||||||
|
|
||||||
|
console.log('Dashboard: Current counters:', counters, 'version:', version);
|
||||||
|
const totalCounters = counters?.length || 0;
|
||||||
|
const totalValue = counters?.reduce((sum, counter) => sum + counter.total_value, 0) || 0;
|
||||||
|
const todayValue = counters?.reduce((sum, counter) => sum + counter.today_value, 0) || 0;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
|
||||||
|
<p className="text-gray-600 mt-1">
|
||||||
|
{isAuthenticated
|
||||||
|
? 'Maaaanage your counters and track your progress'
|
||||||
|
: 'Track your habits and activities (data stored locally)'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className="btn btn-primary flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<Plus className="h-5 w-5" />
|
||||||
|
<span>New Counter</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div className="card p-6">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="p-2 bg-primary-100 rounded-lg">
|
||||||
|
<TrendingUp className="h-6 w-6 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<p className="text-sm font-medium text-gray-600">Total Counters</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{totalCounters}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card p-6">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="p-2 bg-green-100 rounded-lg">
|
||||||
|
<Calendar className="h-6 w-6 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<p className="text-sm font-medium text-gray-600">Total Value</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{totalValue}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card p-6">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="p-2 bg-blue-100 rounded-lg">
|
||||||
|
<Clock className="h-6 w-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<p className="text-sm font-medium text-gray-600">Today's Value</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{todayValue}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<Search className="h-5 w-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search counters..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
|
className="input pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Counters Grid */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center items-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||||
|
</div>
|
||||||
|
) : (counters?.length || 0) === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<TrendingUp className="mx-auto h-12 w-12 text-gray-400" />
|
||||||
|
<h3 className="mt-2 text-sm font-medium text-gray-900">No counters</h3>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
Get started by creating your first counter.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
<Plus className="h-5 w-5 mr-2" />
|
||||||
|
New Counter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{counters?.map((counter) => (
|
||||||
|
<CounterCard
|
||||||
|
key={counter.id}
|
||||||
|
counter={counter}
|
||||||
|
onIncrement={incrementCounter}
|
||||||
|
onDelete={deleteCounter}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Counter Modal */}
|
||||||
|
{showCreateModal && (
|
||||||
|
<CreateCounterModal
|
||||||
|
onClose={() => setShowCreateModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
97
frontend/src/components/Layout.tsx
Normal file
97
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
import { LogOut, User, Plus, Search } from 'lucide-react';
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||||
|
const { user, isAuthenticated, logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-white shadow-sm border-b border-gray-200">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex justify-between items-center h-16">
|
||||||
|
{/* Logo */}
|
||||||
|
<Link to="/" className="flex items-center">
|
||||||
|
<div className="text-2xl font-bold text-primary-600">Counter</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex items-center space-x-4">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="text-gray-700 hover:text-primary-600 px-3 py-2 rounded-md text-sm font-medium"
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* User Menu */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||||
|
className="flex items-center space-x-2 text-gray-700 hover:text-primary-600 px-3 py-2 rounded-md text-sm font-medium"
|
||||||
|
>
|
||||||
|
<User className="h-5 w-5" />
|
||||||
|
<span>{isAuthenticated ? user?.username : 'Anonymous'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showUserMenu && (
|
||||||
|
<div className="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50 border border-gray-200">
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<>
|
||||||
|
<div className="px-4 py-2 text-sm text-gray-700 border-b border-gray-200">
|
||||||
|
{user?.email}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4 mr-2" />
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
onClick={() => setShowUserMenu(false)}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/register"
|
||||||
|
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
onClick={() => setShowUserMenu(false)}
|
||||||
|
>
|
||||||
|
Register
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
132
frontend/src/components/Login.tsx
Normal file
132
frontend/src/components/Login.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
import { LoginRequest } from '../types';
|
||||||
|
import { Eye, EyeOff } from 'lucide-react';
|
||||||
|
|
||||||
|
export const Login: React.FC = () => {
|
||||||
|
const { login, isAuthenticated } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<LoginRequest>();
|
||||||
|
|
||||||
|
// Redirect if already authenticated
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, navigate]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: LoginRequest) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await login(data);
|
||||||
|
navigate('/');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Login failed');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-md w-full space-y-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||||
|
Sign in to your account
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-center text-sm text-gray-600">
|
||||||
|
Or{' '}
|
||||||
|
<Link
|
||||||
|
to="/register"
|
||||||
|
className="font-medium text-primary-600 hover:text-primary-500"
|
||||||
|
>
|
||||||
|
create a new account
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form className="mt-8 space-y-6" onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register('username', { required: 'Username is required' })}
|
||||||
|
type="text"
|
||||||
|
className="input mt-1"
|
||||||
|
placeholder="Enter your username"
|
||||||
|
/>
|
||||||
|
{errors.username && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.username.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 relative">
|
||||||
|
<input
|
||||||
|
{...register('password', { required: 'Password is required' })}
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
className="input pr-10"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="h-5 w-5 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-5 w-5 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="btn btn-primary w-full flex justify-center py-2 px-4 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Signing in...' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="text-sm text-primary-600 hover:text-primary-500"
|
||||||
|
>
|
||||||
|
Continue as anonymous user
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
24
frontend/src/components/ProtectedRoute.tsx
Normal file
24
frontend/src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||||
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow access to authenticated users or anonymous users
|
||||||
|
// The app works for both authenticated and anonymous users
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
185
frontend/src/components/Register.tsx
Normal file
185
frontend/src/components/Register.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
import { RegisterRequest } from '../types';
|
||||||
|
import { Eye, EyeOff } from 'lucide-react';
|
||||||
|
|
||||||
|
export const Register: React.FC = () => {
|
||||||
|
const { register: registerUser, isAuthenticated } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<RegisterRequest & { confirmPassword: string }>();
|
||||||
|
|
||||||
|
const password = watch('password');
|
||||||
|
|
||||||
|
// Redirect if already authenticated
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, navigate]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: RegisterRequest & { confirmPassword: string }) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await registerUser({
|
||||||
|
username: data.username,
|
||||||
|
email: data.email,
|
||||||
|
password: data.password,
|
||||||
|
});
|
||||||
|
navigate('/');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Registration failed');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-md w-full space-y-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||||
|
Create your account
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-center text-sm text-gray-600">
|
||||||
|
Or{' '}
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="font-medium text-primary-600 hover:text-primary-500"
|
||||||
|
>
|
||||||
|
sign in to your existing account
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form className="mt-8 space-y-6" onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register('username', {
|
||||||
|
required: 'Username is required',
|
||||||
|
minLength: { value: 3, message: 'Username must be at least 3 characters' },
|
||||||
|
maxLength: { value: 50, message: 'Username must be less than 50 characters' }
|
||||||
|
})}
|
||||||
|
type="text"
|
||||||
|
className="input mt-1"
|
||||||
|
placeholder="Choose a username"
|
||||||
|
/>
|
||||||
|
{errors.username && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.username.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register('email', {
|
||||||
|
required: 'Email is required',
|
||||||
|
pattern: {
|
||||||
|
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
|
||||||
|
message: 'Invalid email address'
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
type="email"
|
||||||
|
className="input mt-1"
|
||||||
|
placeholder="Enter your email"
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 relative">
|
||||||
|
<input
|
||||||
|
{...register('password', {
|
||||||
|
required: 'Password is required',
|
||||||
|
minLength: { value: 6, message: 'Password must be at least 6 characters' }
|
||||||
|
})}
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
className="input pr-10"
|
||||||
|
placeholder="Create a password"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="h-5 w-5 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-5 w-5 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register('confirmPassword', {
|
||||||
|
required: 'Please confirm your password',
|
||||||
|
validate: value => value === password || 'Passwords do not match'
|
||||||
|
})}
|
||||||
|
type="password"
|
||||||
|
className="input mt-1"
|
||||||
|
placeholder="Confirm your password"
|
||||||
|
/>
|
||||||
|
{errors.confirmPassword && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.confirmPassword.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="btn btn-primary w-full flex justify-center py-2 px-4 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Creating account...' : 'Create account'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="text-sm text-primary-600 hover:text-primary-500"
|
||||||
|
>
|
||||||
|
Continue as anonymous user
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
102
frontend/src/config/environment.ts
Normal file
102
frontend/src/config/environment.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
// Environment configuration for React frontend
|
||||||
|
export type Environment = 'development' | 'staging' | 'production';
|
||||||
|
|
||||||
|
export interface AppConfig {
|
||||||
|
environment: Environment;
|
||||||
|
apiUrl: string;
|
||||||
|
debug: boolean;
|
||||||
|
logLevel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get environment from process.env
|
||||||
|
export const getEnvironment = (): Environment => {
|
||||||
|
const env = process.env.REACT_APP_ENVIRONMENT as Environment;
|
||||||
|
|
||||||
|
// Fallback to NODE_ENV if REACT_APP_ENVIRONMENT is not set
|
||||||
|
if (!env) {
|
||||||
|
const nodeEnv = process.env.NODE_ENV;
|
||||||
|
switch (nodeEnv) {
|
||||||
|
case 'production':
|
||||||
|
return 'production';
|
||||||
|
case 'development':
|
||||||
|
return 'development';
|
||||||
|
default:
|
||||||
|
return 'development';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return env;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get API URL based on environment
|
||||||
|
export const getApiUrl = (): string => {
|
||||||
|
const apiUrl = process.env.REACT_APP_API_URL;
|
||||||
|
|
||||||
|
if (apiUrl) {
|
||||||
|
return apiUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback based on environment
|
||||||
|
const env = getEnvironment();
|
||||||
|
switch (env) {
|
||||||
|
case 'production':
|
||||||
|
return '/api/v1'; // Relative URL for production
|
||||||
|
case 'staging':
|
||||||
|
return 'https://staging-api.yourdomain.com/api/v1';
|
||||||
|
case 'development':
|
||||||
|
default:
|
||||||
|
return 'http://localhost:8080/api/v1';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get debug flag
|
||||||
|
export const isDebugMode = (): boolean => {
|
||||||
|
const debug = process.env.REACT_APP_DEBUG;
|
||||||
|
if (debug !== undefined) {
|
||||||
|
return debug === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback based on environment
|
||||||
|
return getEnvironment() === 'development';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get log level
|
||||||
|
export const getLogLevel = (): string => {
|
||||||
|
return process.env.REACT_APP_LOG_LEVEL || 'info';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get complete app configuration
|
||||||
|
export const getAppConfig = (): AppConfig => {
|
||||||
|
return {
|
||||||
|
environment: getEnvironment(),
|
||||||
|
apiUrl: getApiUrl(),
|
||||||
|
debug: isDebugMode(),
|
||||||
|
logLevel: getLogLevel(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Environment checks
|
||||||
|
export const isDevelopment = (): boolean => {
|
||||||
|
return getEnvironment() === 'development';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isStaging = (): boolean => {
|
||||||
|
return getEnvironment() === 'staging';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isProduction = (): boolean => {
|
||||||
|
return getEnvironment() === 'production';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Log configuration (only in development)
|
||||||
|
export const logConfig = (): void => {
|
||||||
|
if (isDevelopment()) {
|
||||||
|
const config = getAppConfig();
|
||||||
|
console.log('🚀 Frontend Configuration:', {
|
||||||
|
environment: config.environment,
|
||||||
|
apiUrl: config.apiUrl,
|
||||||
|
debug: config.debug,
|
||||||
|
logLevel: config.logLevel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
41
frontend/src/contexts/CountersContext.tsx
Normal file
41
frontend/src/contexts/CountersContext.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import React, { createContext, useContext, ReactNode } from 'react';
|
||||||
|
import { CounterWithStats, CreateCounterRequest, UpdateCounterRequest, CounterEntry } from '../types';
|
||||||
|
import { useCounters } from '../hooks/useCounters';
|
||||||
|
|
||||||
|
interface CountersContextType {
|
||||||
|
counters: CounterWithStats[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
version: number;
|
||||||
|
createCounter: (data: CreateCounterRequest) => Promise<any>;
|
||||||
|
updateCounter: (id: number | string, data: UpdateCounterRequest) => Promise<any>;
|
||||||
|
deleteCounter: (id: number | string) => Promise<void>;
|
||||||
|
incrementCounter: (id: number | string, value: number) => Promise<any>;
|
||||||
|
getCounterEntries: (id: number | string) => Promise<CounterEntry[]>;
|
||||||
|
searchCounters: (search: string) => void;
|
||||||
|
refreshCounters: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CountersContext = createContext<CountersContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
interface CountersProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CountersProvider: React.FC<CountersProviderProps> = ({ children }) => {
|
||||||
|
const countersData = useCounters();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CountersContext.Provider value={countersData}>
|
||||||
|
{children}
|
||||||
|
</CountersContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCountersContext = (): CountersContextType => {
|
||||||
|
const context = useContext(CountersContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useCountersContext must be used within a CountersProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
111
frontend/src/hooks/useAuth.tsx
Normal file
111
frontend/src/hooks/useAuth.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { useState, useEffect, createContext, useContext, ReactNode } from 'react';
|
||||||
|
import { User, AuthResponse, RegisterRequest, LoginRequest } from '../types';
|
||||||
|
import { authAPI } from '../services/api';
|
||||||
|
import { localStorageService } from '../services/localStorage';
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
login: (data: LoginRequest) => Promise<void>;
|
||||||
|
register: (data: RegisterRequest) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
refreshUser: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AuthProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthProvider = ({ children }: AuthProviderProps) => {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const isAuthenticated = !!user;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const initAuth = async () => {
|
||||||
|
const token = localStorageService.getAuthToken();
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
const response = await authAPI.getCurrentUser();
|
||||||
|
setUser(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get current user:', error);
|
||||||
|
localStorageService.clearAuth();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
initAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async (data: LoginRequest) => {
|
||||||
|
try {
|
||||||
|
const response = await authAPI.login(data);
|
||||||
|
const { token, user: userData } = response.data;
|
||||||
|
|
||||||
|
localStorageService.setAuthToken(token);
|
||||||
|
localStorageService.setUser(userData);
|
||||||
|
setUser(userData);
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const register = async (data: RegisterRequest) => {
|
||||||
|
try {
|
||||||
|
const response = await authAPI.register(data);
|
||||||
|
const { token, user: userData } = response.data;
|
||||||
|
|
||||||
|
localStorageService.setAuthToken(token);
|
||||||
|
localStorageService.setUser(userData);
|
||||||
|
setUser(userData);
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorageService.clearAuth();
|
||||||
|
setUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshUser = async () => {
|
||||||
|
try {
|
||||||
|
const response = await authAPI.getCurrentUser();
|
||||||
|
setUser(response.data);
|
||||||
|
localStorageService.setUser(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to refresh user:', error);
|
||||||
|
logout();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const value: AuthContextType = {
|
||||||
|
user,
|
||||||
|
isAuthenticated,
|
||||||
|
isLoading,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
refreshUser,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
232
frontend/src/hooks/useCounters.tsx
Normal file
232
frontend/src/hooks/useCounters.tsx
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { CounterWithStats, AnonymousCounter, CreateCounterRequest, UpdateCounterRequest, CounterEntry } from '../types';
|
||||||
|
import { countersAPI } from '../services/api';
|
||||||
|
import { localStorageService } from '../services/localStorage';
|
||||||
|
import { useAuth } from './useAuth';
|
||||||
|
|
||||||
|
export const useCounters = () => {
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
const [counters, setCounters] = useState<CounterWithStats[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [version, setVersion] = useState(0); // Force re-renders
|
||||||
|
|
||||||
|
// Load counters based on authentication status
|
||||||
|
useEffect(() => {
|
||||||
|
loadCounters();
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('Counters:', counters);
|
||||||
|
}, [counters]);
|
||||||
|
|
||||||
|
const loadCounters = async (search?: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
// Load from API
|
||||||
|
const response = await countersAPI.getCounters(search);
|
||||||
|
console.log('Setting authenticated counters:', response.data);
|
||||||
|
setCounters([...response.data]); // Ensure new array reference
|
||||||
|
setVersion(prev => prev + 1); // Force re-render
|
||||||
|
} else {
|
||||||
|
// Load from localStorage and convert to CounterWithStats format
|
||||||
|
const anonymousCounters = localStorageService.getAnonymousCounters();
|
||||||
|
const convertedCounters = convertAnonymousToDisplay(anonymousCounters);
|
||||||
|
console.log('Setting anonymous counters:', convertedCounters);
|
||||||
|
setCounters([...convertedCounters]); // Ensure new array reference
|
||||||
|
setVersion(prev => prev + 1); // Force re-render
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to load counters');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const convertAnonymousToDisplay = (anonymousCounters: AnonymousCounter[]): CounterWithStats[] => {
|
||||||
|
console.log('Converting anonymous counters:', anonymousCounters);
|
||||||
|
const converted = anonymousCounters.map(counter => {
|
||||||
|
const today = localStorageService.getTodayString();
|
||||||
|
const todayValue = counter.entries[today] || 0;
|
||||||
|
|
||||||
|
const weekValue = Object.keys(counter.entries)
|
||||||
|
.filter((date) => {
|
||||||
|
const entryDate = new Date(date);
|
||||||
|
const weekAgo = new Date();
|
||||||
|
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||||
|
return entryDate >= weekAgo;
|
||||||
|
})
|
||||||
|
.reduce((sum, date) => sum + (counter.entries[date] as number), 0);
|
||||||
|
|
||||||
|
const monthValue = Object.keys(counter.entries)
|
||||||
|
.filter((date) => {
|
||||||
|
const entryDate = new Date(date);
|
||||||
|
const monthAgo = new Date();
|
||||||
|
monthAgo.setMonth(monthAgo.getMonth() - 1);
|
||||||
|
return entryDate >= monthAgo;
|
||||||
|
})
|
||||||
|
.reduce((sum, date) => sum + (counter.entries[date] as number), 0);
|
||||||
|
|
||||||
|
const entryCount = Object.keys(counter.entries).reduce((sum: number, date: string) => sum + Math.abs(counter.entries[date] as number), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: counter.id,
|
||||||
|
name: counter.name,
|
||||||
|
description: counter.description,
|
||||||
|
created_at: counter.created_at,
|
||||||
|
updated_at: counter.updated_at,
|
||||||
|
total_value: counter.total_value,
|
||||||
|
today_value: todayValue,
|
||||||
|
week_value: weekValue,
|
||||||
|
month_value: monthValue,
|
||||||
|
entry_count: entryCount,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Converted counters:', converted);
|
||||||
|
return converted;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createCounter = async (data: CreateCounterRequest) => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
try {
|
||||||
|
const response = await countersAPI.createCounter(data);
|
||||||
|
await loadCounters(); // Refresh the list
|
||||||
|
return response.data;
|
||||||
|
} catch (err: any) {
|
||||||
|
throw new Error(err.response?.data?.error || 'Failed to create counter');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create anonymous counter
|
||||||
|
const newCounter: AnonymousCounter = {
|
||||||
|
id: localStorageService.generateId(),
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
total_value: 0,
|
||||||
|
entries: {},
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorageService.addAnonymousCounter(newCounter);
|
||||||
|
await loadCounters(); // Refresh the list
|
||||||
|
return newCounter;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCounter = async (id: number | string, data: UpdateCounterRequest) => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
try {
|
||||||
|
const response = await countersAPI.updateCounter(id as number, data);
|
||||||
|
await loadCounters(); // Refresh the list
|
||||||
|
return response.data;
|
||||||
|
} catch (err: any) {
|
||||||
|
throw new Error(err.response?.data?.error || 'Failed to update counter');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Update anonymous counter
|
||||||
|
const updated = { ...data, updated_at: new Date().toISOString() };
|
||||||
|
localStorageService.updateAnonymousCounter(id as string, updated);
|
||||||
|
await loadCounters(); // Refresh the list
|
||||||
|
return { id, ...updated };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteCounter = async (id: number | string) => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
try {
|
||||||
|
await countersAPI.deleteCounter(id as number);
|
||||||
|
await loadCounters(); // Refresh the list
|
||||||
|
} catch (err: any) {
|
||||||
|
throw new Error(err.response?.data?.error || 'Failed to delete counter');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Delete anonymous counter
|
||||||
|
localStorageService.deleteAnonymousCounter(id as string);
|
||||||
|
await loadCounters(); // Refresh the list
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const incrementCounter = async (id: number | string, value: number) => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
try {
|
||||||
|
const response = await countersAPI.incrementCounter(id as number, { value });
|
||||||
|
await loadCounters(); // Refresh the list
|
||||||
|
return response.data;
|
||||||
|
} catch (err: any) {
|
||||||
|
throw new Error(err.response?.data?.error || 'Failed to increment counter');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Increment anonymous counter
|
||||||
|
const today = localStorageService.getTodayString();
|
||||||
|
const counters = localStorageService.getAnonymousCounters();
|
||||||
|
const counter = counters.find((c: AnonymousCounter) => c.id === id);
|
||||||
|
|
||||||
|
if (!counter) {
|
||||||
|
throw new Error('Counter not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTotal = counter.total_value + value;
|
||||||
|
const newEntries = { ...counter.entries };
|
||||||
|
newEntries[today] = (newEntries[today] || 0) + value;
|
||||||
|
|
||||||
|
const updated = {
|
||||||
|
total_value: newTotal,
|
||||||
|
entries: newEntries,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorageService.updateAnonymousCounter(id as string, updated);
|
||||||
|
console.log('Increment completed, reloading counters...');
|
||||||
|
await loadCounters(); // Refresh the list
|
||||||
|
return { id, ...updated };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCounterEntries = async (id: number | string): Promise<CounterEntry[]> => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
try {
|
||||||
|
const response = await countersAPI.getCounterEntries(id as number);
|
||||||
|
return response.data;
|
||||||
|
} catch (err: any) {
|
||||||
|
throw new Error(err.response?.data?.error || 'Failed to load counter entries');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Get entries from localStorage
|
||||||
|
const counters = localStorageService.getAnonymousCounters();
|
||||||
|
const counter = counters.find((c: AnonymousCounter) => c.id === id);
|
||||||
|
|
||||||
|
if (!counter) {
|
||||||
|
throw new Error('Counter not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(counter.entries || {}).map(([date, value], index) => ({
|
||||||
|
id: index + 1,
|
||||||
|
counter_id: parseInt(counter.id as string),
|
||||||
|
value: value as number,
|
||||||
|
date: date,
|
||||||
|
created_at: counter.updated_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchCounters = (search: string) => {
|
||||||
|
loadCounters(search);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
counters,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
version, // Add version to force re-renders
|
||||||
|
createCounter,
|
||||||
|
updateCounter,
|
||||||
|
deleteCounter,
|
||||||
|
incrementCounter,
|
||||||
|
getCounterEntries,
|
||||||
|
searchCounters,
|
||||||
|
refreshCounters: loadCounters,
|
||||||
|
};
|
||||||
|
};
|
||||||
20
frontend/src/index.css
Normal file
20
frontend/src/index.css
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
13
frontend/src/index.tsx
Normal file
13
frontend/src/index.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import './index.css';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(
|
||||||
|
document.getElementById('root') as HTMLElement
|
||||||
|
);
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
96
frontend/src/services/api.ts
Normal file
96
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import axios, { AxiosResponse } from 'axios';
|
||||||
|
import {
|
||||||
|
AuthResponse,
|
||||||
|
RegisterRequest,
|
||||||
|
LoginRequest,
|
||||||
|
Counter,
|
||||||
|
CounterWithStats,
|
||||||
|
CounterEntry,
|
||||||
|
CreateCounterRequest,
|
||||||
|
UpdateCounterRequest,
|
||||||
|
IncrementRequest,
|
||||||
|
CounterStats,
|
||||||
|
User,
|
||||||
|
} from '../types';
|
||||||
|
import { getApiUrl, logConfig } from '../config/environment';
|
||||||
|
|
||||||
|
// Initialize configuration
|
||||||
|
logConfig();
|
||||||
|
const API_BASE_URL = getApiUrl();
|
||||||
|
|
||||||
|
// Create axios instance
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add auth token to requests
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle auth errors
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auth API
|
||||||
|
export const authAPI = {
|
||||||
|
register: (data: RegisterRequest): Promise<AxiosResponse<AuthResponse>> =>
|
||||||
|
api.post('/auth/register', data),
|
||||||
|
|
||||||
|
login: (data: LoginRequest): Promise<AxiosResponse<AuthResponse>> =>
|
||||||
|
api.post('/auth/login', data),
|
||||||
|
|
||||||
|
getCurrentUser: (): Promise<AxiosResponse<User>> =>
|
||||||
|
api.get('/auth/me'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Counters API
|
||||||
|
export const countersAPI = {
|
||||||
|
getCounters: (search?: string): Promise<AxiosResponse<CounterWithStats[]>> => {
|
||||||
|
const params = search ? { search } : {};
|
||||||
|
return api.get('/counters', { params });
|
||||||
|
},
|
||||||
|
|
||||||
|
getCounter: (id: number): Promise<AxiosResponse<CounterWithStats>> =>
|
||||||
|
api.get(`/counters/${id}`),
|
||||||
|
|
||||||
|
createCounter: (data: CreateCounterRequest): Promise<AxiosResponse<Counter>> =>
|
||||||
|
api.post('/counters', data),
|
||||||
|
|
||||||
|
updateCounter: (id: number, data: UpdateCounterRequest): Promise<AxiosResponse<Counter>> =>
|
||||||
|
api.put(`/counters/${id}`, data),
|
||||||
|
|
||||||
|
deleteCounter: (id: number): Promise<AxiosResponse<{ message: string }>> =>
|
||||||
|
api.delete(`/counters/${id}`),
|
||||||
|
|
||||||
|
incrementCounter: (id: number, data: IncrementRequest): Promise<AxiosResponse<CounterEntry>> =>
|
||||||
|
api.post(`/counters/${id}/increment`, data),
|
||||||
|
|
||||||
|
getCounterEntries: (id: number, startDate?: string, endDate?: string): Promise<AxiosResponse<CounterEntry[]>> => {
|
||||||
|
const params: any = {};
|
||||||
|
if (startDate) params.start_date = startDate;
|
||||||
|
if (endDate) params.end_date = endDate;
|
||||||
|
return api.get(`/counters/${id}/entries`, { params });
|
||||||
|
},
|
||||||
|
|
||||||
|
getCounterStats: (id: number): Promise<AxiosResponse<CounterStats>> =>
|
||||||
|
api.get(`/counters/${id}/stats`),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default api;
|
||||||
90
frontend/src/services/localStorage.ts
Normal file
90
frontend/src/services/localStorage.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { AnonymousCounter } from '../types';
|
||||||
|
|
||||||
|
const ANONYMOUS_COUNTERS_KEY = 'anonymous_counters';
|
||||||
|
|
||||||
|
export const localStorageService = {
|
||||||
|
// Anonymous counters
|
||||||
|
getAnonymousCounters: (): AnonymousCounter[] => {
|
||||||
|
try {
|
||||||
|
const data = localStorage.getItem(ANONYMOUS_COUNTERS_KEY);
|
||||||
|
return data ? JSON.parse(data) : [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading anonymous counters:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
saveAnonymousCounters: (counters: AnonymousCounter[]): void => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(ANONYMOUS_COUNTERS_KEY, JSON.stringify(counters));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving anonymous counters:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addAnonymousCounter: (counter: AnonymousCounter): void => {
|
||||||
|
const counters = localStorageService.getAnonymousCounters();
|
||||||
|
counters.push(counter);
|
||||||
|
localStorageService.saveAnonymousCounters(counters);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateAnonymousCounter: (id: string, updates: Partial<AnonymousCounter>): void => {
|
||||||
|
const counters = localStorageService.getAnonymousCounters();
|
||||||
|
const index = counters.findIndex(c => c.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
counters[index] = { ...counters[index], ...updates };
|
||||||
|
localStorageService.saveAnonymousCounters(counters);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteAnonymousCounter: (id: string): void => {
|
||||||
|
const counters = localStorageService.getAnonymousCounters();
|
||||||
|
const filtered = counters.filter(c => c.id !== id);
|
||||||
|
localStorageService.saveAnonymousCounters(filtered);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
setAuthToken: (token: string): void => {
|
||||||
|
localStorage.setItem('token', token);
|
||||||
|
},
|
||||||
|
|
||||||
|
getAuthToken: (): string | null => {
|
||||||
|
return localStorage.getItem('token');
|
||||||
|
},
|
||||||
|
|
||||||
|
removeAuthToken: (): void => {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
},
|
||||||
|
|
||||||
|
setUser: (user: any): void => {
|
||||||
|
localStorage.setItem('user', JSON.stringify(user));
|
||||||
|
},
|
||||||
|
|
||||||
|
getUser: (): any => {
|
||||||
|
try {
|
||||||
|
const data = localStorage.getItem('user');
|
||||||
|
return data ? JSON.parse(data) : null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading user:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeUser: (): void => {
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
},
|
||||||
|
|
||||||
|
clearAuth: (): void => {
|
||||||
|
localStorageService.removeAuthToken();
|
||||||
|
localStorageService.removeUser();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Utility
|
||||||
|
generateId: (): string => {
|
||||||
|
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||||
|
},
|
||||||
|
|
||||||
|
getTodayString: (): string => {
|
||||||
|
return new Date().toISOString().split('T')[0];
|
||||||
|
},
|
||||||
|
};
|
||||||
86
frontend/src/types/index.ts
Normal file
86
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Counter {
|
||||||
|
id: number;
|
||||||
|
user_id?: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CounterEntry {
|
||||||
|
id: number;
|
||||||
|
counter_id: number;
|
||||||
|
value: number;
|
||||||
|
date: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CounterWithStats {
|
||||||
|
id: number | string; // Allow both number (authenticated) and string (anonymous) IDs
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
total_value: number;
|
||||||
|
today_value: number;
|
||||||
|
week_value: number;
|
||||||
|
month_value: number;
|
||||||
|
entry_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnonymousCounter {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
total_value: number;
|
||||||
|
entries: Record<string, number>; // date -> count
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
token: string;
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterRequest {
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCounterRequest {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCounterRequest {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IncrementRequest {
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyStat {
|
||||||
|
date: string;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CounterStats {
|
||||||
|
daily_stats: DailyStat[];
|
||||||
|
}
|
||||||
25
frontend/tailwind.config.js
Normal file
25
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
"./src/**/*.{js,jsx,ts,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
50: '#eff6ff',
|
||||||
|
100: '#dbeafe',
|
||||||
|
200: '#bfdbfe',
|
||||||
|
300: '#93c5fd',
|
||||||
|
400: '#60a5fa',
|
||||||
|
500: '#3b82f6',
|
||||||
|
600: '#2563eb',
|
||||||
|
700: '#1d4ed8',
|
||||||
|
800: '#1e40af',
|
||||||
|
900: '#1e3a8a',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
26
frontend/tsconfig.json
Normal file
26
frontend/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"es6"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
}
|
||||||
12
go.mod
12
go.mod
@@ -2,6 +2,15 @@ module counter
|
|||||||
|
|
||||||
go 1.21
|
go 1.21
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-contrib/cors v1.7.0
|
||||||
|
github.com/gin-gonic/gin v1.10.1
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/lib/pq v1.10.9
|
||||||
|
golang.org/x/crypto v0.23.0
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bytedance/sonic v1.11.6 // indirect
|
github.com/bytedance/sonic v1.11.6 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||||
@@ -9,13 +18,13 @@ require (
|
|||||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
github.com/gin-gonic/gin v1.10.1 // indirect
|
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||||
|
github.com/kr/text v0.2.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
@@ -24,7 +33,6 @@ require (
|
|||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
golang.org/x/arch v0.8.0 // 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/net v0.25.0 // indirect
|
||||||
golang.org/x/sys v0.20.0 // indirect
|
golang.org/x/sys v0.20.0 // indirect
|
||||||
golang.org/x/text v0.15.0 // indirect
|
golang.org/x/text v0.15.0 // indirect
|
||||||
|
|||||||
26
go.sum
26
go.sum
@@ -6,14 +6,20 @@ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/
|
|||||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
github.com/cloudwego/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 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.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/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 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||||
|
github.com/gin-contrib/cors v1.7.0 h1:wZX2wuZ0o7rV2/1i7gb4Jn+gW7HBqaP91fizJkBUJOA=
|
||||||
|
github.com/gin-contrib/cors v1.7.0/go.mod h1:cI+h6iOAyxKRtUtC6iF/Si1KSFvGm/gK+kshxlCi8ro=
|
||||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
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-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
github.com/gin-gonic/gin v1.10.1/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 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
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 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
@@ -22,15 +28,27 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
|
|||||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
github.com/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 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
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.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 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
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/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||||
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
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/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-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -40,7 +58,10 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
|||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
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 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||||
|
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
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.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.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
@@ -51,6 +72,7 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
|||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
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/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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
@@ -69,9 +91,13 @@ 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/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 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
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 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
100
main.go
100
main.go
@@ -2,24 +2,92 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
|
||||||
|
"github.com/gin-contrib/cors"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func helloHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/plain")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte("Hello world"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
http.HandleFunc("/", helloHandler)
|
// Load configuration with environment file precedence
|
||||||
|
config := LoadConfig()
|
||||||
|
|
||||||
port := ":8080"
|
// Set Gin mode based on configuration
|
||||||
log.Printf("Server starting on port %s", port)
|
gin.SetMode(config.GinMode)
|
||||||
log.Fatal(http.ListenAndServe(port, nil))
|
|
||||||
|
// Initialize JWT with configuration
|
||||||
|
InitJWTWithConfig(config)
|
||||||
|
|
||||||
|
// Initialize database with configuration
|
||||||
|
if err := InitDBWithConfig(config); err != nil {
|
||||||
|
log.Fatal("Failed to initialize database :", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create tables
|
||||||
|
if err := CreateTables(); err != nil {
|
||||||
|
log.Fatal("Failed to create tables:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Gin router
|
||||||
|
r := gin.Default()
|
||||||
|
|
||||||
|
// Configure CORS
|
||||||
|
corsConfig := cors.DefaultConfig()
|
||||||
|
corsConfig.AllowOrigins = []string{"http://localhost:3000", "http://localhost:5173"} // React dev servers
|
||||||
|
corsConfig.AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}
|
||||||
|
corsConfig.AllowHeaders = []string{"Origin", "Content-Type", "Accept", "Authorization"}
|
||||||
|
corsConfig.AllowCredentials = true
|
||||||
|
r.Use(cors.New(corsConfig))
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
r.GET("/health", func(c *gin.Context) {
|
||||||
|
c.JSON(200, gin.H{"status": "ok"})
|
||||||
|
})
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
api := r.Group("/api/v1")
|
||||||
|
{
|
||||||
|
// Authentication routes
|
||||||
|
auth := api.Group("/auth")
|
||||||
|
{
|
||||||
|
auth.POST("/register", RegisterHandler)
|
||||||
|
auth.POST("/login", LoginHandler)
|
||||||
|
auth.GET("/me", AuthMiddleware(), GetCurrentUserHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counter routes (protected)
|
||||||
|
counters := api.Group("/counters")
|
||||||
|
counters.Use(AuthMiddleware())
|
||||||
|
{
|
||||||
|
counters.POST("", CreateCounterHandler)
|
||||||
|
counters.GET("", GetCountersHandler)
|
||||||
|
counters.GET("/:id", GetCounterHandler)
|
||||||
|
counters.PUT("/:id", UpdateCounterHandler)
|
||||||
|
counters.DELETE("/:id", DeleteCounterHandler)
|
||||||
|
counters.POST("/:id/increment", IncrementCounterHandler)
|
||||||
|
counters.GET("/:id/entries", GetCounterEntriesHandler)
|
||||||
|
counters.GET("/:id/stats", GetCounterStatsHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve static files (React app)
|
||||||
|
r.Static("/static", "./frontend/build/static")
|
||||||
|
r.StaticFile("/", "./frontend/build/index.html")
|
||||||
|
r.NoRoute(func(c *gin.Context) {
|
||||||
|
c.File("./frontend/build/index.html")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
port := config.Port
|
||||||
|
|
||||||
|
log.Printf("")
|
||||||
|
log.Printf("🚀 Starting Counter Application Server...")
|
||||||
|
log.Printf(" 🌐 Listening on: http://localhost:%s", port)
|
||||||
|
log.Printf(" 📊 Health check: http://localhost:%s/health", port)
|
||||||
|
log.Printf(" 🔗 API endpoint: http://localhost:%s/api/v1", port)
|
||||||
|
log.Printf(" 🎨 Frontend: http://localhost:%s/", port)
|
||||||
|
log.Printf("")
|
||||||
|
log.Printf("✅ Server is ready and accepting connections!")
|
||||||
|
log.Printf("")
|
||||||
|
|
||||||
|
log.Fatal(r.Run(":" + port))
|
||||||
}
|
}
|
||||||
|
|||||||
92
models.go
Normal file
92
models.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User represents a registered user
|
||||||
|
type User struct {
|
||||||
|
ID int `json:"id" db:"id"`
|
||||||
|
Username string `json:"username" db:"username"`
|
||||||
|
Email string `json:"email" db:"email"`
|
||||||
|
Password string `json:"-" db:"password"` // Hidden from JSON
|
||||||
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counter represents a counter entity
|
||||||
|
type Counter struct {
|
||||||
|
ID int `json:"id" db:"id"`
|
||||||
|
UserID *int `json:"user_id,omitempty" db:"user_id"` // nil for anonymous users
|
||||||
|
Name string `json:"name" db:"name"`
|
||||||
|
Description string `json:"description" db:"description"`
|
||||||
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CounterEntry represents a single increment/decrement entry
|
||||||
|
type CounterEntry struct {
|
||||||
|
ID int `json:"id" db:"id"`
|
||||||
|
CounterID int `json:"counter_id" db:"counter_id"`
|
||||||
|
Value int `json:"value" db:"value"` // +1 for increment, -1 for decrement
|
||||||
|
Date time.Time `json:"date" db:"date"`
|
||||||
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CounterWithStats represents a counter with aggregated statistics
|
||||||
|
type CounterWithStats struct {
|
||||||
|
Counter
|
||||||
|
TotalValue int `json:"total_value"`
|
||||||
|
TodayValue int `json:"today_value"`
|
||||||
|
WeekValue int `json:"week_value"`
|
||||||
|
MonthValue int `json:"month_value"`
|
||||||
|
EntryCount int `json:"entry_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnonymousCounter represents a counter for anonymous users (stored in localStorage)
|
||||||
|
type AnonymousCounter struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
TotalValue int `json:"total_value"`
|
||||||
|
Entries map[string]int `json:"entries"` // date -> count
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request/Response DTOs
|
||||||
|
|
||||||
|
type RegisterRequest struct {
|
||||||
|
Username string `json:"username" binding:"required,min=3,max=50"`
|
||||||
|
Email string `json:"email" binding:"required,email"`
|
||||||
|
Password string `json:"password" binding:"required,min=6"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginRequest struct {
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
User User `json:"user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateCounterRequest struct {
|
||||||
|
Name string `json:"name" binding:"required,min=1,max=100"`
|
||||||
|
Description string `json:"description" max:"500"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateCounterRequest struct {
|
||||||
|
Name string `json:"name" binding:"required,min=1,max=100"`
|
||||||
|
Description string `json:"description" max:"500"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type IncrementRequest struct {
|
||||||
|
Value int `json:"value" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CounterStatsRequest struct {
|
||||||
|
StartDate *time.Time `json:"start_date"`
|
||||||
|
EndDate *time.Time `json:"end_date"`
|
||||||
|
}
|
||||||
10
scripts/dev.sh
Executable file
10
scripts/dev.sh
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Development environment script
|
||||||
|
|
||||||
|
echo "🚀 Starting Counter app in development mode..."
|
||||||
|
|
||||||
|
# Set environment
|
||||||
|
export ENVIRONMENT=development
|
||||||
|
|
||||||
|
# Start with development configuration
|
||||||
|
docker-compose -f docker-compose.yml up --build
|
||||||
10
scripts/prod.sh
Executable file
10
scripts/prod.sh
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Production environment script
|
||||||
|
|
||||||
|
echo "🚀 Starting Counter app in production mode..."
|
||||||
|
|
||||||
|
# Set environment
|
||||||
|
export ENVIRONMENT=production
|
||||||
|
|
||||||
|
# Start with production configuration
|
||||||
|
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up --build -d
|
||||||
10
scripts/staging.sh
Executable file
10
scripts/staging.sh
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Staging environment script
|
||||||
|
|
||||||
|
echo "🚀 Starting Counter app in staging mode..."
|
||||||
|
|
||||||
|
# Set environment
|
||||||
|
export ENVIRONMENT=staging
|
||||||
|
|
||||||
|
# Start with staging configuration
|
||||||
|
docker-compose -f docker-compose.yml -f docker-compose.staging.yml up --build -d
|
||||||
Reference in New Issue
Block a user