A very performant and light (2mb in memory) link shortener and tracker. Written in Rust and React and uses Postgres/SQLite.

almost production ready

+2
.gitignore
··· 2 2 **/node_modules 3 3 node_modules 4 4 .env 5 + .env.* 6 + /static
+2 -1
.preludeignore
··· 1 1 .sqlx 2 - .env 2 + .env 3 + .env.*
+28
.sqlx/query-8452fbf45386d160bc99ac6c0917a00bf5dad445ef7d484936ce6e0cbe21c965.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT \n query_source as \"source!\",\n COUNT(*)::bigint as \"count!\"\n FROM clicks\n WHERE link_id = $1\n AND query_source IS NOT NULL\n AND query_source != ''\n GROUP BY query_source\n ORDER BY COUNT(*) DESC\n LIMIT 10\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "source!", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "count!", 14 + "type_info": "Int8" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Int4" 20 + ] 21 + }, 22 + "nullable": [ 23 + true, 24 + null 25 + ] 26 + }, 27 + "hash": "8452fbf45386d160bc99ac6c0917a00bf5dad445ef7d484936ce6e0cbe21c965" 28 + }
+28
.sqlx/query-c723ec75f9ca9482e1bc86108c20bf379e5728f378626198a0a9ed024a413273.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT \n DATE(created_at)::date as \"date!\",\n COUNT(*)::bigint as \"clicks!\"\n FROM clicks\n WHERE link_id = $1\n GROUP BY DATE(created_at)\n ORDER BY DATE(created_at) ASC -- Changed from DESC to ASC\n LIMIT 30\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "date!", 9 + "type_info": "Date" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "clicks!", 14 + "type_info": "Int8" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Int4" 20 + ] 21 + }, 22 + "nullable": [ 23 + null, 24 + null 25 + ] 26 + }, 27 + "hash": "c723ec75f9ca9482e1bc86108c20bf379e5728f378626198a0a9ed024a413273" 28 + }
+52
Cargo.lock
··· 35 35 ] 36 36 37 37 [[package]] 38 + name = "actix-files" 39 + version = "0.6.6" 40 + source = "registry+https://github.com/rust-lang/crates.io-index" 41 + checksum = "0773d59061dedb49a8aed04c67291b9d8cf2fe0b60130a381aab53c6dd86e9be" 42 + dependencies = [ 43 + "actix-http", 44 + "actix-service", 45 + "actix-utils", 46 + "actix-web", 47 + "bitflags", 48 + "bytes", 49 + "derive_more", 50 + "futures-core", 51 + "http-range", 52 + "log", 53 + "mime", 54 + "mime_guess", 55 + "percent-encoding", 56 + "pin-project-lite", 57 + "v_htmlescape", 58 + ] 59 + 60 + [[package]] 38 61 name = "actix-http" 39 62 version = "3.9.0" 40 63 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1034 1057 ] 1035 1058 1036 1059 [[package]] 1060 + name = "http-range" 1061 + version = "0.1.5" 1062 + source = "registry+https://github.com/rust-lang/crates.io-index" 1063 + checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" 1064 + 1065 + [[package]] 1037 1066 name = "httparse" 1038 1067 version = "1.9.5" 1039 1068 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1373 1402 version = "0.3.17" 1374 1403 source = "registry+https://github.com/rust-lang/crates.io-index" 1375 1404 checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 1405 + 1406 + [[package]] 1407 + name = "mime_guess" 1408 + version = "2.0.5" 1409 + source = "registry+https://github.com/rust-lang/crates.io-index" 1410 + checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" 1411 + dependencies = [ 1412 + "mime", 1413 + "unicase", 1414 + ] 1376 1415 1377 1416 [[package]] 1378 1417 name = "minimal-lexical" ··· 2019 2058 version = "0.1.0" 2020 2059 dependencies = [ 2021 2060 "actix-cors", 2061 + "actix-files", 2022 2062 "actix-web", 2023 2063 "anyhow", 2024 2064 "argon2", ··· 2588 2628 checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" 2589 2629 2590 2630 [[package]] 2631 + name = "unicase" 2632 + version = "2.8.1" 2633 + source = "registry+https://github.com/rust-lang/crates.io-index" 2634 + checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" 2635 + 2636 + [[package]] 2591 2637 name = "unicode-bidi" 2592 2638 version = "0.3.18" 2593 2639 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2664 2710 "getrandom", 2665 2711 "serde", 2666 2712 ] 2713 + 2714 + [[package]] 2715 + name = "v_htmlescape" 2716 + version = "0.15.8" 2717 + source = "registry+https://github.com/rust-lang/crates.io-index" 2718 + checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c" 2667 2719 2668 2720 [[package]] 2669 2721 name = "valuable"
+1
Cargo.toml
··· 10 10 [dependencies] 11 11 jsonwebtoken = "9" 12 12 actix-web = "4.4" 13 + actix-files = "0.6" 13 14 actix-cors = "0.6" 14 15 tokio = { version = "1.36", features = ["full"] } 15 16 sqlx = { version = "0.8", features = ["runtime-tokio-native-tls", "postgres", "uuid", "chrono"] }
+258
DEPLOYMENT.md
··· 1 + # SimpleLink Deployment Guide 2 + 3 + ## Environment Configuration 4 + 5 + ### Environment Files 6 + 7 + #### Development (.env.development) 8 + ```env 9 + VITE_API_URL=http://localhost:3000 10 + NODE_ENV=development 11 + RUST_ENV=debug 12 + JWT_SECRET=dev-secret-key 13 + POSTGRES_DB=shortener_dev 14 + POSTGRES_USER=shortener 15 + POSTGRES_PASSWORD=shortener123 16 + ``` 17 + 18 + #### Production (.env.production) 19 + ```env 20 + VITE_API_URL=https://your-production-domain.com 21 + NODE_ENV=production 22 + RUST_ENV=release 23 + JWT_SECRET=your-secure-production-key 24 + POSTGRES_DB=shortener_prod 25 + POSTGRES_USER=shortener_prod 26 + POSTGRES_PASSWORD=secure-password 27 + ``` 28 + 29 + #### Staging (.env.staging) 30 + ```env 31 + VITE_API_URL=https://staging.your-domain.com 32 + NODE_ENV=production 33 + RUST_ENV=release 34 + JWT_SECRET=your-staging-key 35 + POSTGRES_DB=shortener_staging 36 + POSTGRES_USER=shortener_staging 37 + POSTGRES_PASSWORD=staging-password 38 + ``` 39 + 40 + ## Docker Deployment 41 + 42 + ### Basic Commands 43 + 44 + ```bash 45 + # Build and run with specific environment 46 + docker-compose --env-file .env.development up --build # Development 47 + docker-compose --env-file .env.staging up --build # Staging 48 + docker-compose --env-file .env.production up --build # Production 49 + 50 + # Run in detached mode 51 + docker-compose --env-file .env.production up -d --build 52 + 53 + # Stop containers 54 + docker-compose down 55 + 56 + # View logs 57 + docker-compose logs -f 58 + ``` 59 + 60 + ### Override Single Variables 61 + ```bash 62 + VITE_API_URL=https://custom-domain.com docker-compose up --build 63 + ``` 64 + 65 + ### Using Docker Compose Override Files 66 + 67 + #### Development (docker-compose.dev.yml) 68 + ```yaml 69 + services: 70 + app: 71 + build: 72 + args: 73 + VITE_API_URL: http://localhost:3000 74 + NODE_ENV: development 75 + RUST_ENV: debug 76 + volumes: 77 + - ./src:/usr/src/app/src 78 + environment: 79 + RUST_LOG: debug 80 + ``` 81 + 82 + #### Production (docker-compose.prod.yml) 83 + ```yaml 84 + services: 85 + app: 86 + build: 87 + args: 88 + VITE_API_URL: https://your-production-domain.com 89 + NODE_ENV: production 90 + RUST_ENV: release 91 + deploy: 92 + replicas: 2 93 + logging: 94 + driver: "json-file" 95 + options: 96 + max-size: "10m" 97 + max-file: "3" 98 + ``` 99 + 100 + ### Using Override Files 101 + ```bash 102 + # Development 103 + docker-compose -f docker-compose.yml -f docker-compose.dev.yml up --build 104 + 105 + # Production 106 + docker-compose -f docker-compose.yml -f docker-compose.prod.yml up --build 107 + ``` 108 + 109 + ## Environment Variables Reference 110 + 111 + ### Build-time Variables 112 + - `VITE_API_URL`: Frontend API endpoint 113 + - `NODE_ENV`: Node.js environment (development/production) 114 + - `RUST_ENV`: Rust build type (debug/release) 115 + 116 + ### Runtime Variables 117 + - `SERVER_HOST`: Backend host address 118 + - `SERVER_PORT`: Backend port 119 + - `JWT_SECRET`: JWT signing key 120 + - `RUST_LOG`: Logging level 121 + - `DATABASE_URL`: PostgreSQL connection string 122 + 123 + ### Database Variables 124 + - `POSTGRES_DB`: Database name 125 + - `POSTGRES_USER`: Database user 126 + - `POSTGRES_PASSWORD`: Database password 127 + 128 + ## Container Structure 129 + 130 + ### Frontend Container 131 + - Build tool: Bun 132 + - Source: `/app/frontend` 133 + - Output: `/app/frontend/dist` 134 + - Static files location: `/app/static` 135 + 136 + ### Backend Container 137 + - Build tool: Cargo 138 + - Source: `/usr/src/app` 139 + - Binary: `/app/simplelink` 140 + - Migrations: `/app/migrations` 141 + 142 + ## Production Deployment Checklist 143 + 144 + 1. Environment Setup 145 + - [ ] Set secure database passwords 146 + - [ ] Generate strong JWT secret 147 + - [ ] Configure proper API URL 148 + - [ ] Set appropriate logging levels 149 + 150 + 2. Database 151 + - [ ] Configure backup strategy 152 + - [ ] Set up proper indexes 153 + - [ ] Configure connection pooling 154 + 155 + 3. Security 156 + - [ ] Enable SSL/TLS 157 + - [ ] Set up proper firewalls 158 + - [ ] Configure CORS properly 159 + - [ ] Use secrets management 160 + 161 + 4. Monitoring 162 + - [ ] Set up logging aggregation 163 + - [ ] Configure health checks 164 + - [ ] Set up metrics collection 165 + 166 + 5. Performance 167 + - [ ] Configure proper cache headers 168 + - [ ] Set up CDN if needed 169 + - [ ] Configure database connection pool size 170 + 171 + ## Common Operations 172 + 173 + ### View Container Logs 174 + ```bash 175 + # All containers 176 + docker-compose logs -f 177 + 178 + # Specific container 179 + docker-compose logs -f app 180 + docker-compose logs -f db 181 + ``` 182 + 183 + ### Access Database 184 + ```bash 185 + # Connect to database container 186 + docker-compose exec db psql -U shortener -d shortener 187 + 188 + # Backup database 189 + docker-compose exec db pg_dump -U shortener shortener > backup.sql 190 + 191 + # Restore database 192 + cat backup.sql | docker-compose exec -T db psql -U shortener -d shortener 193 + ``` 194 + 195 + ### Container Management 196 + ```bash 197 + # Restart single service 198 + docker-compose restart app 199 + 200 + # View container status 201 + docker-compose ps 202 + 203 + # View resource usage 204 + docker-compose top 205 + ``` 206 + 207 + ## Troubleshooting 208 + 209 + ### Database Connection Issues 210 + 1. Check if database container is running: 211 + ```bash 212 + docker-compose ps db 213 + ``` 214 + 2. Verify database credentials in environment files 215 + 3. Check database logs: 216 + ```bash 217 + docker-compose logs db 218 + ``` 219 + 220 + ### Frontend Build Issues 221 + 1. Clear node_modules and rebuild: 222 + ```bash 223 + docker-compose down 224 + rm -rf frontend/node_modules 225 + docker-compose up --build 226 + ``` 227 + 228 + ### Backend Issues 229 + 1. Check backend logs: 230 + ```bash 231 + docker-compose logs app 232 + ``` 233 + 2. Verify environment variables are set correctly 234 + 3. Check database connectivity 235 + 236 + ## Build Script Usage 237 + 238 + The `build.sh` script handles environment-specific builds and static file generation. 239 + 240 + ### Basic Usage 241 + ```bash 242 + # Default production build 243 + ./build.sh 244 + 245 + # Development build 246 + ENV=development ./build.sh 247 + 248 + # Staging build 249 + ENV=staging ./build.sh 250 + 251 + # Production build with custom API URL 252 + VITE_API_URL=https://api.example.com ./build.sh 253 + 254 + # Development build with custom API URL 255 + ENV=development VITE_API_URL=http://localhost:8080 ./build.sh 256 + 257 + # Show help 258 + ./build.sh --help
+39 -12
Dockerfile
··· 1 - # Build stage 2 - FROM rust:latest as builder 1 + # Frontend build stage 2 + FROM oven/bun:latest as frontend-builder 3 + WORKDIR /app/frontend 4 + 5 + # Install bun 6 + RUN curl -fsSL https://bun.sh/install | bash 7 + ENV PATH="/root/.bun/bin:${PATH}" 8 + 9 + # Copy frontend files 10 + COPY frontend/package.json frontend/bun.lock ./ 11 + RUN bun install 12 + 13 + COPY frontend/ ./ 14 + 15 + # Build frontend with environment variables 16 + # These can be overridden at build time 17 + ARG VITE_API_URL=http://localhost:3000 18 + ARG NODE_ENV=production 19 + ENV VITE_API_URL=$VITE_API_URL 20 + ENV NODE_ENV=$NODE_ENV 21 + 22 + RUN echo "VITE_API_URL=${VITE_API_URL}" > .env.production 23 + RUN bun run build 24 + 25 + # Rust build stage 26 + FROM rust:latest as backend-builder 3 27 4 28 # Install PostgreSQL client libraries and SSL dependencies 5 29 RUN apt-get update && \ ··· 16 40 COPY migrations/ migrations/ 17 41 COPY .sqlx/ .sqlx/ 18 42 19 - # Build your application 20 - RUN cargo build --release 43 + # Build application 44 + ARG RUST_ENV=release 45 + RUN cargo build --${RUST_ENV} 21 46 22 47 # Runtime stage 23 48 FROM debian:bookworm-slim ··· 29 54 30 55 WORKDIR /app 31 56 32 - # Copy the binary from builder 33 - COPY --from=builder /usr/src/app/target/release/simplelink /app/simplelink 34 - # Copy migrations folder for SQLx 35 - COPY --from=builder /usr/src/app/migrations /app/migrations 57 + # Copy the binary and migrations from backend builder 58 + COPY --from=backend-builder /usr/src/app/target/release/simplelink /app/simplelink 59 + COPY --from=backend-builder /usr/src/app/migrations /app/migrations 60 + 61 + # Copy static files from frontend builder 62 + COPY --from=frontend-builder /app/frontend/dist /app/static 36 63 37 - # Expose the port (this is just documentation) 38 - EXPOSE 8080 64 + # Expose the port 65 + EXPOSE 3000 39 66 40 67 # Set default network configuration 41 68 ENV SERVER_HOST=0.0.0.0 42 - ENV SERVER_PORT=8080 69 + ENV SERVER_PORT=3000 43 70 44 71 # Run the binary 45 - CMD ["./simplelink"] 72 + CMD ["./simplelink"]
+174
README.md
··· 1 + # SimpleLink 2 + 3 + A modern link shortening and tracking service built with Rust and React. 4 + 5 + ## Features 6 + 7 + - 🔗 Link shortening with custom codes 8 + - 📊 Click tracking and analytics 9 + - 🔒 User authentication 10 + - 📱 Responsive design 11 + - 🌓 Dark/light mode 12 + - 📈 Source attribution tracking 13 + 14 + ## Tech Stack 15 + 16 + ### Backend 17 + - Rust 18 + - Actix-web 19 + - SQLx 20 + - PostgreSQL 21 + - JWT Authentication 22 + 23 + ### Frontend 24 + - React 25 + - TypeScript 26 + - Tailwind CSS 27 + - Shadcn/ui 28 + - Vite 29 + - Recharts 30 + 31 + ## Development Setup 32 + 33 + ### Prerequisites 34 + - Rust (latest stable) 35 + - Bun (or Node.js) 36 + - PostgreSQL 37 + - Docker (optional) 38 + 39 + ### Environment Variables 40 + 41 + #### Backend (.env) 42 + ```env 43 + DATABASE_URL=postgres://user:password@localhost:5432/simplelink 44 + SERVER_HOST=127.0.0.1 45 + SERVER_PORT=3000 46 + JWT_SECRET=your-secret-key 47 + ``` 48 + 49 + #### Frontend Environment Files 50 + 51 + Development (.env.development): 52 + ```env 53 + VITE_API_URL=http://localhost:3000 54 + ``` 55 + 56 + Production (.env.production): 57 + ```env 58 + VITE_API_URL=https://your-production-domain.com 59 + ``` 60 + 61 + ### Local Development 62 + 63 + 1. Clone the repository: 64 + ```bash 65 + git clone https://github.com/yourusername/simplelink.git 66 + cd simplelink 67 + ``` 68 + 69 + 2. Set up the database: 70 + ```bash 71 + psql -U postgres 72 + CREATE DATABASE simplelink; 73 + ``` 74 + 75 + 3. Run database migrations: 76 + ```bash 77 + cargo run --bin migrate 78 + ``` 79 + 80 + 4. Start the backend server: 81 + ```bash 82 + cargo run 83 + ``` 84 + 85 + 5. In a new terminal, start the frontend development server: 86 + ```bash 87 + cd frontend 88 + bun install 89 + bun run dev 90 + ``` 91 + 92 + The app will be available at: 93 + - Frontend: http://localhost:5173 94 + - Backend API: http://localhost:3000 95 + 96 + ### Building for Production 97 + 98 + Use the build script to create a production build: 99 + ```bash 100 + ./build.sh 101 + ``` 102 + 103 + This will: 104 + 1. Build the frontend with production settings 105 + 2. Copy static files to the correct location 106 + 3. Prepare everything for deployment 107 + 108 + You can override the API URL during build: 109 + ```bash 110 + VITE_API_URL=https://api.yoursite.com ./build.sh 111 + ``` 112 + 113 + ### Docker Deployment 114 + 115 + Build and run using Docker Compose: 116 + ```bash 117 + docker-compose up --build 118 + ``` 119 + 120 + Or build and run the containers separately: 121 + ```bash 122 + # Build the image 123 + docker build -t simplelink . 124 + 125 + # Run the container 126 + docker run -p 3000:3000 \ 127 + -e DATABASE_URL=postgres://user:password@db:5432/simplelink \ 128 + -e JWT_SECRET=your-secret-key \ 129 + simplelink 130 + ``` 131 + 132 + ## Project Structure 133 + 134 + ``` 135 + simplelink/ 136 + ├── src/ # Rust backend code 137 + │ ├── handlers/ # Request handlers 138 + │ ├── models/ # Database models 139 + │ └── main.rs # Application entry point 140 + ├── migrations/ # Database migrations 141 + ├── frontend/ # React frontend 142 + │ ├── src/ 143 + │ │ ├── components/ # React components 144 + │ │ ├── api/ # API client 145 + │ │ └── types/ # TypeScript types 146 + │ └── vite.config.ts # Vite configuration 147 + ├── static/ # Built frontend files (generated) 148 + ├── Cargo.toml # Rust dependencies 149 + ├── docker-compose.yml # Docker composition 150 + └── build.sh # Build script 151 + ``` 152 + 153 + ## API Endpoints 154 + 155 + - `POST /api/auth/register` - Register new user 156 + - `POST /api/auth/login` - Login user 157 + - `POST /api/shorten` - Create short link 158 + - `GET /api/links` - Get all user links 159 + - `DELETE /api/links/{id}` - Delete link 160 + - `GET /api/links/{id}/clicks` - Get click statistics 161 + - `GET /api/links/{id}/sources` - Get source statistics 162 + - `GET /{short_code}` - Redirect to original URL 163 + 164 + ## Contributing 165 + 166 + 1. Fork the repository 167 + 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 168 + 3. Commit your changes (`git commit -m 'Add some amazing feature'`) 169 + 4. Push to the branch (`git push origin feature/amazing-feature`) 170 + 5. Open a Pull Request 171 + 172 + ## License 173 + 174 + This project is licensed under the MIT License - see the LICENSE file for details.
+50
build.sh
··· 1 + #!/bin/bash 2 + set -e 3 + 4 + # Default environment is production 5 + ENV=${ENV:-production} 6 + echo "Building for environment: $ENV" 7 + 8 + # Load environment variables from the appropriate .env file 9 + if [ -f "frontend/.env.$ENV" ]; then 10 + echo "Loading environment variables from frontend/.env.$ENV" 11 + export $(cat frontend/.env.$ENV | grep -v '^#' | xargs) 12 + else 13 + echo "Warning: No .env.$ENV file found in frontend directory" 14 + fi 15 + 16 + # Allow override of VITE_API_URL through command line 17 + VITE_API_URL=${VITE_API_URL:-${VITE_API_URL:-http://localhost:3000}} 18 + echo "Using API URL: $VITE_API_URL" 19 + 20 + echo "Building frontend..." 21 + cd frontend 22 + bun install 23 + 24 + # Export variables for Vite to pick up 25 + export VITE_API_URL 26 + export NODE_ENV=$ENV 27 + 28 + echo "Running build..." 29 + bun run build 30 + 31 + echo "Copying static files..." 32 + cd .. 33 + rm -rf static 34 + mkdir -p static 35 + cp -r frontend/dist/* static/ 36 + 37 + echo "Build complete!" 38 + 39 + # Usage information if no arguments provided 40 + if [ "$1" == "--help" ] || [ "$1" == "-h" ]; then 41 + echo "Usage:" 42 + echo " ./build.sh # Builds with production environment" 43 + echo " ENV=development ./build.sh # Builds with development environment" 44 + echo " VITE_API_URL=https://api.example.com ./build.sh # Overrides API URL" 45 + echo "" 46 + echo "Environment Variables:" 47 + echo " ENV - Build environment (development/staging/production)" 48 + echo " VITE_API_URL - Override the API URL" 49 + exit 0 50 + fi
+33 -5
docker-compose.yml
··· 1 1 version: '3.8' 2 + 3 + # Define reusable environment variables 4 + x-environment: &common-env 5 + SERVER_HOST: 0.0.0.0 6 + SERVER_PORT: 3000 7 + RUST_LOG: info 8 + 2 9 services: 10 + app: 11 + build: 12 + context: . 13 + dockerfile: Dockerfile 14 + args: 15 + # Build-time variables 16 + VITE_API_URL: ${VITE_API_URL:-http://localhost:3000} 17 + NODE_ENV: ${NODE_ENV:-production} 18 + RUST_ENV: ${RUST_ENV:-release} 19 + container_name: shortener-app 20 + ports: 21 + - "3000:3000" 22 + environment: 23 + <<: *common-env # Include common environment variables 24 + DATABASE_URL: postgres://shortener:shortener123@db:5432/shortener 25 + JWT_SECRET: ${JWT_SECRET:-your-secret-key-change-me-in-production} 26 + depends_on: 27 + db: 28 + condition: service_healthy 29 + restart: unless-stopped 30 + 3 31 db: 4 32 image: postgres:15-alpine 5 33 container_name: shortener-db 6 34 environment: 7 - POSTGRES_DB: shortener 8 - POSTGRES_USER: shortener 9 - POSTGRES_PASSWORD: shortener123 35 + POSTGRES_DB: ${POSTGRES_DB:-shortener} 36 + POSTGRES_USER: ${POSTGRES_USER:-shortener} 37 + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-shortener123} 10 38 ports: 11 39 - "5432:5432" 12 40 volumes: 13 41 - shortener-data:/var/lib/postgresql/data 14 42 healthcheck: 15 - test: ["CMD-SHELL", "pg_isready -U shortener"] 43 + test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-shortener}" ] 16 44 interval: 5s 17 45 timeout: 5s 18 46 retries: 5 47 + restart: unless-stopped 19 48 20 49 volumes: 21 50 shortener-data: 22 -
+89 -1
frontend/bun.lock
··· 10 10 "@mantine/form": "^7.16.1", 11 11 "@mantine/hooks": "^7.16.1", 12 12 "@radix-ui/react-dialog": "^1.1.5", 13 + "@radix-ui/react-dropdown-menu": "^2.1.5", 13 14 "@radix-ui/react-label": "^2.1.1", 14 15 "@radix-ui/react-slot": "^1.1.1", 15 16 "@radix-ui/react-tabs": "^1.1.2", ··· 22 23 "react": "^18.3.1", 23 24 "react-dom": "^18.3.1", 24 25 "react-hook-form": "^7.54.2", 26 + "recharts": "^2.15.0", 25 27 "tailwind-merge": "^2.6.0", 26 28 "tailwindcss-animate": "^1.0.7", 27 29 "zod": "^3.24.1", ··· 220 222 221 223 "@radix-ui/primitive": ["@radix-ui/primitive@1.1.1", "", {}, "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA=="], 222 224 225 + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.1", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-NaVpZfmv8SKeZbn4ijN2V3jlHA9ngBG16VnIIm22nUR0Yk8KUALyBxT3KYEUnNuch9sTE8UTsS3whzBgKOL30w=="], 226 + 223 227 "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-slot": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-LwT3pSho9Dljg+wY2KN2mrrh6y3qELfftINERIzBUO9e0N+t0oMTyn3k9iv+ZqgrwGkRnLpNJrsMv9BZlt2yuA=="], 224 228 225 229 "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw=="], ··· 231 235 "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg=="], 232 236 233 237 "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.4", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-escape-keydown": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XDUI0IVYVSwjMXxM6P4Dfti7AH+Y4oS/TB+sglZ/EXc7cqLwGAmp1NlMrcUjj7ks6R5WTZuWKv44FBbLpwU3sA=="], 238 + 239 + "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-menu": "2.1.5", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-50ZmEFL1kOuLalPKHrLWvPFMons2fGx9TqQCWlPwDVpbAnaUJ1g4XNcKqFNMQymYU0kKWR4MDDi+9vUQBGFgcQ=="], 234 240 235 241 "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg=="], 236 242 ··· 240 246 241 247 "@radix-ui/react-label": ["@radix-ui/react-label@2.1.1", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UUw5E4e/2+4kFMH7+YxORXGWggtY6sM8WIwh5RZchhLuUg2H1hc98Py+pr8HMz6rdaYrK2t296ZEjYLOCO5uUw=="], 242 248 249 + "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-dismissable-layer": "1.1.4", "@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-scope": "1.1.1", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-popper": "1.2.1", "@radix-ui/react-portal": "1.1.3", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-roving-focus": "1.1.1", "@radix-ui/react-slot": "1.1.1", "@radix-ui/react-use-callback-ref": "1.1.0", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-uH+3w5heoMJtqVCgYOtYVMECk1TOrkUn0OG0p5MqXC0W2ppcuVeESbou8PTHoqAjbdTEK19AGXBWcEtR5WpEQg=="], 250 + 251 + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.1", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0", "@radix-ui/react-use-rect": "1.1.0", "@radix-ui/react-use-size": "1.1.0", "@radix-ui/rect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3kn5Me69L+jv82EKRuQCXdYyf1DqHwD2U/sxoNgBGCB7K9TRc3bQamQ+5EPM9EvyPdli0W41sROd+ZU1dTCztw=="], 252 + 243 253 "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-NciRqhXnGojhT93RPyDaMPfLH3ZSl4jjIFbZQ1b/vxvZEdHsBZ49wP9w8L3HzUQwep01LcWtkUvm0OVB5JAHTw=="], 244 254 245 255 "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg=="], ··· 262 272 263 273 "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w=="], 264 274 275 + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.0", "", { "dependencies": { "@radix-ui/rect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ=="], 276 + 277 + "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.0", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw=="], 278 + 265 279 "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.1.1", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vVfA2IZ9q/J+gEamvj761Oq1FpWgCDaNOOIfbPVp2MVPLEomUr5+Vf7kJGwQ24YxZSlQVar7Bes8kyTo5Dshpg=="], 280 + 281 + "@radix-ui/rect": ["@radix-ui/rect@1.1.0", "", {}, "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg=="], 266 282 267 283 "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.32.0", "", { "os": "android", "cpu": "arm" }, "sha512-G2fUQQANtBPsNwiVFg4zKiPQyjVKZCUdQUol53R8E71J7AsheRMV/Yv/nB8giOcOVqP7//eB5xPqieBYZe9bGg=="], 268 284 ··· 340 356 341 357 "@types/babel__traverse": ["@types/babel__traverse@7.20.6", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg=="], 342 358 359 + "@types/d3-array": ["@types/d3-array@3.2.1", "", {}, "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="], 360 + 361 + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], 362 + 363 + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], 364 + 365 + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], 366 + 367 + "@types/d3-path": ["@types/d3-path@3.1.0", "", {}, "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ=="], 368 + 369 + "@types/d3-scale": ["@types/d3-scale@4.0.8", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ=="], 370 + 371 + "@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="], 372 + 373 + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], 374 + 375 + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], 376 + 343 377 "@types/estree": ["@types/estree@1.0.6", "", {}, "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="], 344 378 345 379 "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], ··· 424 458 425 459 "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], 426 460 461 + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], 462 + 463 + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], 464 + 465 + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], 466 + 467 + "d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="], 468 + 469 + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], 470 + 471 + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], 472 + 473 + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], 474 + 475 + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], 476 + 477 + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], 478 + 479 + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], 480 + 481 + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], 482 + 427 483 "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], 428 484 485 + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], 486 + 429 487 "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], 430 488 431 489 "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], ··· 433 491 "detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], 434 492 435 493 "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], 494 + 495 + "dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="], 436 496 437 497 "electron-to-chromium": ["electron-to-chromium@1.5.88", "", {}, "sha512-K3C2qf1o+bGzbilTDCTBhTQcMS9KW60yTAaTeeXsfvQuTDDwlokLam/AdqlqcSy9u4UainDgsHV23ksXAOgamw=="], 438 498 ··· 466 526 467 527 "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], 468 528 529 + "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], 530 + 469 531 "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], 470 532 533 + "fast-equals": ["fast-equals@5.2.2", "", {}, "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw=="], 534 + 471 535 "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], 472 536 473 537 "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], ··· 519 583 "import-fresh": ["import-fresh@3.3.0", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw=="], 520 584 521 585 "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], 586 + 587 + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], 522 588 523 589 "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], 524 590 ··· 581 647 "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], 582 648 583 649 "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], 650 + 651 + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], 584 652 585 653 "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], 586 654 ··· 608 676 609 677 "node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="], 610 678 679 + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], 680 + 611 681 "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], 612 682 613 683 "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], ··· 634 704 635 705 "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], 636 706 707 + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], 708 + 637 709 "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], 638 710 639 711 "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], ··· 646 718 647 719 "react-hook-form": ["react-hook-form@7.54.2", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg=="], 648 720 649 - "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], 721 + "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], 650 722 651 723 "react-number-format": ["react-number-format@5.4.3", "", { "peerDependencies": { "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VCY5hFg/soBighAoGcdE+GagkJq0230qN6jcS5sp8wQX1qy1fYN/RX7/BXkrs0oyzzwqR8/+eSUrqXbGeywdUQ=="], 652 724 ··· 656 728 657 729 "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], 658 730 731 + "react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="], 732 + 659 733 "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], 660 734 661 735 "react-textarea-autosize": ["react-textarea-autosize@8.5.6", "", { "dependencies": { "@babel/runtime": "^7.20.13", "use-composed-ref": "^1.3.0", "use-latest": "^1.2.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aT3ioKXMa8f6zHYGebhbdMD2L00tKeRX1zuVuDx9YQK/JLLRSaSxq3ugECEmUB9z2kvk6bFSIoRHLkkUv0RJiw=="], 662 736 737 + "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], 738 + 739 + "recharts": ["recharts@2.15.0", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.0", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cIvMxDfpAmqAmVgc4yb7pgm/O1tmmkl/CjrvXuW+62/+7jj/iF9Ykm+hb/UJt42TREHMyd3gb+pkgoa2MxgDIw=="], 740 + 741 + "recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="], 742 + 663 743 "regenerator-runtime": ["regenerator-runtime@0.14.1", "", {}, "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="], 664 744 665 745 "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], ··· 702 782 703 783 "tapable": ["tapable@2.2.1", "", {}, "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ=="], 704 784 785 + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], 786 + 705 787 "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], 706 788 707 789 "ts-api-utils": ["ts-api-utils@2.0.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ=="], ··· 732 814 733 815 "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], 734 816 817 + "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], 818 + 735 819 "vite": ["vite@6.0.11", "", { "dependencies": { "esbuild": "^0.24.2", "postcss": "^8.4.49", "rollup": "^4.23.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4VL9mQPKoHy4+FE0NnRE/kbY51TOfaknxAjt3fJbGJxhIpBZiqVzlZDEesWWsuREXHwNdAoOFZ9MkPEVXczHwg=="], 736 820 737 821 "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], ··· 761 845 "@typescript-eslint/typescript-estree/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], 762 846 763 847 "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], 848 + 849 + "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], 850 + 851 + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], 764 852 765 853 "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], 766 854 }
+1
frontend/package.json
··· 29 29 "react": "^18.3.1", 30 30 "react-dom": "^18.3.1", 31 31 "react-hook-form": "^7.54.2", 32 + "recharts": "^2.15.0", 32 33 "tailwind-merge": "^2.6.0", 33 34 "tailwindcss-animate": "^1.0.7", 34 35 "zod": "^3.24.1"
+14 -2
frontend/src/api/client.ts
··· 1 1 import axios from 'axios'; 2 - import { CreateLinkRequest, Link, AuthResponse } from '../types/api'; 2 + import { CreateLinkRequest, Link, AuthResponse, ClickStats, SourceStats } from '../types/api'; 3 3 4 4 // Create axios instance with default config 5 5 const api = axios.create({ ··· 45 45 46 46 export const deleteLink = async (id: number) => { 47 47 await api.delete(`/links/${id}`); 48 - }; 48 + }; 49 + 50 + export const getLinkClickStats = async (id: number) => { 51 + const response = await api.get<ClickStats[]>(`/links/${id}/clicks`); 52 + return response.data; 53 + }; 54 + 55 + export const getLinkSourceStats = async (id: number) => { 56 + const response = await api.get<SourceStats[]>(`/links/${id}/sources`); 57 + return response.data; 58 + }; 59 + 60 + export { api };
+21 -1
frontend/src/components/LinkList.tsx
··· 12 12 } from "@/components/ui/table" 13 13 import { Button } from "@/components/ui/button" 14 14 import { useToast } from "@/hooks/use-toast" 15 - import { Copy, Trash2 } from "lucide-react" 15 + import { Copy, Trash2, BarChart2 } from "lucide-react" 16 16 import { 17 17 Dialog, 18 18 DialogContent, ··· 21 21 DialogDescription, 22 22 DialogFooter, 23 23 } from "@/components/ui/dialog" 24 + 25 + import { StatisticsModal } from "./StatisticsModal" 24 26 25 27 interface LinkListProps { 26 28 refresh?: number; ··· 33 35 isOpen: false, 34 36 linkId: null, 35 37 }) 38 + const [statsModal, setStatsModal] = useState<{ isOpen: boolean; linkId: number | null }>({ 39 + isOpen: false, 40 + linkId: null, 41 + }); 36 42 const { toast } = useToast() 37 43 38 44 const fetchLinks = async () => { ··· 148 154 <Button 149 155 variant="ghost" 150 156 size="icon" 157 + className="h-8 w-8" 158 + onClick={() => setStatsModal({ isOpen: true, linkId: link.id })} 159 + > 160 + <BarChart2 className="h-4 w-4" /> 161 + <span className="sr-only">View statistics</span> 162 + </Button> 163 + <Button 164 + variant="ghost" 165 + size="icon" 151 166 className="h-8 w-8 text-destructive" 152 167 onClick={() => setDeleteModal({ isOpen: true, linkId: link.id })} 153 168 > ··· 163 178 </div> 164 179 </CardContent> 165 180 </Card> 181 + <StatisticsModal 182 + isOpen={statsModal.isOpen} 183 + onClose={() => setStatsModal({ isOpen: false, linkId: null })} 184 + linkId={statsModal.linkId!} 185 + /> 166 186 </> 167 187 ) 168 188 }
+115
frontend/src/components/StatisticsModal.tsx
··· 1 + import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; 2 + import { 3 + LineChart, 4 + Line, 5 + XAxis, 6 + YAxis, 7 + CartesianGrid, 8 + Tooltip, 9 + ResponsiveContainer, 10 + } from "recharts"; 11 + import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 12 + import { useState, useEffect } from "react"; 13 + 14 + import { getLinkClickStats, getLinkSourceStats } from '../api/client'; 15 + import { ClickStats, SourceStats } from '../types/api'; 16 + 17 + interface StatisticsModalProps { 18 + isOpen: boolean; 19 + onClose: () => void; 20 + linkId: number; 21 + } 22 + 23 + export function StatisticsModal({ isOpen, onClose, linkId }: StatisticsModalProps) { 24 + const [clicksOverTime, setClicksOverTime] = useState<ClickStats[]>([]); 25 + const [sourcesData, setSourcesData] = useState<SourceStats[]>([]); 26 + const [loading, setLoading] = useState(true); 27 + 28 + useEffect(() => { 29 + if (isOpen && linkId) { 30 + const fetchData = async () => { 31 + try { 32 + setLoading(true); 33 + const [clicksData, sourcesData] = await Promise.all([ 34 + getLinkClickStats(linkId), 35 + getLinkSourceStats(linkId), 36 + ]); 37 + setClicksOverTime(clicksData); 38 + setSourcesData(sourcesData); 39 + } catch (error) { 40 + console.error("Failed to fetch statistics:", error); 41 + } finally { 42 + setLoading(false); 43 + } 44 + }; 45 + 46 + fetchData(); 47 + } 48 + }, [isOpen, linkId]); 49 + 50 + return ( 51 + <Dialog open={isOpen} onOpenChange={onClose}> 52 + <DialogContent className="max-w-3xl"> 53 + <DialogHeader> 54 + <DialogTitle>Link Statistics</DialogTitle> 55 + </DialogHeader> 56 + 57 + {loading ? ( 58 + <div className="flex items-center justify-center h-64">Loading...</div> 59 + ) : ( 60 + <div className="grid gap-4"> 61 + <Card> 62 + <CardHeader> 63 + <CardTitle>Clicks Over Time</CardTitle> 64 + </CardHeader> 65 + <CardContent> 66 + <div className="h-[300px]"> 67 + <ResponsiveContainer width="100%" height="100%"> 68 + <LineChart data={clicksOverTime}> 69 + <CartesianGrid strokeDasharray="3 3" /> 70 + <XAxis dataKey="date" /> 71 + <YAxis /> 72 + <Tooltip /> 73 + <Line 74 + type="monotone" 75 + dataKey="clicks" 76 + stroke="#8884d8" 77 + strokeWidth={2} 78 + /> 79 + </LineChart> 80 + </ResponsiveContainer> 81 + </div> 82 + </CardContent> 83 + </Card> 84 + 85 + <Card> 86 + <CardHeader> 87 + <CardTitle>Top Sources</CardTitle> 88 + </CardHeader> 89 + <CardContent> 90 + <ul className="space-y-2"> 91 + {sourcesData.map((source, index) => ( 92 + <li 93 + key={source.source} 94 + className="flex items-center justify-between py-2 border-b last:border-0" 95 + > 96 + <span className="text-sm"> 97 + <span className="font-medium text-muted-foreground mr-2"> 98 + {index + 1}. 99 + </span> 100 + {source.source} 101 + </span> 102 + <span className="text-sm font-medium"> 103 + {source.count} clicks 104 + </span> 105 + </li> 106 + ))} 107 + </ul> 108 + </CardContent> 109 + </Card> 110 + </div> 111 + )} 112 + </DialogContent> 113 + </Dialog> 114 + ); 115 + }
+11 -1
frontend/src/types/api.ts
··· 24 24 25 25 export interface ApiError { 26 26 error: string; 27 - } 27 + } 28 + 29 + export interface ClickStats { 30 + date: string; 31 + clicks: number; 32 + } 33 + 34 + export interface SourceStats { 35 + source: string; 36 + count: number; 37 + }
+28 -17
frontend/vite.config.ts
··· 1 - import { defineConfig } from 'vite' 1 + import { defineConfig, loadEnv } from 'vite' 2 2 import react from '@vitejs/plugin-react' 3 3 import tailwindcss from '@tailwindcss/vite' 4 4 import path from "path" 5 5 6 - export default defineConfig({ 7 - plugins: [ 8 - react(), 9 - tailwindcss(), 10 - ], 11 - server: { 12 - proxy: { 13 - '/api': { 14 - target: 'http://localhost:8080', 15 - changeOrigin: true, 6 + // https://vitejs.dev/config/ 7 + export default defineConfig(({ mode }) => { 8 + // Load env file based on `mode` in the current working directory. 9 + const env = loadEnv(mode, process.cwd(), '') 10 + 11 + return { 12 + plugins: [ 13 + react(), 14 + tailwindcss(), 15 + ], 16 + server: { 17 + proxy: { 18 + '/api': { 19 + // Use environment variable with fallback 20 + target: env.VITE_API_URL || 'http://localhost:3000', 21 + changeOrigin: true, 22 + }, 23 + }, 24 + }, 25 + resolve: { 26 + alias: { 27 + "@": path.resolve(__dirname, "./src"), 16 28 }, 17 29 }, 18 - }, 19 - resolve: { 20 - alias: { 21 - "@": path.resolve(__dirname, "./src"), 30 + base: '/', 31 + build: { 32 + outDir: 'dist', 33 + assetsDir: 'assets', 22 34 }, 23 - }, 35 + } 24 36 }) 25 -
+84 -1
src/handlers.rs
··· 2 2 use crate::{ 3 3 error::AppError, 4 4 models::{ 5 - AuthResponse, Claims, CreateLink, Link, LoginRequest, RegisterRequest, User, UserResponse, 5 + AuthResponse, Claims, ClickStats, CreateLink, Link, LoginRequest, RegisterRequest, 6 + SourceStats, User, UserResponse, 6 7 }, 7 8 AppState, 8 9 }; ··· 305 306 306 307 Ok(HttpResponse::NoContent().finish()) 307 308 } 309 + 310 + pub async fn get_link_clicks( 311 + state: web::Data<AppState>, 312 + user: AuthenticatedUser, 313 + path: web::Path<i32>, 314 + ) -> Result<impl Responder, AppError> { 315 + let link_id = path.into_inner(); 316 + 317 + // Verify the link belongs to the user 318 + let link = sqlx::query!( 319 + "SELECT id FROM links WHERE id = $1 AND user_id = $2", 320 + link_id, 321 + user.user_id 322 + ) 323 + .fetch_optional(&state.db) 324 + .await?; 325 + 326 + if link.is_none() { 327 + return Err(AppError::NotFound); 328 + } 329 + 330 + let clicks = sqlx::query_as!( 331 + ClickStats, 332 + r#" 333 + SELECT 334 + DATE(created_at)::date as "date!", 335 + COUNT(*)::bigint as "clicks!" 336 + FROM clicks 337 + WHERE link_id = $1 338 + GROUP BY DATE(created_at) 339 + ORDER BY DATE(created_at) ASC -- Changed from DESC to ASC 340 + LIMIT 30 341 + "#, 342 + link_id 343 + ) 344 + .fetch_all(&state.db) 345 + .await?; 346 + 347 + Ok(HttpResponse::Ok().json(clicks)) 348 + } 349 + 350 + pub async fn get_link_sources( 351 + state: web::Data<AppState>, 352 + user: AuthenticatedUser, 353 + path: web::Path<i32>, 354 + ) -> Result<impl Responder, AppError> { 355 + let link_id = path.into_inner(); 356 + 357 + // Verify the link belongs to the user 358 + let link = sqlx::query!( 359 + "SELECT id FROM links WHERE id = $1 AND user_id = $2", 360 + link_id, 361 + user.user_id 362 + ) 363 + .fetch_optional(&state.db) 364 + .await?; 365 + 366 + if link.is_none() { 367 + return Err(AppError::NotFound); 368 + } 369 + 370 + let sources = sqlx::query_as!( 371 + SourceStats, 372 + r#" 373 + SELECT 374 + query_source as "source!", 375 + COUNT(*)::bigint as "count!" 376 + FROM clicks 377 + WHERE link_id = $1 378 + AND query_source IS NOT NULL 379 + AND query_source != '' 380 + GROUP BY query_source 381 + ORDER BY COUNT(*) DESC 382 + LIMIT 10 383 + "#, 384 + link_id 385 + ) 386 + .fetch_all(&state.db) 387 + .await?; 388 + 389 + Ok(HttpResponse::Ok().json(sources)) 390 + }
+24 -8
src/main.rs
··· 1 1 use actix_cors::Cors; 2 - use actix_web::{web, App, HttpServer}; 2 + use actix_files::Files; 3 + use actix_web::{middleware::DefaultHeaders, web, App, HttpServer}; 3 4 use anyhow::Result; 4 - use simple_link::{handlers, AppState}; 5 + use simplelink::{handlers, AppState}; 5 6 use sqlx::postgres::PgPoolOptions; 6 7 use tracing::info; 7 8 9 + async fn index() -> Result<actix_files::NamedFile, actix_web::Error> { 10 + Ok(actix_files::NamedFile::open("./static/index.html")?) 11 + } 12 + 8 13 #[actix_web::main] 9 14 async fn main() -> Result<()> { 10 15 // Load environment variables from .env file ··· 26 31 // Run database migrations 27 32 sqlx::migrate!("./migrations").run(&pool).await?; 28 33 29 - let state = AppState { db: pool }; 34 + let _state = AppState { db: pool }; 30 35 31 36 let host = std::env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); 32 - let port = std::env::var("SERVER_PORT").unwrap_or_else(|_| "8080".to_string()); 37 + let port = std::env::var("SERVER_PORT").unwrap_or_else(|_| "3000".to_string()); 33 38 info!("Starting server at http://{}:{}", host, port); 34 39 35 40 // Start HTTP server ··· 42 47 43 48 App::new() 44 49 .wrap(cors) 45 - .app_data(web::Data::new(state.clone())) 50 + // Add headers to help with caching static assets 51 + .wrap(DefaultHeaders::new().add(("Cache-Control", "max-age=31536000"))) 52 + // API routes 46 53 .service( 47 54 web::scope("/api") 48 55 .route("/shorten", web::post().to(handlers::create_short_url)) 49 56 .route("/links", web::get().to(handlers::get_all_links)) 50 57 .route("/links/{id}", web::delete().to(handlers::delete_link)) 58 + .route( 59 + "/links/{id}/clicks", 60 + web::get().to(handlers::get_link_clicks), 61 + ) 62 + .route( 63 + "/links/{id}/sources", 64 + web::get().to(handlers::get_link_sources), 65 + ) 51 66 .route("/auth/register", web::post().to(handlers::register)) 52 67 .route("/auth/login", web::post().to(handlers::login)) 53 68 .route("/health", web::get().to(handlers::health_check)), 54 69 ) 55 - .service(web::resource("/{short_code}").route(web::get().to(handlers::redirect_to_url))) 70 + // Serve static files 71 + .service(Files::new("/assets", "./static/assets")) 72 + // Handle SPA routes - must be last 73 + .default_service(web::get().to(index)) 56 74 }) 57 - .workers(2) 58 - .backlog(10_000) 59 75 .bind(format!("{}:{}", host, port))? 60 76 .run() 61 77 .await?;
+17 -6
src/models.rs
··· 1 1 use std::time::{SystemTime, UNIX_EPOCH}; 2 2 3 + use chrono::NaiveDate; 3 4 use serde::{Deserialize, Serialize}; 4 5 use sqlx::FromRow; 5 6 ··· 14 15 let exp = SystemTime::now() 15 16 .duration_since(UNIX_EPOCH) 16 17 .unwrap() 17 - .as_secs() as usize + 24 * 60 * 60; // 24 hours from now 18 - 19 - Self { 20 - sub: user_id, 21 - exp, 22 - } 18 + .as_secs() as usize 19 + + 24 * 60 * 60; // 24 hours from now 20 + 21 + Self { sub: user_id, exp } 23 22 } 24 23 } 25 24 ··· 70 69 pub email: String, 71 70 pub password_hash: String, 72 71 } 72 + 73 + #[derive(sqlx::FromRow, Serialize)] 74 + pub struct ClickStats { 75 + pub date: NaiveDate, 76 + pub clicks: i64, 77 + } 78 + 79 + #[derive(sqlx::FromRow, Serialize)] 80 + pub struct SourceStats { 81 + pub source: String, 82 + pub count: i64, 83 + }