├── .env.example ├── .github └── workflows │ └── docker-image.yml ├── .gitignore ├── .preludeignore ├── .sqlx ├── query-2adc9fa303079a3e9c28bbf0565c1ac60eea3a5c37e34fc6a0cb6e151c325382.json ├── query-4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068.json ├── query-8452fbf45386d160bc99ac6c0917a00bf5dad445ef7d484936ce6e0cbe21c965.json ├── query-a7e827ab162e612a3ef110b680bfafe623409a8da72626952f173df6b740e33b.json ├── query-c723ec75f9ca9482e1bc86108c20bf379e5728f378626198a0a9ed024a413273.json ├── query-d5fcbbb502138662f670111479633938368ca7a29212cf4f72567efc4cd81a85.json ├── query-eeec02400984de4ad69b0b363ce911e74c5684121d0340a97ca8f1fa726774d5.json ├── query-f3f58600e971f1be6cbe206bba24f77769f54c6230e28f5b3dc719b869d9cb3f.json └── query-fd64104d130b93dd5fc9414b8710ad5183b647eaaff90decbce15e10d83c7538.json ├── API.md ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── README.md ├── build.sh ├── docker-compose.yml ├── frontend ├── .gitignore ├── .preludeignore ├── README.md ├── components.json ├── eslint.config.js ├── index.html ├── package.json ├── postcss.config.js ├── src │ ├── App.tsx │ ├── api │ │ └── client.ts │ ├── components │ │ ├── AuthForms.tsx │ │ ├── EditModal.tsx │ │ ├── Footer.tsx │ │ ├── LinkForm.tsx │ │ ├── LinkList.tsx │ │ ├── PrivacyModal.tsx │ │ ├── StatisticsModal.tsx │ │ ├── mode-toggle.tsx │ │ ├── theme-provider.tsx │ │ └── ui │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── container.tsx │ │ │ ├── dialog.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── form.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ ├── toast.tsx │ │ │ └── toaster.tsx │ ├── context │ │ └── AuthContext.tsx │ ├── hooks │ │ └── use-toast.ts │ ├── index.css │ ├── lib │ │ └── utils.ts │ ├── main.tsx │ ├── types │ │ └── api.ts │ └── vite-env.d.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── migrations ├── 20250125000000_init.sql ├── 20250219000000_extend_short_code.sql └── sqlite │ └── 20250125000000_init.sql ├── readme_img ├── mainview.jpg └── statview.jpg ├── src ├── auth.rs ├── error.rs ├── handlers.rs ├── lib.rs ├── main.rs └── models.rs └── test ├── .gitignore ├── README.md ├── bun.lock ├── index.ts ├── mikubeam.js ├── package.json └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # by default, simplelink uses an sqlite db in /data, to use a postgres db, set DATABASE_URl 2 | # DATABASE_URL=postgresql://user:password@localhost/dbname 3 | SERVER_HOST=127.0.0.1 4 | SERVER_PORT=8080 5 | JWT_SECRET=change-me-in-production 6 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: 4 | schedule: 5 | - cron: "38 9 * * *" 6 | push: 7 | branches: ["main"] 8 | tags: ["v*.*.*"] 9 | pull_request: 10 | branches: ["main"] 11 | release: 12 | types: [published] 13 | 14 | env: 15 | REGISTRY: ghcr.io 16 | IMAGE_NAME: ${{ github.repository }} 17 | 18 | jobs: 19 | build: 20 | runs-on: self-hosted 21 | permissions: 22 | contents: read 23 | packages: write 24 | id-token: write 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v3 29 | 30 | - name: Install cosign 31 | if: github.event_name != 'pull_request' 32 | uses: sigstore/cosign-installer@v3.8.1 33 | with: 34 | cosign-release: "v2.4.3" 35 | 36 | - name: Setup Docker buildx 37 | uses: docker/setup-buildx-action@v3 38 | 39 | - name: Log into registry ${{ env.REGISTRY }} 40 | if: github.event_name != 'pull_request' 41 | uses: docker/login-action@v3 42 | with: 43 | registry: ${{ env.REGISTRY }} 44 | username: ${{ github.actor }} 45 | password: ${{ secrets.GITHUB_TOKEN }} 46 | 47 | - name: Log in to Docker Hub 48 | if: github.event_name != 'pull_request' 49 | uses: docker/login-action@v3 50 | with: 51 | username: ${{ secrets.DOCKER_USERNAME }} 52 | password: ${{ secrets.DOCKER_PASSWORD }} 53 | 54 | - name: Extract metadata (tags, labels) for Docker 55 | id: meta 56 | uses: docker/metadata-action@v5 57 | with: 58 | images: | 59 | ${{ env.IMAGE_NAME }} 60 | ${{ env.REGISTRY }}/${{ github.repository }} 61 | 62 | - name: Build and push Docker image 63 | uses: docker/build-push-action@v6 64 | with: 65 | context: . 66 | file: ./Dockerfile 67 | platforms: linux/amd64,linux/arm64 68 | push: ${{ github.event_name != 'pull_request' }} 69 | tags: ${{ steps.meta.outputs.tags }} 70 | labels: ${{ steps.meta.outputs.labels }} 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/node_modules 3 | node_modules 4 | .env 5 | /static 6 | /target 7 | /release 8 | release.tar.gz 9 | *.log 10 | .DS_STORE 11 | admin-setup-token.txt 12 | package-lock.json 13 | bun.lock 14 | *.db* -------------------------------------------------------------------------------- /.preludeignore: -------------------------------------------------------------------------------- 1 | .sqlx 2 | .env 3 | .env.* -------------------------------------------------------------------------------- /.sqlx/query-2adc9fa303079a3e9c28bbf0565c1ac60eea3a5c37e34fc6a0cb6e151c325382.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING *", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Int4" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "email", 14 | "type_info": "Varchar" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "password_hash", 19 | "type_info": "Text" 20 | } 21 | ], 22 | "parameters": { 23 | "Left": [ 24 | "Varchar", 25 | "Text" 26 | ] 27 | }, 28 | "nullable": [ 29 | false, 30 | false, 31 | false 32 | ] 33 | }, 34 | "hash": "2adc9fa303079a3e9c28bbf0565c1ac60eea3a5c37e34fc6a0cb6e151c325382" 35 | } 36 | -------------------------------------------------------------------------------- /.sqlx/query-4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "SELECT id FROM users WHERE email = $1", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Int4" 10 | } 11 | ], 12 | "parameters": { 13 | "Left": [ 14 | "Text" 15 | ] 16 | }, 17 | "nullable": [ 18 | false 19 | ] 20 | }, 21 | "hash": "4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068" 22 | } 23 | -------------------------------------------------------------------------------- /.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 | } 29 | -------------------------------------------------------------------------------- /.sqlx/query-a7e827ab162e612a3ef110b680bfafe623409a8da72626952f173df6b740e33b.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "SELECT id FROM links WHERE id = $1 AND user_id = $2", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Int4" 10 | } 11 | ], 12 | "parameters": { 13 | "Left": [ 14 | "Int4", 15 | "Int4" 16 | ] 17 | }, 18 | "nullable": [ 19 | false 20 | ] 21 | }, 22 | "hash": "a7e827ab162e612a3ef110b680bfafe623409a8da72626952f173df6b740e33b" 23 | } 24 | -------------------------------------------------------------------------------- /.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 | } 29 | -------------------------------------------------------------------------------- /.sqlx/query-d5fcbbb502138662f670111479633938368ca7a29212cf4f72567efc4cd81a85.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "DELETE FROM links WHERE id = $1", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Int4" 9 | ] 10 | }, 11 | "nullable": [] 12 | }, 13 | "hash": "d5fcbbb502138662f670111479633938368ca7a29212cf4f72567efc4cd81a85" 14 | } 15 | -------------------------------------------------------------------------------- /.sqlx/query-eeec02400984de4ad69b0b363ce911e74c5684121d0340a97ca8f1fa726774d5.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "DELETE FROM clicks WHERE link_id = $1", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Int4" 9 | ] 10 | }, 11 | "nullable": [] 12 | }, 13 | "hash": "eeec02400984de4ad69b0b363ce911e74c5684121d0340a97ca8f1fa726774d5" 14 | } 15 | -------------------------------------------------------------------------------- /.sqlx/query-f3f58600e971f1be6cbe206bba24f77769f54c6230e28f5b3dc719b869d9cb3f.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "SELECT * FROM users WHERE email = $1", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Int4" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "email", 14 | "type_info": "Varchar" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "password_hash", 19 | "type_info": "Text" 20 | } 21 | ], 22 | "parameters": { 23 | "Left": [ 24 | "Text" 25 | ] 26 | }, 27 | "nullable": [ 28 | false, 29 | false, 30 | false 31 | ] 32 | }, 33 | "hash": "f3f58600e971f1be6cbe206bba24f77769f54c6230e28f5b3dc719b869d9cb3f" 34 | } 35 | -------------------------------------------------------------------------------- /.sqlx/query-fd64104d130b93dd5fc9414b8710ad5183b647eaaff90decbce15e10d83c7538.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "SELECT COUNT(*) as count FROM users", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "count", 9 | "type_info": "Int8" 10 | } 11 | ], 12 | "parameters": { 13 | "Left": [] 14 | }, 15 | "nullable": [ 16 | null 17 | ] 18 | }, 19 | "hash": "fd64104d130b93dd5fc9414b8710ad5183b647eaaff90decbce15e10d83c7538" 20 | } 21 | -------------------------------------------------------------------------------- /API.md: -------------------------------------------------------------------------------- 1 | # Link Shortener API Documentation 2 | 3 | ## Base URL 4 | `http://localhost:8080` 5 | 6 | ## Authentication 7 | The API uses JWT tokens for authentication. Include the token in the Authorization header: 8 | ``` 9 | Authorization: Bearer 10 | ``` 11 | 12 | ### Register 13 | Create a new user account. 14 | 15 | ```bash 16 | POST /api/auth/register 17 | ``` 18 | 19 | Request Body: 20 | ```json 21 | { 22 | "email": string, // Required: Valid email address 23 | "password": string // Required: Password 24 | } 25 | ``` 26 | 27 | Example: 28 | ```bash 29 | curl -X POST http://localhost:8080/api/auth/register \ 30 | -H "Content-Type: application/json" \ 31 | -d '{ 32 | "email": "user@example.com", 33 | "password": "your_password" 34 | }' 35 | ``` 36 | 37 | Response (200 OK): 38 | ```json 39 | { 40 | "token": "eyJ0eXAiOiJKV1QiLCJhbGc...", 41 | "user": { 42 | "id": 1, 43 | "email": "user@example.com" 44 | } 45 | } 46 | ``` 47 | 48 | ### Login 49 | Authenticate and receive a JWT token. 50 | 51 | ```bash 52 | POST /api/auth/login 53 | ``` 54 | 55 | Request Body: 56 | ```json 57 | { 58 | "email": string, // Required: Registered email address 59 | "password": string // Required: Password 60 | } 61 | ``` 62 | 63 | Example: 64 | ```bash 65 | curl -X POST http://localhost:8080/api/auth/login \ 66 | -H "Content-Type: application/json" \ 67 | -d '{ 68 | "email": "user@example.com", 69 | "password": "your_password" 70 | }' 71 | ``` 72 | 73 | Response (200 OK): 74 | ```json 75 | { 76 | "token": "eyJ0eXAiOiJKV1QiLCJhbGc...", 77 | "user": { 78 | "id": 1, 79 | "email": "user@example.com" 80 | } 81 | } 82 | ``` 83 | 84 | ## Protected Endpoints 85 | 86 | ### Health Check 87 | Check if the service and database are running. 88 | 89 | ```bash 90 | GET /health 91 | ``` 92 | 93 | Example: 94 | ```bash 95 | curl http://localhost:8080/health 96 | ``` 97 | 98 | Response (200 OK): 99 | ```json 100 | "Healthy" 101 | ``` 102 | 103 | Response (503 Service Unavailable): 104 | ```json 105 | "Database unavailable" 106 | ``` 107 | 108 | ### Create Short URL 109 | Create a new shortened URL with optional custom code. Requires authentication. 110 | 111 | ```bash 112 | POST /api/shorten 113 | ``` 114 | 115 | Request Body: 116 | ```json 117 | { 118 | "url": string, // Required: The URL to shorten 119 | "custom_code": string, // Optional: Custom short code 120 | "source": string // Optional: Source of the request 121 | } 122 | ``` 123 | 124 | Examples: 125 | 126 | 1. Create with auto-generated code: 127 | ```bash 128 | curl -X POST http://localhost:8080/api/shorten \ 129 | -H "Content-Type: application/json" \ 130 | -H "Authorization: Bearer YOUR_TOKEN" \ 131 | -d '{ 132 | "url": "https://example.com", 133 | "source": "curl-test" 134 | }' 135 | ``` 136 | 137 | Response (201 Created): 138 | ```json 139 | { 140 | "id": 1, 141 | "user_id": 1, 142 | "original_url": "https://example.com", 143 | "short_code": "Xa7Bc9", 144 | "created_at": "2024-03-01T12:34:56Z", 145 | "clicks": 0 146 | } 147 | ``` 148 | 149 | 2. Create with custom code: 150 | ```bash 151 | curl -X POST http://localhost:8080/api/shorten \ 152 | -H "Content-Type: application/json" \ 153 | -H "Authorization: Bearer YOUR_TOKEN" \ 154 | -d '{ 155 | "url": "https://example.com", 156 | "custom_code": "example", 157 | "source": "curl-test" 158 | }' 159 | ``` 160 | 161 | Response (201 Created): 162 | ```json 163 | { 164 | "id": 2, 165 | "user_id": 1, 166 | "original_url": "https://example.com", 167 | "short_code": "example", 168 | "created_at": "2024-03-01T12:34:56Z", 169 | "clicks": 0 170 | } 171 | ``` 172 | 173 | Error Responses: 174 | 175 | Invalid URL (400 Bad Request): 176 | ```json 177 | { 178 | "error": "URL must start with http:// or https://" 179 | } 180 | ``` 181 | 182 | Custom code taken (400 Bad Request): 183 | ```json 184 | { 185 | "error": "Custom code already taken" 186 | } 187 | ``` 188 | 189 | Invalid custom code (400 Bad Request): 190 | ```json 191 | { 192 | "error": "Custom code must be 1-32 characters long and contain only letters, numbers, underscores, and hyphens" 193 | } 194 | ``` 195 | 196 | Unauthorized (401 Unauthorized): 197 | ```json 198 | { 199 | "error": "Unauthorized" 200 | } 201 | ``` 202 | 203 | ### Get All Links 204 | Retrieve all shortened URLs for the authenticated user. 205 | 206 | ```bash 207 | GET /api/links 208 | ``` 209 | 210 | Example: 211 | ```bash 212 | curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8080/api/links 213 | ``` 214 | 215 | Response (200 OK): 216 | ```json 217 | [ 218 | { 219 | "id": 1, 220 | "user_id": 1, 221 | "original_url": "https://example.com", 222 | "short_code": "Xa7Bc9", 223 | "created_at": "2024-03-01T12:34:56Z", 224 | "clicks": 5 225 | }, 226 | { 227 | "id": 2, 228 | "user_id": 1, 229 | "original_url": "https://example.org", 230 | "short_code": "example", 231 | "created_at": "2024-03-01T12:35:00Z", 232 | "clicks": 3 233 | } 234 | ] 235 | ``` 236 | 237 | ### Redirect to Original URL 238 | Use the shortened URL to redirect to the original URL. Source tracking via query parameter is supported. 239 | 240 | ```bash 241 | GET /{short_code}?source={source} 242 | ``` 243 | 244 | Example: 245 | ```bash 246 | curl -i http://localhost:8080/example?source=email 247 | ``` 248 | 249 | Response (307 Temporary Redirect): 250 | ```http 251 | HTTP/1.1 307 Temporary Redirect 252 | Location: https://example.com 253 | ``` 254 | 255 | Error Response (404 Not Found): 256 | ```json 257 | { 258 | "error": "Not found" 259 | } 260 | ``` 261 | 262 | ## Custom Code Rules 263 | 1. Length: 1-32 characters 264 | 2. Allowed characters: letters, numbers, underscores, and hyphens 265 | 3. Case-sensitive 266 | 4. Cannot use reserved words: ["api", "health", "admin", "static", "assets"] 267 | 268 | ## Rate Limiting 269 | Currently, no rate limiting is implemented. 270 | 271 | ## Notes 272 | 1. All timestamps are in UTC 273 | 2. Click counts are incremented on successful redirects 274 | 3. Source tracking is supported both at link creation and during redirection via query parameter 275 | 4. Custom codes are case-sensitive 276 | 5. URLs must include protocol (http:// or https://) 277 | 6. All create/read operations require authentication 278 | 7. Users can only see and manage their own links 279 | 280 | ## Error Codes 281 | - 200: Success 282 | - 201: Created 283 | - 307: Temporary Redirect 284 | - 400: Bad Request (invalid input) 285 | - 401: Unauthorized (missing or invalid token) 286 | - 404: Not Found 287 | - 503: Service Unavailable 288 | 289 | ## Database Schema 290 | ```sql 291 | -- Users table for authentication 292 | CREATE TABLE users ( 293 | id SERIAL PRIMARY KEY, 294 | email VARCHAR(255) NOT NULL UNIQUE, 295 | password_hash TEXT NOT NULL 296 | ); 297 | 298 | -- Links table with user association 299 | CREATE TABLE links ( 300 | id SERIAL PRIMARY KEY, 301 | original_url TEXT NOT NULL, 302 | short_code VARCHAR(8) NOT NULL UNIQUE, 303 | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 304 | clicks BIGINT NOT NULL DEFAULT 0, 305 | user_id INTEGER REFERENCES users(id) 306 | ); 307 | 308 | -- Click tracking with source information 309 | CREATE TABLE clicks ( 310 | id SERIAL PRIMARY KEY, 311 | link_id INTEGER REFERENCES links(id), 312 | source TEXT, 313 | query_source TEXT, 314 | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 315 | ); 316 | 317 | -- Indexes 318 | CREATE INDEX idx_short_code ON links(short_code); 319 | CREATE INDEX idx_user_id ON links(user_id); 320 | CREATE INDEX idx_link_id ON clicks(link_id); 321 | ``` 322 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "simplelink" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | name = "simplelink" 8 | path = "src/lib.rs" 9 | 10 | [dependencies] 11 | rust-embed = "6.8" 12 | jsonwebtoken = "9" 13 | actix-web = "4.4" 14 | actix-files = "0.6" 15 | actix-cors = "0.6" 16 | tokio = { version = "1.43", features = ["rt-multi-thread", "macros"] } 17 | sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "sqlite", "chrono"] } 18 | serde = { version = "1.0", features = ["derive"] } 19 | serde_json = "1.0" 20 | anyhow = "1.0" 21 | thiserror = "1.0" 22 | tracing = "0.1" 23 | tracing-subscriber = "0.3" 24 | uuid = { version = "1.7", features = ["v4"] } # Remove serde if not using UUID serialization 25 | base62 = "2.0" 26 | clap = { version = "4.5", features = ["derive"] } 27 | dotenv = "0.15" 28 | chrono = { version = "0.4", features = ["serde"] } 29 | regex = "1.10" 30 | lazy_static = "1.4" 31 | argon2 = "0.5.3" 32 | rand = { version = "0.8", features = ["std"] } 33 | mime_guess = "2.0.5" 34 | futures = "0.3.31" 35 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Frontend build stage 2 | FROM oven/bun:latest AS frontend-builder 3 | 4 | WORKDIR /usr/src/frontend 5 | 6 | # Copy frontend files 7 | COPY frontend/package.json ./ 8 | RUN bun install 9 | 10 | COPY frontend/ ./ 11 | 12 | # Build frontend with production configuration 13 | ARG API_URL=http://localhost:8080 14 | ENV VITE_API_URL=${API_URL} 15 | RUN bun run build 16 | 17 | # Rust build stage 18 | FROM rust:latest AS backend-builder 19 | 20 | # Install PostgreSQL client libraries and SSL dependencies 21 | RUN apt-get update && \ 22 | apt-get install -y pkg-config libssl-dev libpq-dev && \ 23 | rm -rf /var/lib/apt/lists/* 24 | 25 | WORKDIR /usr/src/app 26 | 27 | # Copy manifests first (better layer caching) 28 | COPY Cargo.toml Cargo.lock ./ 29 | 30 | # Copy source code and SQLx prepared queries 31 | COPY src/ src/ 32 | COPY migrations/ migrations/ 33 | COPY .sqlx/ .sqlx/ 34 | 35 | # Create static directory and copy frontend build 36 | COPY --from=frontend-builder /usr/src/frontend/dist/ static/ 37 | 38 | # Build the application 39 | RUN cargo build --release 40 | 41 | # Runtime stage 42 | FROM debian:bookworm-slim 43 | 44 | # Install runtime dependencies 45 | RUN apt-get update && \ 46 | apt-get install -y libpq5 ca-certificates openssl libssl3 && \ 47 | rm -rf /var/lib/apt/lists/* 48 | 49 | WORKDIR /app 50 | 51 | # Copy the binary from builder 52 | COPY --from=backend-builder /usr/src/app/target/release/simplelink /app/simplelink 53 | 54 | # Copy migrations folder for SQLx 55 | COPY --from=backend-builder /usr/src/app/migrations /app/migrations 56 | 57 | # Copy static files 58 | COPY --from=backend-builder /usr/src/app/static /app/static 59 | 60 | # Expose the port 61 | EXPOSE 8080 62 | 63 | # Set default network configuration 64 | ENV SERVER_HOST=0.0.0.0 65 | ENV SERVER_PORT=8080 66 | 67 | # Run the binary 68 | CMD ["./simplelink"] 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SimpleLink 2 | 3 | A very performant and light (2MB in memory) link shortener and tracker. Written in Rust and React and uses Postgres or SQLite. 4 | 5 | ![MainView](readme_img/mainview.jpg) 6 | 7 | ![StatsView](readme_img/statview.jpg) 8 | 9 | ## How to Run 10 | 11 | ### From Docker 12 | 13 | ```bash 14 | docker run -p 8080:8080 \ 15 | -e JWT_SECRET=change-me-in-production \ 16 | -e SIMPLELINK_USER=admin@example.com \ 17 | -e SIMPLELINK_PASS=your-secure-password \ 18 | -v simplelink_data:/data \ 19 | ghcr.io/waveringana/simplelink:v2.2 20 | ``` 21 | 22 | ### Environment Variables 23 | 24 | - `JWT_SECRET`: Required. Used for JWT token generation 25 | - `SIMPLELINK_USER`: Optional. If set along with SIMPLELINK_PASS, creates an admin user on first run 26 | - `SIMPLELINK_PASS`: Optional. Admin user password 27 | - `DATABASE_URL`: Optional. Postgres connection string. If not set, uses SQLite 28 | - `INITIAL_LINKS`: Optional. Semicolon-separated list of initial links in format "url,code;url2,code2" 29 | - `SERVER_HOST`: Optional. Default: "127.0.0.1" 30 | - `SERVER_PORT`: Optional. Default: "8080" 31 | 32 | If `SIMPLELINK_USER` and `SIMPLELINK_PASS` are not passed, an admin-setup-token is pasted to the console and as a text file in the project root. 33 | 34 | ### From Docker Compose 35 | 36 | Edit the docker-compose.yml file. It comes included with a PostgreSQL db configuration. 37 | 38 | ## Build 39 | 40 | ### From Source 41 | 42 | First configure .env.example and save it to .env 43 | 44 | ```bash 45 | git clone https://github.com/waveringana/simplelink && cd simplelink 46 | ./build.sh 47 | cargo run 48 | ``` 49 | 50 | Alternatively for a binary build: 51 | 52 | ```bash 53 | ./build.sh --binary 54 | ``` 55 | 56 | then check /target/release for the binary named `SimpleGit` 57 | 58 | ### From Docker 59 | 60 | ```bash 61 | docker build -t simplelink . 62 | docker run -p 8080:8080 \ 63 | -e JWT_SECRET=change-me-in-production \ 64 | -e SIMPLELINK_USER=admin@example.com \ 65 | -e SIMPLELINK_PASS=your-secure-password \ 66 | -v simplelink_data:/data \ 67 | simplelink 68 | ``` 69 | 70 | ### From Docker Compose 71 | 72 | Adjust the included docker-compose.yml to your liking; it includes a postgres config as well. 73 | 74 | ## Features 75 | 76 | - Support for both PostgreSQL and SQLite databases 77 | - Initial links can be configured via environment variables 78 | - Admin user can be created on first run via environment variables 79 | - Link click tracking and statistics 80 | - Lightweight and performant 81 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Default values 4 | #API_URL="http://localhost:8080" 5 | RELEASE_MODE=false 6 | BINARY_MODE=false 7 | 8 | # Parse command line arguments 9 | for arg in "$@" 10 | do 11 | case $arg in 12 | #api-domain=*) 13 | #API_URL="${arg#*=}" 14 | #shift 15 | #;; 16 | --release) 17 | RELEASE_MODE=true 18 | shift 19 | ;; 20 | --binary) 21 | BINARY_MODE=true 22 | shift 23 | ;; 24 | esac 25 | done 26 | 27 | #echo "Building project with API_URL: $API_URL" 28 | echo "Release mode: $RELEASE_MODE" 29 | 30 | # Check if cargo is installed 31 | if ! command -v cargo &> /dev/null; then 32 | echo "cargo is not installed. Please install Rust and cargo first." 33 | exit 1 34 | fi 35 | 36 | # Check if npm is installed 37 | if ! command -v npm &> /dev/null; then 38 | echo "npm is not installed. Please install Node.js and npm first." 39 | exit 1 40 | fi 41 | 42 | # Build frontend 43 | echo "Building frontend..." 44 | # Create .env file for Vite 45 | #echo "VITE_API_URL=$API_URL" > frontend/.env 46 | 47 | # Install frontend dependencies and build 48 | cd frontend 49 | npm install 50 | npm run build 51 | cd .. 52 | 53 | # Create static directory and copy frontend build 54 | mkdir -p static 55 | rm -rf static/* 56 | cp -r frontend/dist/* static/ 57 | 58 | # Build Rust project 59 | echo "Building Rust project..." 60 | if [ "$RELEASE_MODE" = true ]; then 61 | cargo build --release 62 | 63 | # Create release directory 64 | mkdir -p release 65 | 66 | # Copy only the binary to release directory 67 | cp target/release/simplelink release/ 68 | cp .env.example release/.env 69 | 70 | # Create a tar archive 71 | tar -czf release.tar.gz release/ 72 | 73 | echo "Release archive created: release.tar.gz" 74 | elif [ "$BINARY_MODE" = true ]; then 75 | cargo build --release 76 | else 77 | cargo build 78 | fi 79 | 80 | echo "Build complete!" 81 | echo "To run the project:" 82 | if [ "$RELEASE_MODE" = true ]; then 83 | echo "1. Extract release.tar.gz" 84 | echo "2. Configure .env file" 85 | echo "3. Run ./simplelink" 86 | else 87 | echo "1. Configure .env file" 88 | echo "2. Run 'cargo run'" 89 | fi 90 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | image: postgres:15-alpine 4 | container_name: shortener-db 5 | environment: 6 | POSTGRES_DB: shortener 7 | POSTGRES_USER: shortener 8 | POSTGRES_PASSWORD: shortener123 9 | ports: 10 | - "5432:5432" 11 | volumes: 12 | - shortener-data:/var/lib/postgresql/data 13 | healthcheck: 14 | test: ["CMD-SHELL", "pg_isready -U shortener"] 15 | interval: 5s 16 | timeout: 5s 17 | retries: 5 18 | networks: 19 | - shortener-network 20 | 21 | app: 22 | image: ghcr.io/waveringana/simplelink:v2.2 23 | container_name: shortener-app 24 | ports: 25 | - "8080:8080" 26 | environment: 27 | - DATABASE_URL=postgresql://shortener:shortener123@db:5432/shortener 28 | - SERVER_HOST=0.0.0.0 29 | - SERVER_PORT=8080 30 | - JWT_SECRET=change-me-in-production 31 | depends_on: 32 | db: 33 | condition: service_healthy 34 | healthcheck: 35 | test: ["CMD", "curl", "-f", "http://localhost:8080/api/health"] 36 | interval: 30s 37 | timeout: 10s 38 | retries: 3 39 | start_period: 40s 40 | networks: 41 | - shortener-network 42 | deploy: 43 | restart_policy: 44 | condition: on-failure 45 | max_attempts: 3 46 | window: 120s 47 | 48 | networks: 49 | shortener-network: 50 | driver: bridge 51 | 52 | volumes: 53 | shortener-data: 54 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | .sqlx 27 | -------------------------------------------------------------------------------- /frontend/.preludeignore: -------------------------------------------------------------------------------- 1 | bun.lock 2 | *.json 3 | *.js 4 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default tseslint.config({ 18 | languageOptions: { 19 | // other options... 20 | parserOptions: { 21 | project: ['./tsconfig.node.json', './tsconfig.app.json'], 22 | tsconfigRootDir: import.meta.dirname, 23 | }, 24 | }, 25 | }) 26 | ``` 27 | 28 | - Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` 29 | - Optionally add `...tseslint.configs.stylisticTypeChecked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: 31 | 32 | ```js 33 | // eslint.config.js 34 | import react from 'eslint-plugin-react' 35 | 36 | export default tseslint.config({ 37 | // Set the react version 38 | settings: { react: { version: '18.3' } }, 39 | plugins: { 40 | // Add the react plugin 41 | react, 42 | }, 43 | rules: { 44 | // other rules... 45 | // Enable its recommended rules 46 | ...react.configs.recommended.rules, 47 | ...react.configs['jsx-runtime'].rules, 48 | }, 49 | }) 50 | ``` 51 | -------------------------------------------------------------------------------- /frontend/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/index.css", 9 | "baseColor": "stone", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /frontend/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | SimpleLink 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@emotion/react": "^11.14.0", 14 | "@hookform/resolvers": "^3.10.0", 15 | "@icons-pack/react-simple-icons": "^11.2.0", 16 | "@mantine/core": "^7.16.1", 17 | "@mantine/form": "^7.16.1", 18 | "@mantine/hooks": "^7.16.1", 19 | "@radix-ui/react-dialog": "^1.1.5", 20 | "@radix-ui/react-dropdown-menu": "^2.1.5", 21 | "@radix-ui/react-label": "^2.1.1", 22 | "@radix-ui/react-slot": "^1.1.1", 23 | "@radix-ui/react-tabs": "^1.1.2", 24 | "@radix-ui/react-toast": "^1.2.5", 25 | "@tailwindcss/vite": "^4.0.0", 26 | "axios": "^1.7.9", 27 | "class-variance-authority": "^0.7.1", 28 | "clsx": "^2.1.1", 29 | "lucide-react": "^0.474.0", 30 | "react": "^18.3.1", 31 | "react-dom": "^18.3.1", 32 | "react-hook-form": "^7.54.2", 33 | "recharts": "^2.15.0", 34 | "tailwind-merge": "^2.6.0", 35 | "tailwindcss-animate": "^1.0.7", 36 | "zod": "^3.24.1" 37 | }, 38 | "devDependencies": { 39 | "@eslint/js": "^9.17.0", 40 | "@tailwindcss/postcss": "^4.0.0", 41 | "@types/node": "^22.10.10", 42 | "@types/react": "^18.3.18", 43 | "@types/react-dom": "^18.3.5", 44 | "@vitejs/plugin-react": "^4.3.4", 45 | "eslint": "^9.17.0", 46 | "eslint-plugin-react-hooks": "^5.0.0", 47 | "eslint-plugin-react-refresh": "^0.4.16", 48 | "globals": "^15.14.0", 49 | "postcss": "^8.5.1", 50 | "tailwindcss": "^4.0.0", 51 | "typescript": "~5.6.2", 52 | "typescript-eslint": "^8.18.2", 53 | "vite": "^6.0.5" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | '@tailwindcss/postcss': {}, 4 | }, 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { ThemeProvider } from "@/components/theme-provider" 2 | import { LinkForm } from './components/LinkForm' 3 | import { LinkList } from './components/LinkList' 4 | import { AuthForms } from './components/AuthForms' 5 | import { Footer } from './components/Footer' 6 | import { AuthProvider, useAuth } from './context/AuthContext' 7 | import { Button } from "@/components/ui/button" 8 | import { Toaster } from './components/ui/toaster' 9 | import { ModeToggle } from './components/mode-toggle' 10 | import { useState } from 'react' 11 | 12 | function AppContent() { 13 | const { user, logout } = useAuth() 14 | const [refreshCounter, setRefreshCounter] = useState(0) 15 | 16 | const handleLinkCreated = () => { 17 | setRefreshCounter(prev => prev + 1) 18 | } 19 | 20 | return ( 21 |
22 |
23 |
24 |

SimpleLink

25 |
26 | {user ? ( 27 | <> 28 | Welcome, {user.email} 29 | 32 | 33 | ) : ( 34 | A link shortening service 35 | )} 36 | 37 |
38 |
39 |
40 |
41 |
42 |
43 | {user ? ( 44 | <> 45 | 46 | 47 | 48 | ) : ( 49 | 50 | )} 51 |
52 |
53 |
54 |
55 |
56 | ) 57 | } 58 | 59 | function App() { 60 | return ( 61 | 62 | 63 | 64 | 65 | 66 | 67 | ) 68 | } 69 | 70 | export default App -------------------------------------------------------------------------------- /frontend/src/api/client.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { CreateLinkRequest, Link, AuthResponse, ClickStats, SourceStats } from '../types/api'; 3 | 4 | // Create axios instance with default config 5 | const api = axios.create({ 6 | baseURL: '/api', 7 | }); 8 | 9 | // Add a request interceptor to add the auth token to all requests 10 | api.interceptors.request.use((config) => { 11 | const token = localStorage.getItem('token'); 12 | if (token) { 13 | config.headers.Authorization = `Bearer ${token}`; 14 | } 15 | return config; 16 | }); 17 | 18 | api.interceptors.response.use( 19 | (response) => response, 20 | (error) => { 21 | if (error.response?.status === 401) { 22 | localStorage.removeItem('token'); 23 | localStorage.removeItem('user'); 24 | 25 | window.dispatchEvent(new Event('unauthorized')); 26 | } 27 | return Promise.reject(error); 28 | } 29 | ); 30 | 31 | 32 | // Auth endpoints 33 | export const login = async (email: string, password: string) => { 34 | const response = await api.post('/auth/login', { 35 | email, 36 | password, 37 | }); 38 | return response.data; 39 | }; 40 | 41 | export const register = async (email: string, password: string, adminToken: string) => { 42 | const response = await api.post('/auth/register', { 43 | email, 44 | password, 45 | admin_token: adminToken, 46 | }); 47 | return response.data; 48 | }; 49 | 50 | // Protected endpoints 51 | export const createShortLink = async (data: CreateLinkRequest) => { 52 | const response = await api.post('/shorten', data); 53 | return response.data; 54 | }; 55 | 56 | export const getAllLinks = async () => { 57 | const response = await api.get('/links'); 58 | return response.data; 59 | }; 60 | 61 | export const editLink = async (id: number, data: Partial) => { 62 | const response = await api.patch(`/links/${id}`, data); 63 | return response.data; 64 | }; 65 | 66 | 67 | export const deleteLink = async (id: number) => { 68 | await api.delete(`/links/${id}`); 69 | }; 70 | 71 | export const getLinkClickStats = async (id: number) => { 72 | try { 73 | const response = await api.get(`/links/${id}/clicks`); 74 | return response.data; 75 | } catch (error) { 76 | console.error('Error fetching click stats:', error); 77 | throw error; 78 | } 79 | }; 80 | 81 | export const getLinkSourceStats = async (id: number) => { 82 | try { 83 | const response = await api.get(`/links/${id}/sources`); 84 | return response.data; 85 | } catch (error) { 86 | console.error('Error fetching source stats:', error); 87 | throw error; 88 | } 89 | }; 90 | 91 | 92 | export const checkFirstUser = async () => { 93 | const response = await api.get<{ isFirstUser: boolean }>('/auth/check-first-user'); 94 | return response.data.isFirstUser; 95 | }; 96 | 97 | export { api }; -------------------------------------------------------------------------------- /frontend/src/components/AuthForms.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { useForm } from 'react-hook-form' 3 | import { z } from 'zod' 4 | import { zodResolver } from '@hookform/resolvers/zod' 5 | import { useAuth } from '../context/AuthContext' 6 | import { Button } from '@/components/ui/button' 7 | import { Input } from '@/components/ui/input' 8 | import { Card } from '@/components/ui/card' 9 | import { 10 | Form, 11 | FormControl, 12 | FormField, 13 | FormItem, 14 | FormLabel, 15 | FormMessage, 16 | } from '@/components/ui/form' 17 | import { useToast } from '@/hooks/use-toast' 18 | import { checkFirstUser } from '../api/client' 19 | 20 | const formSchema = z.object({ 21 | email: z.string().email('Invalid email address'), 22 | password: z.string().min(6, 'Password must be at least 6 characters long'), 23 | adminToken: z.string().optional(), 24 | }) 25 | 26 | type FormValues = z.infer 27 | 28 | export function AuthForms() { 29 | const [isFirstUser, setIsFirstUser] = useState(null) 30 | const { login, register } = useAuth() 31 | const { toast } = useToast() 32 | 33 | const form = useForm({ 34 | resolver: zodResolver(formSchema), 35 | defaultValues: { 36 | email: '', 37 | password: '', 38 | adminToken: '', 39 | }, 40 | }) 41 | 42 | useEffect(() => { 43 | const init = async () => { 44 | try { 45 | const isFirst = await checkFirstUser() 46 | setIsFirstUser(isFirst) 47 | } catch (err) { 48 | console.error('Error checking first user:', err) 49 | setIsFirstUser(false) 50 | } 51 | } 52 | 53 | init() 54 | }, []) 55 | 56 | const onSubmit = async (values: FormValues) => { 57 | try { 58 | if (isFirstUser) { 59 | await register(values.email, values.password, values.adminToken || '') 60 | } else { 61 | await login(values.email, values.password) 62 | } 63 | form.reset() 64 | } catch (err: any) { 65 | toast({ 66 | variant: 'destructive', 67 | title: 'Error', 68 | description: err.response?.data || 'An error occurred', 69 | }) 70 | } 71 | } 72 | 73 | if (isFirstUser === null) { 74 | return
Loading...
75 | } 76 | 77 | return ( 78 | 79 |
80 |

81 | {isFirstUser ? 'Create Admin Account' : 'Login'} 82 |

83 |

84 | {isFirstUser 85 | ? 'Set up your admin account to get started' 86 | : 'Welcome back! Please login to your account'} 87 |

88 |
89 | 90 |
91 | 92 | ( 96 | 97 | Email 98 | 99 | 100 | 101 | 102 | 103 | )} 104 | /> 105 | 106 | ( 110 | 111 | Password 112 | 113 | 114 | 115 | 116 | 117 | )} 118 | /> 119 | 120 | {isFirstUser && ( 121 | ( 125 | 126 | Admin Setup Token 127 | 128 | 129 | 130 | 131 | 132 | )} 133 | /> 134 | )} 135 | 136 | 139 | 140 | 141 |
142 | ) 143 | } 144 | -------------------------------------------------------------------------------- /frontend/src/components/EditModal.tsx: -------------------------------------------------------------------------------- 1 | // src/components/EditModal.tsx 2 | import { useState } from 'react'; 3 | import { useForm } from 'react-hook-form'; 4 | import { zodResolver } from '@hookform/resolvers/zod'; 5 | import * as z from 'zod'; 6 | import { Link } from '../types/api'; 7 | import { editLink } from '../api/client'; 8 | import { useToast } from '@/hooks/use-toast'; 9 | import { 10 | Dialog, 11 | DialogContent, 12 | DialogHeader, 13 | DialogTitle, 14 | DialogFooter, 15 | } from '@/components/ui/dialog'; 16 | import { Button } from '@/components/ui/button'; 17 | import { Input } from '@/components/ui/input'; 18 | import { 19 | Form, 20 | FormControl, 21 | FormField, 22 | FormItem, 23 | FormLabel, 24 | FormMessage, 25 | } from '@/components/ui/form'; 26 | 27 | const formSchema = z.object({ 28 | url: z 29 | .string() 30 | .min(1, 'URL is required') 31 | .url('Must be a valid URL') 32 | .refine((val) => val.startsWith('http://') || val.startsWith('https://'), { 33 | message: 'URL must start with http:// or https://', 34 | }), 35 | custom_code: z 36 | .string() 37 | .regex(/^[a-zA-Z0-9_-]{1,32}$/, { 38 | message: 39 | 'Custom code must be 1-32 characters and contain only letters, numbers, underscores, and hyphens', 40 | }) 41 | .optional(), 42 | }); 43 | 44 | interface EditModalProps { 45 | isOpen: boolean; 46 | onClose: () => void; 47 | link: Link; 48 | onSuccess: () => void; 49 | } 50 | 51 | export function EditModal({ isOpen, onClose, link, onSuccess }: EditModalProps) { 52 | const [loading, setLoading] = useState(false); 53 | const { toast } = useToast(); 54 | 55 | const form = useForm>({ 56 | resolver: zodResolver(formSchema), 57 | defaultValues: { 58 | url: link.original_url, 59 | custom_code: link.short_code, 60 | }, 61 | }); 62 | 63 | const onSubmit = async (values: z.infer) => { 64 | try { 65 | setLoading(true); 66 | await editLink(link.id, values); 67 | toast({ 68 | description: 'Link updated successfully', 69 | }); 70 | onSuccess(); 71 | onClose(); 72 | } catch (err: unknown) { 73 | const error = err as { response?: { data?: { error?: string } } }; 74 | toast({ 75 | variant: 'destructive', 76 | title: 'Error', 77 | description: error.response?.data?.error || 'Failed to update link', 78 | }); 79 | } finally { 80 | setLoading(false); 81 | } 82 | }; 83 | 84 | return ( 85 | 86 | 87 | 88 | Edit Link 89 | 90 | 91 |
92 | 93 | ( 97 | 98 | Destination URL 99 | 100 | 101 | 102 | 103 | 104 | )} 105 | /> 106 | 107 | ( 111 | 112 | Short Code 113 | 114 | 115 | 116 | 117 | 118 | )} 119 | /> 120 | 121 | 122 | 130 | 133 | 134 | 135 | 136 |
137 |
138 | ); 139 | } 140 | -------------------------------------------------------------------------------- /frontend/src/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { SiGithub, SiBluesky } from "@icons-pack/react-simple-icons" 2 | import { Button } from "@/components/ui/button" 3 | import { useState } from 'react' 4 | import { PrivacyModal } from './PrivacyModal' 5 | 6 | export function Footer() { 7 | const [privacyModalOpen, setPrivacyModalOpen] = useState(false) 8 | 9 | const handlePrivacyModalOpen = () => { 10 | setPrivacyModalOpen(true) 11 | } 12 | 13 | const handlePrivacyModalClose = () => { 14 | setPrivacyModalOpen(false) 15 | } 16 | 17 | return ( 18 | 53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /frontend/src/components/LinkForm.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { useForm } from 'react-hook-form' 3 | import { zodResolver } from '@hookform/resolvers/zod' 4 | import * as z from 'zod' 5 | import { CreateLinkRequest } from '../types/api' 6 | import { createShortLink } from '../api/client' 7 | import { Button } from "@/components/ui/button" 8 | import { Input } from "@/components/ui/input" 9 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" 10 | import { LinkIcon } from "lucide-react" 11 | import { 12 | Form, 13 | FormControl, 14 | FormField, 15 | FormLabel, 16 | FormMessage, 17 | } from "@/components/ui/form" 18 | import { useToast } from "@/hooks/use-toast" 19 | 20 | const formSchema = z.object({ 21 | url: z.string() 22 | .min(1, 'URL is required') 23 | .url('Must be a valid URL') 24 | .refine(val => val.startsWith('http://') || val.startsWith('https://'), { 25 | message: 'URL must start with http:// or https://' 26 | }), 27 | custom_code: z.string() 28 | .regex(/^[a-zA-Z0-9_-]{0,32}$/, 'Custom code must contain only letters, numbers, underscores, and hyphens') 29 | .optional() 30 | }) 31 | 32 | interface LinkFormProps { 33 | onSuccess: () => void; 34 | } 35 | 36 | export function LinkForm({ onSuccess }: LinkFormProps) { 37 | const [loading, setLoading] = useState(false) 38 | const { toast } = useToast() 39 | 40 | const form = useForm>({ 41 | resolver: zodResolver(formSchema), 42 | defaultValues: { 43 | url: '', 44 | custom_code: '', 45 | }, 46 | }) 47 | 48 | const onSubmit = async (values: z.infer) => { 49 | try { 50 | setLoading(true) 51 | await createShortLink(values as CreateLinkRequest) 52 | form.reset() 53 | onSuccess() // Call the onSuccess callback to trigger refresh 54 | toast({ 55 | description: "Short link created successfully", 56 | }) 57 | } catch (err: any) { 58 | toast({ 59 | variant: "destructive", 60 | title: "Error", 61 | description: err.response?.data?.error || 'An error occurred', 62 | }) 63 | } finally { 64 | setLoading(false) 65 | } 66 | } 67 | 68 | return ( 69 | 70 | 71 | Create Short Link 72 | Enter a URL to generate a shortened link 73 | 74 | 75 |
76 | 77 | ( 81 |
82 | URL 83 | 84 |
85 | 86 | 87 |
88 |
89 | 90 |
91 | )} 92 | /> 93 | 94 | ( 98 |
99 | Custom Code (optional) 100 | 101 | 102 | 103 | 104 |
105 | )} 106 | /> 107 | 108 | 111 | 112 | 113 |
114 |
115 | ) 116 | } -------------------------------------------------------------------------------- /frontend/src/components/LinkList.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react' 2 | import { Link } from '../types/api' 3 | import { getAllLinks, deleteLink } from '../api/client' 4 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" 5 | import { 6 | Table, 7 | TableBody, 8 | TableCell, 9 | TableHead, 10 | TableHeader, 11 | TableRow, 12 | } from "@/components/ui/table" 13 | import { Button } from "@/components/ui/button" 14 | import { useToast } from "@/hooks/use-toast" 15 | import { Copy, Trash2, BarChart2, Pencil } from "lucide-react" 16 | import { 17 | Dialog, 18 | DialogContent, 19 | DialogHeader, 20 | DialogTitle, 21 | DialogDescription, 22 | DialogFooter, 23 | } from "@/components/ui/dialog" 24 | 25 | import { StatisticsModal } from "./StatisticsModal" 26 | import { EditModal } from './EditModal' 27 | 28 | interface LinkListProps { 29 | refresh?: number; 30 | } 31 | 32 | export function LinkList({ refresh = 0 }: LinkListProps) { 33 | const [links, setLinks] = useState([]) 34 | const [loading, setLoading] = useState(true) 35 | const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; linkId: number | null }>({ 36 | isOpen: false, 37 | linkId: null, 38 | }) 39 | const [statsModal, setStatsModal] = useState<{ isOpen: boolean; linkId: number | null }>({ 40 | isOpen: false, 41 | linkId: null, 42 | }); 43 | const [editModal, setEditModal] = useState<{ isOpen: boolean; link: Link | null }>({ 44 | isOpen: false, 45 | link: null, 46 | }); 47 | const { toast } = useToast() 48 | 49 | const fetchLinks = useCallback(async () => { 50 | try { 51 | setLoading(true) 52 | const data = await getAllLinks() 53 | setLinks(data) 54 | } catch (err: unknown) { 55 | const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; 56 | toast({ 57 | title: "Error", 58 | description: `Failed to load links: ${errorMessage}`, 59 | variant: "destructive", 60 | }) 61 | } finally { 62 | setLoading(false) 63 | } 64 | }, [toast, setLinks, setLoading]) 65 | 66 | useEffect(() => { 67 | fetchLinks() 68 | }, [fetchLinks, refresh]) // Re-fetch when refresh counter changes 69 | 70 | const handleDelete = async () => { 71 | if (!deleteModal.linkId) return 72 | 73 | try { 74 | await deleteLink(deleteModal.linkId) 75 | await fetchLinks() 76 | setDeleteModal({ isOpen: false, linkId: null }) 77 | toast({ 78 | description: "Link deleted successfully", 79 | }) 80 | } catch (err: unknown) { 81 | const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; 82 | toast({ 83 | title: "Error", 84 | description: `Failed to delete link: ${errorMessage}`, 85 | variant: "destructive", 86 | }) 87 | } 88 | } 89 | 90 | const handleCopy = (shortCode: string) => { 91 | // Use import.meta.env.VITE_BASE_URL or fall back to window.location.origin 92 | const baseUrl = window.location.origin 93 | navigator.clipboard.writeText(`${baseUrl}/${shortCode}`) 94 | toast({ 95 | description: ( 96 | <> 97 | Link copied to clipboard 98 |
99 | You can add ?source=TextHere to the end of the link to track the source of clicks 100 | 101 | ), 102 | }) 103 | } 104 | 105 | if (loading && !links.length) { 106 | return
Loading...
107 | } 108 | 109 | return ( 110 | <> 111 | setDeleteModal({ isOpen: open, linkId: null })}> 112 | 113 | 114 | Delete Link 115 | 116 | Are you sure you want to delete this link? This action cannot be undone. 117 | 118 | 119 | 120 | 123 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | Your Links 133 | Manage and track your shortened links 134 | 135 | 136 |
137 | 138 | 139 | 140 | 141 | Short Code 142 | Original URL 143 | Clicks 144 | Created 145 | Actions 146 | 147 | 148 | 149 | {links.map((link) => ( 150 | 151 | {link.short_code} 152 | 153 | {link.original_url} 154 | 155 | {link.clicks} 156 | 157 | {new Date(link.created_at).toLocaleDateString()} 158 | 159 | 160 |
161 | 170 | 179 | 188 | 197 |
198 |
199 |
200 | ))} 201 |
202 |
203 |
204 |
205 |
206 | setStatsModal({ isOpen: false, linkId: null })} 209 | linkId={statsModal.linkId!} 210 | /> 211 | {editModal.link && ( 212 | setEditModal({ isOpen: false, link: null })} 215 | link={editModal.link} 216 | onSuccess={fetchLinks} 217 | /> 218 | )} 219 | 220 | ) 221 | } -------------------------------------------------------------------------------- /frontend/src/components/PrivacyModal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Dialog, 3 | DialogContent, 4 | DialogDescription, 5 | DialogFooter, 6 | DialogHeader, 7 | DialogTitle, 8 | } from "@/components/ui/dialog" 9 | 10 | import { Button } from "@/components/ui/button" 11 | 12 | interface PrivacyModalProps { 13 | isOpen: boolean; 14 | onClose: () => void; 15 | } 16 | 17 | export function PrivacyModal({ isOpen, onClose }: PrivacyModalProps) { 18 | return ( 19 | 20 | 21 | 22 | Privacy Policy 23 | 24 | Simplelink's data collection and usage policies 25 | 26 | 27 |
28 |

Simplelink shortens URLs and tracks only two pieces of information: the time each link is clicked and the source of the link through a ?source= query tag. We do not collect any personal information such as IP addresses or any other data.

29 |
30 | 31 | 34 | 35 |
36 |
37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /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 { toast } from "@/hooks/use-toast"; 13 | import { useState, useEffect, useMemo } from "react"; 14 | 15 | import { getLinkClickStats, getLinkSourceStats } from "../api/client"; 16 | import { ClickStats, SourceStats } from "../types/api"; 17 | 18 | interface StatisticsModalProps { 19 | isOpen: boolean; 20 | onClose: () => void; 21 | linkId: number; 22 | } 23 | 24 | interface EnhancedClickStats extends ClickStats { 25 | sources?: { source: string; count: number }[]; 26 | } 27 | 28 | const CustomTooltip = ({ 29 | active, 30 | payload, 31 | label, 32 | }: { 33 | active?: boolean; 34 | payload?: { value: number; payload: EnhancedClickStats }[]; 35 | label?: string; 36 | }) => { 37 | if (active && payload && payload.length > 0) { 38 | const data = payload[0].payload; 39 | return ( 40 |
41 |

{label}

42 |

Clicks: {data.clicks}

43 | {data.sources && data.sources.length > 0 && ( 44 |
45 |

Sources:

46 |
    47 | {data.sources.map((source: { source: string; count: number }) => ( 48 |
  • 49 | {source.source}: {source.count} 50 |
  • 51 | ))} 52 |
53 |
54 | )} 55 |
56 | ); 57 | } 58 | return null; 59 | }; 60 | 61 | export function StatisticsModal({ isOpen, onClose, linkId }: StatisticsModalProps) { 62 | const [clicksOverTime, setClicksOverTime] = useState([]); 63 | const [sourcesData, setSourcesData] = useState([]); 64 | const [loading, setLoading] = useState(true); 65 | 66 | useEffect(() => { 67 | if (isOpen && linkId) { 68 | const fetchData = async () => { 69 | try { 70 | setLoading(true); 71 | const [clicksData, sourcesData] = await Promise.all([ 72 | getLinkClickStats(linkId), 73 | getLinkSourceStats(linkId), 74 | ]); 75 | 76 | // Enhance clicks data with source information 77 | const enhancedClicksData = clicksData.map((clickData) => ({ 78 | ...clickData, 79 | sources: sourcesData.filter((source) => source.date === clickData.date), 80 | })); 81 | 82 | setClicksOverTime(enhancedClicksData); 83 | setSourcesData(sourcesData); 84 | } catch (error: unknown) { 85 | console.error("Failed to fetch statistics:", error); 86 | toast({ 87 | variant: "destructive", 88 | title: "Error", 89 | description: error instanceof Error ? error.message : "Failed to load statistics", 90 | }); 91 | } finally { 92 | setLoading(false); 93 | } 94 | }; 95 | 96 | fetchData(); 97 | } 98 | }, [isOpen, linkId]); 99 | 100 | const aggregatedSources = useMemo(() => { 101 | const sourceMap = sourcesData.reduce>( 102 | (acc, { source, count }) => ({ 103 | ...acc, 104 | [source]: (acc[source] || 0) + count 105 | }), 106 | {} 107 | ); 108 | 109 | return Object.entries(sourceMap) 110 | .map(([source, count]) => ({ source, count })) 111 | .sort((a, b) => b.count - a.count); 112 | }, [sourcesData]); 113 | 114 | return ( 115 | 116 | 117 | 118 | Link Statistics 119 | 120 | 121 | {loading ? ( 122 |
Loading...
123 | ) : ( 124 |
125 | 126 | 127 | Clicks Over Time 128 | 129 | 130 |
131 | 132 | 133 | 134 | 135 | 136 | } /> 137 | 143 | 144 | 145 |
146 |
147 |
148 | 149 | 150 | 151 | Top Sources 152 | 153 | 154 |
    155 | {aggregatedSources.map((source, index) => ( 156 |
  • 160 | 161 | 162 | {index + 1}. 163 | 164 | {source.source} 165 | 166 | {source.count} clicks 167 |
  • 168 | ))} 169 |
170 |
171 |
172 |
173 | )} 174 |
175 |
176 | ); 177 | } 178 | -------------------------------------------------------------------------------- /frontend/src/components/mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | import { Moon, Sun } from "lucide-react" 2 | 3 | import { Button } from "@/components/ui/button" 4 | import { 5 | DropdownMenu, 6 | DropdownMenuContent, 7 | DropdownMenuItem, 8 | DropdownMenuTrigger, 9 | } from "@/components/ui/dropdown-menu" 10 | import { useTheme } from "@/components/theme-provider" 11 | 12 | export function ModeToggle() { 13 | const { setTheme } = useTheme() 14 | 15 | return ( 16 | 17 | 18 | 23 | 24 | 25 | setTheme("light")}> 26 | Light 27 | 28 | setTheme("dark")}> 29 | Dark 30 | 31 | setTheme("system")}> 32 | System 33 | 34 | 35 | 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /frontend/src/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useEffect, useState } from "react" 2 | 3 | type Theme = "dark" | "light" | "system" 4 | 5 | type ThemeProviderProps = { 6 | children: React.ReactNode 7 | defaultTheme?: Theme 8 | storageKey?: string 9 | } 10 | 11 | type ThemeProviderState = { 12 | theme: Theme 13 | setTheme: (theme: Theme) => void 14 | } 15 | 16 | const initialState: ThemeProviderState = { 17 | theme: "system", 18 | setTheme: () => null, 19 | } 20 | 21 | const ThemeProviderContext = createContext(initialState) 22 | 23 | export function ThemeProvider({ 24 | children, 25 | defaultTheme = "system", 26 | storageKey = "vite-ui-theme", 27 | ...props 28 | }: ThemeProviderProps) { 29 | const [theme, setTheme] = useState( 30 | () => (localStorage.getItem(storageKey) as Theme) || defaultTheme 31 | ) 32 | 33 | useEffect(() => { 34 | const root = window.document.documentElement 35 | 36 | root.classList.remove("light", "dark") 37 | 38 | if (theme === "system") { 39 | const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") 40 | .matches 41 | ? "dark" 42 | : "light" 43 | 44 | root.classList.add(systemTheme) 45 | return 46 | } 47 | 48 | root.classList.add(theme) 49 | }, [theme]) 50 | 51 | const value = { 52 | theme, 53 | setTheme: (theme: Theme) => { 54 | localStorage.setItem(storageKey, theme) 55 | setTheme(theme) 56 | }, 57 | } 58 | 59 | return ( 60 | 61 | {children} 62 | 63 | ) 64 | } 65 | 66 | export const useTheme = () => { 67 | const context = useContext(ThemeProviderContext) 68 | 69 | if (context === undefined) 70 | throw new Error("useTheme must be used within a ThemeProvider") 71 | 72 | return context 73 | } 74 | -------------------------------------------------------------------------------- /frontend/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /frontend/src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLDivElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |
41 | )) 42 | CardTitle.displayName = "CardTitle" 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLDivElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |
53 | )) 54 | CardDescription.displayName = "CardDescription" 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |
61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | -------------------------------------------------------------------------------- /frontend/src/components/ui/container.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | export function Container({ className, ...props }: React.HTMLAttributes) { 4 | return ( 5 |
12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as DialogPrimitive from "@radix-ui/react-dialog" 3 | import { X } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Dialog = DialogPrimitive.Root 8 | 9 | const DialogTrigger = DialogPrimitive.Trigger 10 | 11 | const DialogPortal = DialogPrimitive.Portal 12 | 13 | const DialogClose = DialogPrimitive.Close 14 | 15 | const DialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )) 28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 29 | 30 | const DialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, children, ...props }, ref) => ( 34 | 35 | 36 | 44 | {children} 45 | 46 | 47 | Close 48 | 49 | 50 | 51 | )) 52 | DialogContent.displayName = DialogPrimitive.Content.displayName 53 | 54 | const DialogHeader = ({ 55 | className, 56 | ...props 57 | }: React.HTMLAttributes) => ( 58 |
65 | ) 66 | DialogHeader.displayName = "DialogHeader" 67 | 68 | const DialogFooter = ({ 69 | className, 70 | ...props 71 | }: React.HTMLAttributes) => ( 72 |
79 | ) 80 | DialogFooter.displayName = "DialogFooter" 81 | 82 | const DialogTitle = React.forwardRef< 83 | React.ElementRef, 84 | React.ComponentPropsWithoutRef 85 | >(({ className, ...props }, ref) => ( 86 | 94 | )) 95 | DialogTitle.displayName = DialogPrimitive.Title.displayName 96 | 97 | const DialogDescription = React.forwardRef< 98 | React.ElementRef, 99 | React.ComponentPropsWithoutRef 100 | >(({ className, ...props }, ref) => ( 101 | 106 | )) 107 | DialogDescription.displayName = DialogPrimitive.Description.displayName 108 | 109 | export { 110 | Dialog, 111 | DialogPortal, 112 | DialogOverlay, 113 | DialogTrigger, 114 | DialogClose, 115 | DialogContent, 116 | DialogHeader, 117 | DialogFooter, 118 | DialogTitle, 119 | DialogDescription, 120 | } 121 | -------------------------------------------------------------------------------- /frontend/src/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 3 | import { Check, ChevronRight, Circle } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const DropdownMenu = DropdownMenuPrimitive.Root 8 | 9 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 10 | 11 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 12 | 13 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 14 | 15 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 16 | 17 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 18 | 19 | const DropdownMenuSubTrigger = React.forwardRef< 20 | React.ElementRef, 21 | React.ComponentPropsWithoutRef & { 22 | inset?: boolean 23 | } 24 | >(({ className, inset, children, ...props }, ref) => ( 25 | 34 | {children} 35 | 36 | 37 | )) 38 | DropdownMenuSubTrigger.displayName = 39 | DropdownMenuPrimitive.SubTrigger.displayName 40 | 41 | const DropdownMenuSubContent = React.forwardRef< 42 | React.ElementRef, 43 | React.ComponentPropsWithoutRef 44 | >(({ className, ...props }, ref) => ( 45 | 53 | )) 54 | DropdownMenuSubContent.displayName = 55 | DropdownMenuPrimitive.SubContent.displayName 56 | 57 | const DropdownMenuContent = React.forwardRef< 58 | React.ElementRef, 59 | React.ComponentPropsWithoutRef 60 | >(({ className, sideOffset = 4, ...props }, ref) => ( 61 | 62 | 72 | 73 | )) 74 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 75 | 76 | const DropdownMenuItem = React.forwardRef< 77 | React.ElementRef, 78 | React.ComponentPropsWithoutRef & { 79 | inset?: boolean 80 | } 81 | >(({ className, inset, ...props }, ref) => ( 82 | svg]:size-4 [&>svg]:shrink-0", 86 | inset && "pl-8", 87 | className 88 | )} 89 | {...props} 90 | /> 91 | )) 92 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 93 | 94 | const DropdownMenuCheckboxItem = React.forwardRef< 95 | React.ElementRef, 96 | React.ComponentPropsWithoutRef 97 | >(({ className, children, checked, ...props }, ref) => ( 98 | 107 | 108 | 109 | 110 | 111 | 112 | {children} 113 | 114 | )) 115 | DropdownMenuCheckboxItem.displayName = 116 | DropdownMenuPrimitive.CheckboxItem.displayName 117 | 118 | const DropdownMenuRadioItem = React.forwardRef< 119 | React.ElementRef, 120 | React.ComponentPropsWithoutRef 121 | >(({ className, children, ...props }, ref) => ( 122 | 130 | 131 | 132 | 133 | 134 | 135 | {children} 136 | 137 | )) 138 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 139 | 140 | const DropdownMenuLabel = React.forwardRef< 141 | React.ElementRef, 142 | React.ComponentPropsWithoutRef & { 143 | inset?: boolean 144 | } 145 | >(({ className, inset, ...props }, ref) => ( 146 | 155 | )) 156 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 157 | 158 | const DropdownMenuSeparator = React.forwardRef< 159 | React.ElementRef, 160 | React.ComponentPropsWithoutRef 161 | >(({ className, ...props }, ref) => ( 162 | 167 | )) 168 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 169 | 170 | const DropdownMenuShortcut = ({ 171 | className, 172 | ...props 173 | }: React.HTMLAttributes) => { 174 | return ( 175 | 179 | ) 180 | } 181 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" 182 | 183 | export { 184 | DropdownMenu, 185 | DropdownMenuTrigger, 186 | DropdownMenuContent, 187 | DropdownMenuItem, 188 | DropdownMenuCheckboxItem, 189 | DropdownMenuRadioItem, 190 | DropdownMenuLabel, 191 | DropdownMenuSeparator, 192 | DropdownMenuShortcut, 193 | DropdownMenuGroup, 194 | DropdownMenuPortal, 195 | DropdownMenuSub, 196 | DropdownMenuSubContent, 197 | DropdownMenuSubTrigger, 198 | DropdownMenuRadioGroup, 199 | } 200 | -------------------------------------------------------------------------------- /frontend/src/components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { Slot } from "@radix-ui/react-slot" 6 | import { 7 | Controller, 8 | ControllerProps, 9 | FieldPath, 10 | FieldValues, 11 | FormProvider, 12 | useFormContext, 13 | } from "react-hook-form" 14 | 15 | import { cn } from "@/lib/utils" 16 | import { Label } from "@/components/ui/label" 17 | 18 | const Form = FormProvider 19 | 20 | type FormFieldContextValue< 21 | TFieldValues extends FieldValues = FieldValues, 22 | TName extends FieldPath = FieldPath 23 | > = { 24 | name: TName 25 | } 26 | 27 | const FormFieldContext = React.createContext( 28 | {} as FormFieldContextValue 29 | ) 30 | 31 | const FormField = < 32 | TFieldValues extends FieldValues = FieldValues, 33 | TName extends FieldPath = FieldPath 34 | >({ 35 | ...props 36 | }: ControllerProps) => { 37 | return ( 38 | 39 | 40 | 41 | ) 42 | } 43 | 44 | const useFormField = () => { 45 | const fieldContext = React.useContext(FormFieldContext) 46 | const itemContext = React.useContext(FormItemContext) 47 | const { getFieldState, formState } = useFormContext() 48 | 49 | const fieldState = getFieldState(fieldContext.name, formState) 50 | 51 | if (!fieldContext) { 52 | throw new Error("useFormField should be used within ") 53 | } 54 | 55 | const { id } = itemContext 56 | 57 | return { 58 | id, 59 | name: fieldContext.name, 60 | formItemId: `${id}-form-item`, 61 | formDescriptionId: `${id}-form-item-description`, 62 | formMessageId: `${id}-form-item-message`, 63 | ...fieldState, 64 | } 65 | } 66 | 67 | type FormItemContextValue = { 68 | id: string 69 | } 70 | 71 | const FormItemContext = React.createContext( 72 | {} as FormItemContextValue 73 | ) 74 | 75 | const FormItem = React.forwardRef< 76 | HTMLDivElement, 77 | React.HTMLAttributes 78 | >(({ className, ...props }, ref) => { 79 | const id = React.useId() 80 | 81 | return ( 82 | 83 |
84 | 85 | ) 86 | }) 87 | FormItem.displayName = "FormItem" 88 | 89 | const FormLabel = React.forwardRef< 90 | React.ElementRef, 91 | React.ComponentPropsWithoutRef 92 | >(({ className, ...props }, ref) => { 93 | const { error, formItemId } = useFormField() 94 | 95 | return ( 96 |