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

Compare changes

Choose any two refs to compare.

Changed files
+5026 -1344
.github
workflows
.sqlx
frontend
migrations
readme_img
src
test
+5
.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
+70
.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 }}
+10
.gitignore
··· 2 2 **/node_modules 3 3 node_modules 4 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*
+3
.preludeignore
··· 1 + .sqlx 2 + .env 3 + .env.*
+35
.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 + }
+22
.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 + }
+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 + }
+23
.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 + }
+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 + }
+14
.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 + }
+14
.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 + }
+34
.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 + }
+20
.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 + }
+196 -242
Cargo.lock
··· 3 3 version = 4 4 4 5 5 [[package]] 6 - name = "SimpleLink" 7 - version = "0.1.0" 8 - dependencies = [ 9 - "actix-cors", 10 - "actix-web", 11 - "anyhow", 12 - "argon2", 13 - "base62", 14 - "chrono", 15 - "clap", 16 - "dotenv", 17 - "jsonwebtoken", 18 - "lazy_static", 19 - "regex", 20 - "serde", 21 - "serde_json", 22 - "sqlx", 23 - "thiserror 1.0.69", 24 - "tokio", 25 - "tracing", 26 - "tracing-subscriber", 27 - "uuid", 28 - ] 29 - 30 - [[package]] 31 6 name = "actix-codec" 32 7 version = "0.5.2" 33 8 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 57 32 "log", 58 33 "once_cell", 59 34 "smallvec", 35 + ] 36 + 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", 60 58 ] 61 59 62 60 [[package]] ··· 609 607 ] 610 608 611 609 [[package]] 612 - name = "core-foundation" 613 - version = "0.9.4" 614 - source = "registry+https://github.com/rust-lang/crates.io-index" 615 - checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" 616 - dependencies = [ 617 - "core-foundation-sys", 618 - "libc", 619 - ] 620 - 621 - [[package]] 622 610 name = "core-foundation-sys" 623 611 version = "0.8.7" 624 612 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 840 828 checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 841 829 842 830 [[package]] 843 - name = "foreign-types" 844 - version = "0.3.2" 845 - source = "registry+https://github.com/rust-lang/crates.io-index" 846 - checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" 847 - dependencies = [ 848 - "foreign-types-shared", 849 - ] 850 - 851 - [[package]] 852 - name = "foreign-types-shared" 853 - version = "0.1.1" 831 + name = "foldhash" 832 + version = "0.1.4" 854 833 source = "registry+https://github.com/rust-lang/crates.io-index" 855 - checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" 834 + checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" 856 835 857 836 [[package]] 858 837 name = "form_urlencoded" ··· 864 843 ] 865 844 866 845 [[package]] 846 + name = "futures" 847 + version = "0.3.31" 848 + source = "registry+https://github.com/rust-lang/crates.io-index" 849 + checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" 850 + dependencies = [ 851 + "futures-channel", 852 + "futures-core", 853 + "futures-executor", 854 + "futures-io", 855 + "futures-sink", 856 + "futures-task", 857 + "futures-util", 858 + ] 859 + 860 + [[package]] 867 861 name = "futures-channel" 868 862 version = "0.3.31" 869 863 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 908 902 checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" 909 903 910 904 [[package]] 905 + name = "futures-macro" 906 + version = "0.3.31" 907 + source = "registry+https://github.com/rust-lang/crates.io-index" 908 + checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" 909 + dependencies = [ 910 + "proc-macro2", 911 + "quote", 912 + "syn", 913 + ] 914 + 915 + [[package]] 911 916 name = "futures-sink" 912 917 version = "0.3.31" 913 918 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 925 930 source = "registry+https://github.com/rust-lang/crates.io-index" 926 931 checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" 927 932 dependencies = [ 933 + "futures-channel", 928 934 "futures-core", 929 935 "futures-io", 936 + "futures-macro", 930 937 "futures-sink", 931 938 "futures-task", 932 939 "memchr", ··· 985 992 986 993 [[package]] 987 994 name = "hashbrown" 988 - version = "0.14.5" 995 + version = "0.15.2" 989 996 source = "registry+https://github.com/rust-lang/crates.io-index" 990 - checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" 997 + checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" 991 998 dependencies = [ 992 - "ahash", 993 999 "allocator-api2", 1000 + "equivalent", 1001 + "foldhash", 994 1002 ] 995 1003 996 1004 [[package]] 997 - name = "hashbrown" 998 - version = "0.15.2" 999 - source = "registry+https://github.com/rust-lang/crates.io-index" 1000 - checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" 1001 - 1002 - [[package]] 1003 1005 name = "hashlink" 1004 - version = "0.9.1" 1006 + version = "0.10.0" 1005 1007 source = "registry+https://github.com/rust-lang/crates.io-index" 1006 - checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" 1008 + checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" 1007 1009 dependencies = [ 1008 - "hashbrown 0.14.5", 1010 + "hashbrown", 1009 1011 ] 1010 1012 1011 1013 [[package]] ··· 1059 1061 ] 1060 1062 1061 1063 [[package]] 1064 + name = "http-range" 1065 + version = "0.1.5" 1066 + source = "registry+https://github.com/rust-lang/crates.io-index" 1067 + checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" 1068 + 1069 + [[package]] 1062 1070 name = "httparse" 1063 1071 version = "1.9.5" 1064 1072 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1245 1253 checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" 1246 1254 dependencies = [ 1247 1255 "equivalent", 1248 - "hashbrown 0.15.2", 1256 + "hashbrown", 1249 1257 ] 1250 1258 1251 1259 [[package]] ··· 1400 1408 checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 1401 1409 1402 1410 [[package]] 1403 - name = "minimal-lexical" 1404 - version = "0.2.1" 1411 + name = "mime_guess" 1412 + version = "2.0.5" 1405 1413 source = "registry+https://github.com/rust-lang/crates.io-index" 1406 - checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" 1414 + checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" 1415 + dependencies = [ 1416 + "mime", 1417 + "unicase", 1418 + ] 1407 1419 1408 1420 [[package]] 1409 1421 name = "miniz_oxide" ··· 1424 1436 "log", 1425 1437 "wasi", 1426 1438 "windows-sys 0.52.0", 1427 - ] 1428 - 1429 - [[package]] 1430 - name = "native-tls" 1431 - version = "0.2.12" 1432 - source = "registry+https://github.com/rust-lang/crates.io-index" 1433 - checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466" 1434 - dependencies = [ 1435 - "libc", 1436 - "log", 1437 - "openssl", 1438 - "openssl-probe", 1439 - "openssl-sys", 1440 - "schannel", 1441 - "security-framework", 1442 - "security-framework-sys", 1443 - "tempfile", 1444 - ] 1445 - 1446 - [[package]] 1447 - name = "nom" 1448 - version = "7.1.3" 1449 - source = "registry+https://github.com/rust-lang/crates.io-index" 1450 - checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" 1451 - dependencies = [ 1452 - "memchr", 1453 - "minimal-lexical", 1454 1439 ] 1455 1440 1456 1441 [[package]] 1457 1442 name = "nu-ansi-term" 1458 - version = "0.46.0" 1443 + version = "0.50.1" 1459 1444 source = "registry+https://github.com/rust-lang/crates.io-index" 1460 - checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" 1445 + checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" 1461 1446 dependencies = [ 1462 - "overload", 1463 - "winapi", 1447 + "windows-sys 0.52.0", 1464 1448 ] 1465 1449 1466 1450 [[package]] ··· 1542 1526 checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" 1543 1527 1544 1528 [[package]] 1545 - name = "openssl" 1546 - version = "0.10.68" 1547 - source = "registry+https://github.com/rust-lang/crates.io-index" 1548 - checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5" 1549 - dependencies = [ 1550 - "bitflags", 1551 - "cfg-if", 1552 - "foreign-types", 1553 - "libc", 1554 - "once_cell", 1555 - "openssl-macros", 1556 - "openssl-sys", 1557 - ] 1558 - 1559 - [[package]] 1560 - name = "openssl-macros" 1561 - version = "0.1.1" 1562 - source = "registry+https://github.com/rust-lang/crates.io-index" 1563 - checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" 1564 - dependencies = [ 1565 - "proc-macro2", 1566 - "quote", 1567 - "syn", 1568 - ] 1569 - 1570 - [[package]] 1571 - name = "openssl-probe" 1572 - version = "0.1.6" 1573 - source = "registry+https://github.com/rust-lang/crates.io-index" 1574 - checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" 1575 - 1576 - [[package]] 1577 - name = "openssl-sys" 1578 - version = "0.9.104" 1579 - source = "registry+https://github.com/rust-lang/crates.io-index" 1580 - checksum = "45abf306cbf99debc8195b66b7346498d7b10c210de50418b5ccd7ceba08c741" 1581 - dependencies = [ 1582 - "cc", 1583 - "libc", 1584 - "pkg-config", 1585 - "vcpkg", 1586 - ] 1587 - 1588 - [[package]] 1589 - name = "overload" 1590 - version = "0.1.1" 1591 - source = "registry+https://github.com/rust-lang/crates.io-index" 1592 - checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" 1593 - 1594 - [[package]] 1595 1529 name = "parking" 1596 1530 version = "2.2.1" 1597 1531 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1810 1744 1811 1745 [[package]] 1812 1746 name = "ring" 1813 - version = "0.17.8" 1747 + version = "0.17.13" 1814 1748 source = "registry+https://github.com/rust-lang/crates.io-index" 1815 - checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" 1749 + checksum = "70ac5d832aa16abd7d1def883a8545280c20a60f523a370aa3a9617c2b8550ee" 1816 1750 dependencies = [ 1817 1751 "cc", 1818 1752 "cfg-if", 1819 1753 "getrandom", 1820 1754 "libc", 1821 - "spin", 1822 1755 "untrusted", 1823 1756 "windows-sys 0.52.0", 1824 1757 ] ··· 1844 1777 ] 1845 1778 1846 1779 [[package]] 1780 + name = "rust-embed" 1781 + version = "6.8.1" 1782 + source = "registry+https://github.com/rust-lang/crates.io-index" 1783 + checksum = "a36224c3276f8c4ebc8c20f158eca7ca4359c8db89991c4925132aaaf6702661" 1784 + dependencies = [ 1785 + "rust-embed-impl", 1786 + "rust-embed-utils", 1787 + "walkdir", 1788 + ] 1789 + 1790 + [[package]] 1791 + name = "rust-embed-impl" 1792 + version = "6.8.1" 1793 + source = "registry+https://github.com/rust-lang/crates.io-index" 1794 + checksum = "49b94b81e5b2c284684141a2fb9e2a31be90638caf040bf9afbc5a0416afe1ac" 1795 + dependencies = [ 1796 + "proc-macro2", 1797 + "quote", 1798 + "rust-embed-utils", 1799 + "syn", 1800 + "walkdir", 1801 + ] 1802 + 1803 + [[package]] 1804 + name = "rust-embed-utils" 1805 + version = "7.8.1" 1806 + source = "registry+https://github.com/rust-lang/crates.io-index" 1807 + checksum = "9d38ff6bf570dc3bb7100fce9f7b60c33fa71d80e88da3f2580df4ff2bdded74" 1808 + dependencies = [ 1809 + "sha2", 1810 + "walkdir", 1811 + ] 1812 + 1813 + [[package]] 1847 1814 name = "rustc-demangle" 1848 1815 version = "0.1.24" 1849 1816 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1884 1851 checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" 1885 1852 1886 1853 [[package]] 1887 - name = "schannel" 1888 - version = "0.1.27" 1854 + name = "same-file" 1855 + version = "1.0.6" 1889 1856 source = "registry+https://github.com/rust-lang/crates.io-index" 1890 - checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" 1857 + checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 1891 1858 dependencies = [ 1892 - "windows-sys 0.59.0", 1859 + "winapi-util", 1893 1860 ] 1894 1861 1895 1862 [[package]] ··· 1899 1866 checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 1900 1867 1901 1868 [[package]] 1902 - name = "security-framework" 1903 - version = "2.11.1" 1904 - source = "registry+https://github.com/rust-lang/crates.io-index" 1905 - checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" 1906 - dependencies = [ 1907 - "bitflags", 1908 - "core-foundation", 1909 - "core-foundation-sys", 1910 - "libc", 1911 - "security-framework-sys", 1912 - ] 1913 - 1914 - [[package]] 1915 - name = "security-framework-sys" 1916 - version = "2.14.0" 1917 - source = "registry+https://github.com/rust-lang/crates.io-index" 1918 - checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" 1919 - dependencies = [ 1920 - "core-foundation-sys", 1921 - "libc", 1922 - ] 1923 - 1924 - [[package]] 1925 1869 name = "semver" 1926 1870 version = "1.0.25" 1927 1871 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2040 1984 ] 2041 1985 2042 1986 [[package]] 1987 + name = "simplelink" 1988 + version = "0.1.0" 1989 + dependencies = [ 1990 + "actix-cors", 1991 + "actix-files", 1992 + "actix-web", 1993 + "anyhow", 1994 + "argon2", 1995 + "base62", 1996 + "chrono", 1997 + "clap", 1998 + "dotenv", 1999 + "futures", 2000 + "jsonwebtoken", 2001 + "lazy_static", 2002 + "mime_guess", 2003 + "rand", 2004 + "regex", 2005 + "rust-embed", 2006 + "serde", 2007 + "serde_json", 2008 + "sqlx", 2009 + "thiserror 1.0.69", 2010 + "tokio", 2011 + "tracing", 2012 + "tracing-subscriber", 2013 + "uuid", 2014 + ] 2015 + 2016 + [[package]] 2043 2017 name = "slab" 2044 2018 version = "0.4.9" 2045 2019 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2087 2061 ] 2088 2062 2089 2063 [[package]] 2090 - name = "sqlformat" 2091 - version = "0.2.6" 2092 - source = "registry+https://github.com/rust-lang/crates.io-index" 2093 - checksum = "7bba3a93db0cc4f7bdece8bb09e77e2e785c20bfebf79eb8340ed80708048790" 2094 - dependencies = [ 2095 - "nom", 2096 - "unicode_categories", 2097 - ] 2098 - 2099 - [[package]] 2100 2064 name = "sqlx" 2101 - version = "0.8.1" 2065 + version = "0.8.3" 2102 2066 source = "registry+https://github.com/rust-lang/crates.io-index" 2103 - checksum = "fcfa89bea9500db4a0d038513d7a060566bfc51d46d1c014847049a45cce85e8" 2067 + checksum = "4410e73b3c0d8442c5f99b425d7a435b5ee0ae4167b3196771dd3f7a01be745f" 2104 2068 dependencies = [ 2105 2069 "sqlx-core", 2106 2070 "sqlx-macros", ··· 2111 2075 2112 2076 [[package]] 2113 2077 name = "sqlx-core" 2114 - version = "0.8.1" 2078 + version = "0.8.3" 2115 2079 source = "registry+https://github.com/rust-lang/crates.io-index" 2116 - checksum = "d06e2f2bd861719b1f3f0c7dbe1d80c30bf59e76cf019f07d9014ed7eefb8e08" 2080 + checksum = "6a007b6936676aa9ab40207cde35daab0a04b823be8ae004368c0793b96a61e0" 2117 2081 dependencies = [ 2118 - "atoi", 2119 - "byteorder", 2120 2082 "bytes", 2121 2083 "chrono", 2122 2084 "crc", 2123 2085 "crossbeam-queue", 2124 2086 "either", 2125 2087 "event-listener", 2126 - "futures-channel", 2127 2088 "futures-core", 2128 2089 "futures-intrusive", 2129 2090 "futures-io", 2130 2091 "futures-util", 2131 - "hashbrown 0.14.5", 2092 + "hashbrown", 2132 2093 "hashlink", 2133 - "hex", 2134 2094 "indexmap", 2135 2095 "log", 2136 2096 "memchr", 2137 - "native-tls", 2138 2097 "once_cell", 2139 - "paste", 2140 2098 "percent-encoding", 2141 2099 "serde", 2142 2100 "serde_json", 2143 2101 "sha2", 2144 2102 "smallvec", 2145 - "sqlformat", 2146 - "thiserror 1.0.69", 2103 + "thiserror 2.0.11", 2147 2104 "tokio", 2148 2105 "tokio-stream", 2149 2106 "tracing", 2150 2107 "url", 2151 - "uuid", 2152 2108 ] 2153 2109 2154 2110 [[package]] 2155 2111 name = "sqlx-macros" 2156 - version = "0.8.1" 2112 + version = "0.8.3" 2157 2113 source = "registry+https://github.com/rust-lang/crates.io-index" 2158 - checksum = "2f998a9defdbd48ed005a89362bd40dd2117502f15294f61c8d47034107dbbdc" 2114 + checksum = "3112e2ad78643fef903618d78cf0aec1cb3134b019730edb039b69eaf531f310" 2159 2115 dependencies = [ 2160 2116 "proc-macro2", 2161 2117 "quote", ··· 2166 2122 2167 2123 [[package]] 2168 2124 name = "sqlx-macros-core" 2169 - version = "0.8.1" 2125 + version = "0.8.3" 2170 2126 source = "registry+https://github.com/rust-lang/crates.io-index" 2171 - checksum = "3d100558134176a2629d46cec0c8891ba0be8910f7896abfdb75ef4ab6f4e7ce" 2127 + checksum = "4e9f90acc5ab146a99bf5061a7eb4976b573f560bc898ef3bf8435448dd5e7ad" 2172 2128 dependencies = [ 2173 2129 "dotenvy", 2174 2130 "either", ··· 2192 2148 2193 2149 [[package]] 2194 2150 name = "sqlx-mysql" 2195 - version = "0.8.1" 2151 + version = "0.8.3" 2196 2152 source = "registry+https://github.com/rust-lang/crates.io-index" 2197 - checksum = "936cac0ab331b14cb3921c62156d913e4c15b74fb6ec0f3146bd4ef6e4fb3c12" 2153 + checksum = "4560278f0e00ce64938540546f59f590d60beee33fffbd3b9cd47851e5fff233" 2198 2154 dependencies = [ 2199 2155 "atoi", 2200 2156 "base64 0.22.1", ··· 2228 2184 "smallvec", 2229 2185 "sqlx-core", 2230 2186 "stringprep", 2231 - "thiserror 1.0.69", 2187 + "thiserror 2.0.11", 2232 2188 "tracing", 2233 - "uuid", 2234 2189 "whoami", 2235 2190 ] 2236 2191 2237 2192 [[package]] 2238 2193 name = "sqlx-postgres" 2239 - version = "0.8.1" 2194 + version = "0.8.3" 2240 2195 source = "registry+https://github.com/rust-lang/crates.io-index" 2241 - checksum = "9734dbce698c67ecf67c442f768a5e90a49b2a4d61a9f1d59f73874bd4cf0710" 2196 + checksum = "c5b98a57f363ed6764d5b3a12bfedf62f07aa16e1856a7ddc2a0bb190a959613" 2242 2197 dependencies = [ 2243 2198 "atoi", 2244 2199 "base64 0.22.1", ··· 2250 2205 "etcetera", 2251 2206 "futures-channel", 2252 2207 "futures-core", 2253 - "futures-io", 2254 2208 "futures-util", 2255 2209 "hex", 2256 2210 "hkdf", ··· 2268 2222 "smallvec", 2269 2223 "sqlx-core", 2270 2224 "stringprep", 2271 - "thiserror 1.0.69", 2225 + "thiserror 2.0.11", 2272 2226 "tracing", 2273 - "uuid", 2274 2227 "whoami", 2275 2228 ] 2276 2229 2277 2230 [[package]] 2278 2231 name = "sqlx-sqlite" 2279 - version = "0.8.1" 2232 + version = "0.8.3" 2280 2233 source = "registry+https://github.com/rust-lang/crates.io-index" 2281 - checksum = "a75b419c3c1b1697833dd927bdc4c6545a620bc1bbafabd44e1efbe9afcd337e" 2234 + checksum = "f85ca71d3a5b24e64e1d08dd8fe36c6c95c339a896cc33068148906784620540" 2282 2235 dependencies = [ 2283 2236 "atoi", 2284 2237 "chrono", ··· 2296 2249 "sqlx-core", 2297 2250 "tracing", 2298 2251 "url", 2299 - "uuid", 2300 2252 ] 2301 2253 2302 2254 [[package]] ··· 2472 2424 2473 2425 [[package]] 2474 2426 name = "tokio" 2475 - version = "1.43.0" 2427 + version = "1.43.1" 2476 2428 source = "registry+https://github.com/rust-lang/crates.io-index" 2477 - checksum = "3d61fa4ffa3de412bfea335c6ecff681de2b609ba3c77ef3e00e521813a9ed9e" 2429 + checksum = "492a604e2fd7f814268a378409e6c92b5525d747d10db9a229723f55a417958c" 2478 2430 dependencies = [ 2479 2431 "backtrace", 2480 2432 "bytes", ··· 2569 2521 2570 2522 [[package]] 2571 2523 name = "tracing-subscriber" 2572 - version = "0.3.19" 2524 + version = "0.3.20" 2573 2525 source = "registry+https://github.com/rust-lang/crates.io-index" 2574 - checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" 2526 + checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" 2575 2527 dependencies = [ 2576 2528 "nu-ansi-term", 2577 2529 "sharded-slab", ··· 2586 2538 version = "1.17.0" 2587 2539 source = "registry+https://github.com/rust-lang/crates.io-index" 2588 2540 checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" 2541 + 2542 + [[package]] 2543 + name = "unicase" 2544 + version = "2.8.1" 2545 + source = "registry+https://github.com/rust-lang/crates.io-index" 2546 + checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" 2589 2547 2590 2548 [[package]] 2591 2549 name = "unicode-bidi" ··· 2615 2573 checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" 2616 2574 2617 2575 [[package]] 2618 - name = "unicode_categories" 2619 - version = "0.1.1" 2620 - source = "registry+https://github.com/rust-lang/crates.io-index" 2621 - checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" 2622 - 2623 - [[package]] 2624 2576 name = "untrusted" 2625 2577 version = "0.9.0" 2626 2578 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2662 2614 checksum = "b3758f5e68192bb96cc8f9b7e2c2cfdabb435499a28499a42f8f984092adad4b" 2663 2615 dependencies = [ 2664 2616 "getrandom", 2665 - "serde", 2666 2617 ] 2618 + 2619 + [[package]] 2620 + name = "v_htmlescape" 2621 + version = "0.15.8" 2622 + source = "registry+https://github.com/rust-lang/crates.io-index" 2623 + checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c" 2667 2624 2668 2625 [[package]] 2669 2626 name = "valuable" ··· 2684 2641 checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 2685 2642 2686 2643 [[package]] 2644 + name = "walkdir" 2645 + version = "2.5.0" 2646 + source = "registry+https://github.com/rust-lang/crates.io-index" 2647 + checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 2648 + dependencies = [ 2649 + "same-file", 2650 + "winapi-util", 2651 + ] 2652 + 2653 + [[package]] 2687 2654 name = "wasi" 2688 2655 version = "0.11.0+wasi-snapshot-preview1" 2689 2656 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2764 2731 ] 2765 2732 2766 2733 [[package]] 2767 - name = "winapi" 2768 - version = "0.3.9" 2734 + name = "winapi-util" 2735 + version = "0.1.9" 2769 2736 source = "registry+https://github.com/rust-lang/crates.io-index" 2770 - checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 2737 + checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" 2771 2738 dependencies = [ 2772 - "winapi-i686-pc-windows-gnu", 2773 - "winapi-x86_64-pc-windows-gnu", 2739 + "windows-sys 0.59.0", 2774 2740 ] 2775 - 2776 - [[package]] 2777 - name = "winapi-i686-pc-windows-gnu" 2778 - version = "0.4.0" 2779 - source = "registry+https://github.com/rust-lang/crates.io-index" 2780 - checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 2781 - 2782 - [[package]] 2783 - name = "winapi-x86_64-pc-windows-gnu" 2784 - version = "0.4.0" 2785 - source = "registry+https://github.com/rust-lang/crates.io-index" 2786 - checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 2787 2741 2788 2742 [[package]] 2789 2743 name = "windows-core"
+13 -4
Cargo.toml
··· 1 1 [package] 2 - name = "SimpleLink" 2 + name = "simplelink" 3 3 version = "0.1.0" 4 4 edition = "2021" 5 5 6 + [lib] 7 + name = "simplelink" 8 + path = "src/lib.rs" 9 + 6 10 [dependencies] 11 + rust-embed = "6.8" 7 12 jsonwebtoken = "9" 8 13 actix-web = "4.4" 14 + actix-files = "0.6" 9 15 actix-cors = "0.6" 10 - tokio = { version = "1.36", features = ["full"] } 11 - sqlx = { version = "0.8", features = ["runtime-tokio-native-tls", "postgres", "uuid", "chrono"] } 16 + tokio = { version = "1.43", features = ["rt-multi-thread", "macros"] } 17 + sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "sqlite", "chrono"] } 12 18 serde = { version = "1.0", features = ["derive"] } 13 19 serde_json = "1.0" 14 20 anyhow = "1.0" 15 21 thiserror = "1.0" 16 22 tracing = "0.1" 17 23 tracing-subscriber = "0.3" 18 - uuid = { version = "1.7", features = ["v4", "serde"] } 24 + uuid = { version = "1.7", features = ["v4"] } # Remove serde if not using UUID serialization 19 25 base62 = "2.0" 20 26 clap = { version = "4.5", features = ["derive"] } 21 27 dotenv = "0.15" ··· 23 29 regex = "1.10" 24 30 lazy_static = "1.4" 25 31 argon2 = "0.5.3" 32 + rand = { version = "0.8", features = ["std"] } 33 + mime_guess = "2.0.5" 34 + futures = "0.3.31"
+68
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"]
+80
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
+89
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
+33 -2
docker-compose.yml
··· 1 - version: '3.8' 2 1 services: 3 2 db: 4 3 image: postgres:15-alpine ··· 16 15 interval: 5s 17 16 timeout: 5s 18 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 19 51 20 52 volumes: 21 53 shortener-data: 22 -
+2
frontend/.gitignore
··· 22 22 *.njsproj 23 23 *.sln 24 24 *.sw? 25 + 26 + .sqlx
+3
frontend/.preludeignore
··· 1 + bun.lock 2 + *.json 3 + *.js
-622
frontend/bun.lock
··· 1 - { 2 - "lockfileVersion": 1, 3 - "workspaces": { 4 - "": { 5 - "name": "frontend", 6 - "dependencies": { 7 - "@emotion/react": "^11.14.0", 8 - "@mantine/core": "^7.16.1", 9 - "@mantine/form": "^7.16.1", 10 - "@mantine/hooks": "^7.16.1", 11 - "axios": "^1.7.9", 12 - "react": "^18.3.1", 13 - "react-dom": "^18.3.1", 14 - }, 15 - "devDependencies": { 16 - "@eslint/js": "^9.17.0", 17 - "@types/node": "^22.10.10", 18 - "@types/react": "^18.3.18", 19 - "@types/react-dom": "^18.3.5", 20 - "@vitejs/plugin-react": "^4.3.4", 21 - "eslint": "^9.17.0", 22 - "eslint-plugin-react-hooks": "^5.0.0", 23 - "eslint-plugin-react-refresh": "^0.4.16", 24 - "globals": "^15.14.0", 25 - "typescript": "~5.6.2", 26 - "typescript-eslint": "^8.18.2", 27 - "vite": "^6.0.5", 28 - }, 29 - }, 30 - }, 31 - "packages": { 32 - "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], 33 - 34 - "@babel/code-frame": ["@babel/code-frame@7.26.2", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "js-tokens": "^4.0.0", "picocolors": "^1.0.0" } }, "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ=="], 35 - 36 - "@babel/compat-data": ["@babel/compat-data@7.26.5", "", {}, "sha512-XvcZi1KWf88RVbF9wn8MN6tYFloU5qX8KjuF3E1PVBmJ9eypXfs4GRiJwLuTZL0iSnJUKn1BFPa5BPZZJyFzPg=="], 37 - 38 - "@babel/core": ["@babel/core@7.26.7", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", "@babel/generator": "^7.26.5", "@babel/helper-compilation-targets": "^7.26.5", "@babel/helper-module-transforms": "^7.26.0", "@babel/helpers": "^7.26.7", "@babel/parser": "^7.26.7", "@babel/template": "^7.25.9", "@babel/traverse": "^7.26.7", "@babel/types": "^7.26.7", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-SRijHmF0PSPgLIBYlWnG0hyeJLwXE2CgpsXaMOrtt2yp9/86ALw6oUlj9KYuZ0JN07T4eBMVIW4li/9S1j2BGA=="], 39 - 40 - "@babel/generator": ["@babel/generator@7.26.5", "", { "dependencies": { "@babel/parser": "^7.26.5", "@babel/types": "^7.26.5", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" } }, "sha512-2caSP6fN9I7HOe6nqhtft7V4g7/V/gfDsC3Ag4W7kEzzvRGKqiv0pu0HogPiZ3KaVSoNDhUws6IJjDjpfmYIXw=="], 41 - 42 - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.26.5", "", { "dependencies": { "@babel/compat-data": "^7.26.5", "@babel/helper-validator-option": "^7.25.9", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA=="], 43 - 44 - "@babel/helper-module-imports": ["@babel/helper-module-imports@7.25.9", "", { "dependencies": { "@babel/traverse": "^7.25.9", "@babel/types": "^7.25.9" } }, "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw=="], 45 - 46 - "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.26.0", "", { "dependencies": { "@babel/helper-module-imports": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9", "@babel/traverse": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw=="], 47 - 48 - "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.26.5", "", {}, "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg=="], 49 - 50 - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.9", "", {}, "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA=="], 51 - 52 - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="], 53 - 54 - "@babel/helper-validator-option": ["@babel/helper-validator-option@7.25.9", "", {}, "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw=="], 55 - 56 - "@babel/helpers": ["@babel/helpers@7.26.7", "", { "dependencies": { "@babel/template": "^7.25.9", "@babel/types": "^7.26.7" } }, "sha512-8NHiL98vsi0mbPQmYAGWwfcFaOy4j2HY49fXJCfuDcdE7fMIsH9a7GdaeXpIBsbT7307WU8KCMp5pUVDNL4f9A=="], 57 - 58 - "@babel/parser": ["@babel/parser@7.26.7", "", { "dependencies": { "@babel/types": "^7.26.7" }, "bin": "./bin/babel-parser.js" }, "sha512-kEvgGGgEjRUutvdVvZhbn/BxVt+5VSpwXz1j3WYXQbXDo8KzFOPNG2GQbdAiNq8g6wn1yKk7C/qrke03a84V+w=="], 59 - 60 - "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg=="], 61 - 62 - "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg=="], 63 - 64 - "@babel/runtime": ["@babel/runtime@7.26.7", "", { "dependencies": { "regenerator-runtime": "^0.14.0" } }, "sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ=="], 65 - 66 - "@babel/template": ["@babel/template@7.25.9", "", { "dependencies": { "@babel/code-frame": "^7.25.9", "@babel/parser": "^7.25.9", "@babel/types": "^7.25.9" } }, "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg=="], 67 - 68 - "@babel/traverse": ["@babel/traverse@7.26.7", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "@babel/generator": "^7.26.5", "@babel/parser": "^7.26.7", "@babel/template": "^7.25.9", "@babel/types": "^7.26.7", "debug": "^4.3.1", "globals": "^11.1.0" } }, "sha512-1x1sgeyRLC3r5fQOM0/xtQKsYjyxmFjaOrLJNtZ81inNjyJHGIolTULPiSc/2qe1/qfpFLisLQYFnnZl7QoedA=="], 69 - 70 - "@babel/types": ["@babel/types@7.26.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" } }, "sha512-t8kDRGrKXyp6+tjUh7hw2RLyclsW4TRoRvRHtSyAX9Bb5ldlFh+90YAYY6awRXrlB4G5G2izNeGySpATlFzmOg=="], 71 - 72 - "@emotion/babel-plugin": ["@emotion/babel-plugin@11.13.5", "", { "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/serialize": "^1.3.3", "babel-plugin-macros": "^3.1.0", "convert-source-map": "^1.5.0", "escape-string-regexp": "^4.0.0", "find-root": "^1.1.0", "source-map": "^0.5.7", "stylis": "4.2.0" } }, "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ=="], 73 - 74 - "@emotion/cache": ["@emotion/cache@11.14.0", "", { "dependencies": { "@emotion/memoize": "^0.9.0", "@emotion/sheet": "^1.4.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "stylis": "4.2.0" } }, "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA=="], 75 - 76 - "@emotion/hash": ["@emotion/hash@0.9.2", "", {}, "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="], 77 - 78 - "@emotion/memoize": ["@emotion/memoize@0.9.0", "", {}, "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ=="], 79 - 80 - "@emotion/react": ["@emotion/react@11.14.0", "", { "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", "@emotion/cache": "^11.14.0", "@emotion/serialize": "^1.3.3", "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "hoist-non-react-statics": "^3.3.1" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA=="], 81 - 82 - "@emotion/serialize": ["@emotion/serialize@1.3.3", "", { "dependencies": { "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/unitless": "^0.10.0", "@emotion/utils": "^1.4.2", "csstype": "^3.0.2" } }, "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA=="], 83 - 84 - "@emotion/sheet": ["@emotion/sheet@1.4.0", "", {}, "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg=="], 85 - 86 - "@emotion/unitless": ["@emotion/unitless@0.10.0", "", {}, "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg=="], 87 - 88 - "@emotion/use-insertion-effect-with-fallbacks": ["@emotion/use-insertion-effect-with-fallbacks@1.2.0", "", { "peerDependencies": { "react": ">=16.8.0" } }, "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg=="], 89 - 90 - "@emotion/utils": ["@emotion/utils@1.4.2", "", {}, "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA=="], 91 - 92 - "@emotion/weak-memoize": ["@emotion/weak-memoize@0.4.0", "", {}, "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg=="], 93 - 94 - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.24.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA=="], 95 - 96 - "@esbuild/android-arm": ["@esbuild/android-arm@0.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q=="], 97 - 98 - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.24.2", "", { "os": "android", "cpu": "arm64" }, "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg=="], 99 - 100 - "@esbuild/android-x64": ["@esbuild/android-x64@0.24.2", "", { "os": "android", "cpu": "x64" }, "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw=="], 101 - 102 - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.24.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA=="], 103 - 104 - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.24.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA=="], 105 - 106 - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.24.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg=="], 107 - 108 - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.24.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q=="], 109 - 110 - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.24.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA=="], 111 - 112 - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.24.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg=="], 113 - 114 - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.24.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw=="], 115 - 116 - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ=="], 117 - 118 - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw=="], 119 - 120 - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.24.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw=="], 121 - 122 - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q=="], 123 - 124 - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.24.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw=="], 125 - 126 - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.24.2", "", { "os": "linux", "cpu": "x64" }, "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q=="], 127 - 128 - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.24.2", "", { "os": "none", "cpu": "arm64" }, "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw=="], 129 - 130 - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.24.2", "", { "os": "none", "cpu": "x64" }, "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw=="], 131 - 132 - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.24.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A=="], 133 - 134 - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.24.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA=="], 135 - 136 - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.24.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig=="], 137 - 138 - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.24.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ=="], 139 - 140 - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.24.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA=="], 141 - 142 - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.24.2", "", { "os": "win32", "cpu": "x64" }, "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg=="], 143 - 144 - "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.4.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA=="], 145 - 146 - "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="], 147 - 148 - "@eslint/config-array": ["@eslint/config-array@0.19.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.5", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-fo6Mtm5mWyKjA/Chy1BYTdn5mGJoDNjC7C64ug20ADsRDGrA85bN3uK3MaKbeRkRuuIEAR5N33Jr1pbm411/PA=="], 149 - 150 - "@eslint/core": ["@eslint/core@0.10.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-gFHJ+xBOo4G3WRlR1e/3G8A6/KZAH6zcE/hkLRCZTi/B9avAG365QhFA8uOGzTMqgTghpn7/fSnscW++dpMSAw=="], 151 - 152 - "@eslint/eslintrc": ["@eslint/eslintrc@3.2.0", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w=="], 153 - 154 - "@eslint/js": ["@eslint/js@9.19.0", "", {}, "sha512-rbq9/g38qjfqFLOVPvwjIvFFdNziEC5S65jmjPw5r6A//QH+W91akh9irMwjDN8zKUTak6W9EsAv4m/7Wnw0UQ=="], 155 - 156 - "@eslint/object-schema": ["@eslint/object-schema@2.1.5", "", {}, "sha512-o0bhxnL89h5Bae5T318nFoFzGy+YE5i/gGkoPAgkmTVdRKTiv3p8JHevPiPaMwoloKfEiiaHlawCqaZMqRm+XQ=="], 157 - 158 - "@eslint/plugin-kit": ["@eslint/plugin-kit@0.2.5", "", { "dependencies": { "@eslint/core": "^0.10.0", "levn": "^0.4.1" } }, "sha512-lB05FkqEdUg2AA0xEbUz0SnkXT1LcCTa438W4IWTUh4hdOnVbQyOJ81OrDXsJk/LSiJHubgGEFoR5EHq1NsH1A=="], 159 - 160 - "@floating-ui/core": ["@floating-ui/core@1.6.9", "", { "dependencies": { "@floating-ui/utils": "^0.2.9" } }, "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw=="], 161 - 162 - "@floating-ui/dom": ["@floating-ui/dom@1.6.13", "", { "dependencies": { "@floating-ui/core": "^1.6.0", "@floating-ui/utils": "^0.2.9" } }, "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w=="], 163 - 164 - "@floating-ui/react": ["@floating-ui/react@0.26.28", "", { "dependencies": { "@floating-ui/react-dom": "^2.1.2", "@floating-ui/utils": "^0.2.8", "tabbable": "^6.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw=="], 165 - 166 - "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.2", "", { "dependencies": { "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A=="], 167 - 168 - "@floating-ui/utils": ["@floating-ui/utils@0.2.9", "", {}, "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="], 169 - 170 - "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], 171 - 172 - "@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="], 173 - 174 - "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], 175 - 176 - "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.1", "", {}, "sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA=="], 177 - 178 - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="], 179 - 180 - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], 181 - 182 - "@jridgewell/set-array": ["@jridgewell/set-array@1.2.1", "", {}, "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A=="], 183 - 184 - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], 185 - 186 - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], 187 - 188 - "@mantine/core": ["@mantine/core@7.16.1", "", { "dependencies": { "@floating-ui/react": "^0.26.28", "clsx": "^2.1.1", "react-number-format": "^5.4.3", "react-remove-scroll": "^2.6.2", "react-textarea-autosize": "8.5.6", "type-fest": "^4.27.0" }, "peerDependencies": { "@mantine/hooks": "7.16.1", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-HYdjCeMU3dUJbc1CrAAedeAASTG5kVyL/qsiuYh5b7BoG0qsRtK8WJxBpUjW6VqtJpUaE94c5tlBJ8MgAmPHTQ=="], 189 - 190 - "@mantine/form": ["@mantine/form@7.16.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "klona": "^2.0.6" }, "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-SZfOlmO14oAYdqo3SJKJlPrSNaeWyTPIPV/cur/4sPf114cAyggEZHoHJEjy2yA8UccfwYZx39yWrwxQCb8J8w=="], 191 - 192 - "@mantine/hooks": ["@mantine/hooks@7.16.1", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-+hER8E4d2ByfQ/DKIXGM3Euxb7IH5ArSjzzzoF21sG095iXIryOCob22ZanrmiXCoAzKKdxqgVj4Di67ikLYSQ=="], 193 - 194 - "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], 195 - 196 - "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], 197 - 198 - "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], 199 - 200 - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.32.0", "", { "os": "android", "cpu": "arm" }, "sha512-G2fUQQANtBPsNwiVFg4zKiPQyjVKZCUdQUol53R8E71J7AsheRMV/Yv/nB8giOcOVqP7//eB5xPqieBYZe9bGg=="], 201 - 202 - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-qhFwQ+ljoymC+j5lXRv8DlaJYY/+8vyvYmVx074zrLsu5ZGWYsJNLjPPVJJjhZQpyAKUGPydOq9hRLLNvh1s3A=="], 203 - 204 - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-44n/X3lAlWsEY6vF8CzgCx+LQaoqWGN7TzUfbJDiTIOjJm4+L2Yq+r5a8ytQRGyPqgJDs3Rgyo8eVL7n9iW6AQ=="], 205 - 206 - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-F9ct0+ZX5Np6+ZDztxiGCIvlCaW87HBdHcozUfsHnj1WCUTBUubAoanhHUfnUHZABlElyRikI0mgcw/qdEm2VQ=="], 207 - 208 - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.32.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-JpsGxLBB2EFXBsTLHfkZDsXSpSmKD3VxXCgBQtlPcuAqB8TlqtLcbeMhxXQkCDv1avgwNjF8uEIbq5p+Cee0PA=="], 209 - 210 - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-wegiyBT6rawdpvnD9lmbOpx5Sph+yVZKHbhnSP9MqUEDX08G4UzMU+D87jrazGE7lRSyTRs6NEYHtzfkJ3FjjQ=="], 211 - 212 - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-3pA7xecItbgOs1A5H58dDvOUEboG5UfpTq3WzAdF54acBbUM+olDJAPkgj1GRJ4ZqE12DZ9/hNS2QZk166v92A=="], 213 - 214 - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Y7XUZEVISGyge51QbYyYAEHwpGgmRrAxQXO3siyYo2kmaj72USSG8LtlQQgAtlGfxYiOwu+2BdbPjzEpcOpRmQ=="], 215 - 216 - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-r7/OTF5MqeBrZo5omPXcTnjvv1GsrdH8a8RerARvDFiDwFpDVDnJyByYM/nX+mvks8XXsgPUxkwe/ltaX2VH7w=="], 217 - 218 - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-HJbifC9vex9NqnlodV2BHVFNuzKL5OnsV2dvTw6e1dpZKkNjPG6WUq+nhEYV6Hv2Bv++BXkwcyoGlXnPrjAKXw=="], 219 - 220 - "@rollup/rollup-linux-loongarch64-gnu": ["@rollup/rollup-linux-loongarch64-gnu@4.32.0", "", { "os": "linux", "cpu": "none" }, "sha512-VAEzZTD63YglFlWwRj3taofmkV1V3xhebDXffon7msNz4b14xKsz7utO6F8F4cqt8K/ktTl9rm88yryvDpsfOw=="], 221 - 222 - "@rollup/rollup-linux-powerpc64le-gnu": ["@rollup/rollup-linux-powerpc64le-gnu@4.32.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Sts5DST1jXAc9YH/iik1C9QRsLcCoOScf3dfbY5i4kH9RJpKxiTBXqm7qU5O6zTXBTEZry69bGszr3SMgYmMcQ=="], 223 - 224 - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.32.0", "", { "os": "linux", "cpu": "none" }, "sha512-qhlXeV9AqxIyY9/R1h1hBD6eMvQCO34ZmdYvry/K+/MBs6d1nRFLm6BOiITLVI+nFAAB9kUB6sdJRKyVHXnqZw=="], 225 - 226 - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.32.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-8ZGN7ExnV0qjXa155Rsfi6H8M4iBBwNLBM9lcVS+4NcSzOFaNqmt7djlox8pN1lWrRPMRRQ8NeDlozIGx3Omsw=="], 227 - 228 - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-VDzNHtLLI5s7xd/VubyS10mq6TxvZBp+4NRWoW+Hi3tgV05RtVm4qK99+dClwTN1McA6PHwob6DEJ6PlXbY83A=="], 229 - 230 - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-qcb9qYDlkxz9DxJo7SDhWxTWV1gFuwznjbTiov289pASxlfGbaOD54mgbs9+z94VwrXtKTu+2RqwlSTbiOqxGg=="], 231 - 232 - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-pFDdotFDMXW2AXVbfdUEfidPAk/OtwE/Hd4eYMTNVVaCQ6Yl8et0meDaKNL63L44Haxv4UExpv9ydSf3aSayDg=="], 233 - 234 - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.32.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-/TG7WfrCAjeRNDvI4+0AAMoHxea/USWhAzf9PVDFHbcqrQ7hMMKp4jZIy4VEjk72AAfN5k4TiSMRXRKf/0akSw=="], 235 - 236 - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-5hqO5S3PTEO2E5VjCePxv40gIgyS2KvO7E7/vvC/NbIW4SIRamkMr1hqj+5Y67fbBWv/bQLB6KelBQmXlyCjWA=="], 237 - 238 - "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], 239 - 240 - "@types/babel__generator": ["@types/babel__generator@7.6.8", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw=="], 241 - 242 - "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], 243 - 244 - "@types/babel__traverse": ["@types/babel__traverse@7.20.6", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg=="], 245 - 246 - "@types/estree": ["@types/estree@1.0.6", "", {}, "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="], 247 - 248 - "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], 249 - 250 - "@types/node": ["@types/node@22.10.10", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-X47y/mPNzxviAGY5TcYPtYL8JsY3kAq2n8fMmKoRCxq/c4v4pyGNCzM2R6+M5/umG4ZfHuT+sgqDYqWc9rJ6ww=="], 251 - 252 - "@types/parse-json": ["@types/parse-json@4.0.2", "", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="], 253 - 254 - "@types/prop-types": ["@types/prop-types@15.7.14", "", {}, "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ=="], 255 - 256 - "@types/react": ["@types/react@18.3.18", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ=="], 257 - 258 - "@types/react-dom": ["@types/react-dom@18.3.5", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q=="], 259 - 260 - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.21.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.21.0", "@typescript-eslint/type-utils": "8.21.0", "@typescript-eslint/utils": "8.21.0", "@typescript-eslint/visitor-keys": "8.21.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", "ts-api-utils": "^2.0.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.8.0" } }, "sha512-eTH+UOR4I7WbdQnG4Z48ebIA6Bgi7WO8HvFEneeYBxG8qCOYgTOFPSg6ek9ITIDvGjDQzWHcoWHCDO2biByNzA=="], 261 - 262 - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.21.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.21.0", "@typescript-eslint/types": "8.21.0", "@typescript-eslint/typescript-estree": "8.21.0", "@typescript-eslint/visitor-keys": "8.21.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.8.0" } }, "sha512-Wy+/sdEH9kI3w9civgACwabHbKl+qIOu0uFZ9IMKzX3Jpv9og0ZBJrZExGrPpFAY7rWsXuxs5e7CPPP17A4eYA=="], 263 - 264 - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.21.0", "", { "dependencies": { "@typescript-eslint/types": "8.21.0", "@typescript-eslint/visitor-keys": "8.21.0" } }, "sha512-G3IBKz0/0IPfdeGRMbp+4rbjfSSdnGkXsM/pFZA8zM9t9klXDnB/YnKOBQ0GoPmoROa4bCq2NeHgJa5ydsQ4mA=="], 265 - 266 - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.21.0", "", { "dependencies": { "@typescript-eslint/typescript-estree": "8.21.0", "@typescript-eslint/utils": "8.21.0", "debug": "^4.3.4", "ts-api-utils": "^2.0.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.8.0" } }, "sha512-95OsL6J2BtzoBxHicoXHxgk3z+9P3BEcQTpBKriqiYzLKnM2DeSqs+sndMKdamU8FosiadQFT3D+BSL9EKnAJQ=="], 267 - 268 - "@typescript-eslint/types": ["@typescript-eslint/types@8.21.0", "", {}, "sha512-PAL6LUuQwotLW2a8VsySDBwYMm129vFm4tMVlylzdoTybTHaAi0oBp7Ac6LhSrHHOdLM3efH+nAR6hAWoMF89A=="], 269 - 270 - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.21.0", "", { "dependencies": { "@typescript-eslint/types": "8.21.0", "@typescript-eslint/visitor-keys": "8.21.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.0.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.8.0" } }, "sha512-x+aeKh/AjAArSauz0GiQZsjT8ciadNMHdkUSwBB9Z6PrKc/4knM4g3UfHml6oDJmKC88a6//cdxnO/+P2LkMcg=="], 271 - 272 - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.21.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@typescript-eslint/scope-manager": "8.21.0", "@typescript-eslint/types": "8.21.0", "@typescript-eslint/typescript-estree": "8.21.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.8.0" } }, "sha512-xcXBfcq0Kaxgj7dwejMbFyq7IOHgpNMtVuDveK7w3ZGwG9owKzhALVwKpTF2yrZmEwl9SWdetf3fxNzJQaVuxw=="], 273 - 274 - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.21.0", "", { "dependencies": { "@typescript-eslint/types": "8.21.0", "eslint-visitor-keys": "^4.2.0" } }, "sha512-BkLMNpdV6prozk8LlyK/SOoWLmUFi+ZD+pcqti9ILCbVvHGk1ui1g4jJOc2WDLaeExz2qWwojxlPce5PljcT3w=="], 275 - 276 - "@vitejs/plugin-react": ["@vitejs/plugin-react@4.3.4", "", { "dependencies": { "@babel/core": "^7.26.0", "@babel/plugin-transform-react-jsx-self": "^7.25.9", "@babel/plugin-transform-react-jsx-source": "^7.25.9", "@types/babel__core": "^7.20.5", "react-refresh": "^0.14.2" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, "sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug=="], 277 - 278 - "acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], 279 - 280 - "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], 281 - 282 - "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], 283 - 284 - "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], 285 - 286 - "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], 287 - 288 - "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], 289 - 290 - "axios": ["axios@1.7.9", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } }, "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw=="], 291 - 292 - "babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="], 293 - 294 - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], 295 - 296 - "brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], 297 - 298 - "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], 299 - 300 - "browserslist": ["browserslist@4.24.4", "", { "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.1" }, "bin": { "browserslist": "cli.js" } }, "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A=="], 301 - 302 - "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], 303 - 304 - "caniuse-lite": ["caniuse-lite@1.0.30001695", "", {}, "sha512-vHyLade6wTgI2u1ec3WQBxv+2BrTERV28UXQu9LO6lZ9pYeMk34vjXFLOxo1A4UBA8XTL4njRQZdno/yYaSmWw=="], 305 - 306 - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], 307 - 308 - "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], 309 - 310 - "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], 311 - 312 - "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], 313 - 314 - "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], 315 - 316 - "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], 317 - 318 - "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], 319 - 320 - "cosmiconfig": ["cosmiconfig@7.1.0", "", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="], 321 - 322 - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], 323 - 324 - "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], 325 - 326 - "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], 327 - 328 - "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], 329 - 330 - "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], 331 - 332 - "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], 333 - 334 - "electron-to-chromium": ["electron-to-chromium@1.5.88", "", {}, "sha512-K3C2qf1o+bGzbilTDCTBhTQcMS9KW60yTAaTeeXsfvQuTDDwlokLam/AdqlqcSy9u4UainDgsHV23ksXAOgamw=="], 335 - 336 - "error-ex": ["error-ex@1.3.2", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g=="], 337 - 338 - "esbuild": ["esbuild@0.24.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.24.2", "@esbuild/android-arm": "0.24.2", "@esbuild/android-arm64": "0.24.2", "@esbuild/android-x64": "0.24.2", "@esbuild/darwin-arm64": "0.24.2", "@esbuild/darwin-x64": "0.24.2", "@esbuild/freebsd-arm64": "0.24.2", "@esbuild/freebsd-x64": "0.24.2", "@esbuild/linux-arm": "0.24.2", "@esbuild/linux-arm64": "0.24.2", "@esbuild/linux-ia32": "0.24.2", "@esbuild/linux-loong64": "0.24.2", "@esbuild/linux-mips64el": "0.24.2", "@esbuild/linux-ppc64": "0.24.2", "@esbuild/linux-riscv64": "0.24.2", "@esbuild/linux-s390x": "0.24.2", "@esbuild/linux-x64": "0.24.2", "@esbuild/netbsd-arm64": "0.24.2", "@esbuild/netbsd-x64": "0.24.2", "@esbuild/openbsd-arm64": "0.24.2", "@esbuild/openbsd-x64": "0.24.2", "@esbuild/sunos-x64": "0.24.2", "@esbuild/win32-arm64": "0.24.2", "@esbuild/win32-ia32": "0.24.2", "@esbuild/win32-x64": "0.24.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA=="], 339 - 340 - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], 341 - 342 - "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], 343 - 344 - "eslint": ["eslint@9.19.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.19.0", "@eslint/core": "^0.10.0", "@eslint/eslintrc": "^3.2.0", "@eslint/js": "9.19.0", "@eslint/plugin-kit": "^0.2.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.1", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.2.0", "espree": "^10.3.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-ug92j0LepKlbbEv6hD911THhoRHmbdXt2gX+VDABAW/Ir7D3nqKdv5Pf5vtlyY6HQMTEP2skXY43ueqTCWssEA=="], 345 - 346 - "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.1.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-mpJRtPgHN2tNAvZ35AMfqeB3Xqeo273QxrHJsbBEPWODRM4r0yB6jfoROqKEYrOn27UtRPpcpHc2UqyBSuUNTw=="], 347 - 348 - "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.18", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-IRGEoFn3OKalm3hjfolEWGqoF/jPqeEYFp+C8B0WMzwGwBMvlRDQd06kghDhF0C61uJ6WfSDhEZE/sAQjduKgw=="], 349 - 350 - "eslint-scope": ["eslint-scope@8.2.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A=="], 351 - 352 - "eslint-visitor-keys": ["eslint-visitor-keys@4.2.0", "", {}, "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw=="], 353 - 354 - "espree": ["espree@10.3.0", "", { "dependencies": { "acorn": "^8.14.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.0" } }, "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg=="], 355 - 356 - "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], 357 - 358 - "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], 359 - 360 - "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], 361 - 362 - "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], 363 - 364 - "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], 365 - 366 - "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=="], 367 - 368 - "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], 369 - 370 - "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], 371 - 372 - "fastq": ["fastq@1.18.0", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw=="], 373 - 374 - "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], 375 - 376 - "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], 377 - 378 - "find-root": ["find-root@1.1.0", "", {}, "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="], 379 - 380 - "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], 381 - 382 - "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], 383 - 384 - "flatted": ["flatted@3.3.2", "", {}, "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA=="], 385 - 386 - "follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="], 387 - 388 - "form-data": ["form-data@4.0.1", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "mime-types": "^2.1.12" } }, "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw=="], 389 - 390 - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], 391 - 392 - "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], 393 - 394 - "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], 395 - 396 - "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], 397 - 398 - "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], 399 - 400 - "globals": ["globals@15.14.0", "", {}, "sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig=="], 401 - 402 - "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], 403 - 404 - "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], 405 - 406 - "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], 407 - 408 - "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], 409 - 410 - "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], 411 - 412 - "import-fresh": ["import-fresh@3.3.0", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw=="], 413 - 414 - "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], 415 - 416 - "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], 417 - 418 - "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], 419 - 420 - "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], 421 - 422 - "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], 423 - 424 - "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], 425 - 426 - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], 427 - 428 - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], 429 - 430 - "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], 431 - 432 - "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], 433 - 434 - "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], 435 - 436 - "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], 437 - 438 - "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], 439 - 440 - "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], 441 - 442 - "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], 443 - 444 - "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], 445 - 446 - "klona": ["klona@2.0.6", "", {}, "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA=="], 447 - 448 - "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], 449 - 450 - "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], 451 - 452 - "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], 453 - 454 - "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], 455 - 456 - "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], 457 - 458 - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], 459 - 460 - "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], 461 - 462 - "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], 463 - 464 - "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], 465 - 466 - "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], 467 - 468 - "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], 469 - 470 - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 471 - 472 - "nanoid": ["nanoid@3.3.8", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w=="], 473 - 474 - "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], 475 - 476 - "node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="], 477 - 478 - "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=="], 479 - 480 - "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], 481 - 482 - "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], 483 - 484 - "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], 485 - 486 - "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], 487 - 488 - "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], 489 - 490 - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], 491 - 492 - "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], 493 - 494 - "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], 495 - 496 - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], 497 - 498 - "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], 499 - 500 - "postcss": ["postcss@8.5.1", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ=="], 501 - 502 - "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], 503 - 504 - "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], 505 - 506 - "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], 507 - 508 - "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], 509 - 510 - "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], 511 - 512 - "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], 513 - 514 - "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], 515 - 516 - "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=="], 517 - 518 - "react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], 519 - 520 - "react-remove-scroll": ["react-remove-scroll@2.6.3", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ=="], 521 - 522 - "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=="], 523 - 524 - "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=="], 525 - 526 - "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=="], 527 - 528 - "regenerator-runtime": ["regenerator-runtime@0.14.1", "", {}, "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="], 529 - 530 - "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=="], 531 - 532 - "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], 533 - 534 - "reusify": ["reusify@1.0.4", "", {}, "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw=="], 535 - 536 - "rollup": ["rollup@4.32.0", "", { "dependencies": { "@types/estree": "1.0.6" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.32.0", "@rollup/rollup-android-arm64": "4.32.0", "@rollup/rollup-darwin-arm64": "4.32.0", "@rollup/rollup-darwin-x64": "4.32.0", "@rollup/rollup-freebsd-arm64": "4.32.0", "@rollup/rollup-freebsd-x64": "4.32.0", "@rollup/rollup-linux-arm-gnueabihf": "4.32.0", "@rollup/rollup-linux-arm-musleabihf": "4.32.0", "@rollup/rollup-linux-arm64-gnu": "4.32.0", "@rollup/rollup-linux-arm64-musl": "4.32.0", "@rollup/rollup-linux-loongarch64-gnu": "4.32.0", "@rollup/rollup-linux-powerpc64le-gnu": "4.32.0", "@rollup/rollup-linux-riscv64-gnu": "4.32.0", "@rollup/rollup-linux-s390x-gnu": "4.32.0", "@rollup/rollup-linux-x64-gnu": "4.32.0", "@rollup/rollup-linux-x64-musl": "4.32.0", "@rollup/rollup-win32-arm64-msvc": "4.32.0", "@rollup/rollup-win32-ia32-msvc": "4.32.0", "@rollup/rollup-win32-x64-msvc": "4.32.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-JmrhfQR31Q4AuNBjjAX4s+a/Pu/Q8Q9iwjWBsjRH1q52SPFE2NqRMK6fUZKKnvKO6id+h7JIRf0oYsph53eATg=="], 537 - 538 - "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], 539 - 540 - "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], 541 - 542 - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], 543 - 544 - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], 545 - 546 - "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], 547 - 548 - "source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], 549 - 550 - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], 551 - 552 - "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], 553 - 554 - "stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="], 555 - 556 - "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], 557 - 558 - "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], 559 - 560 - "tabbable": ["tabbable@6.2.0", "", {}, "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="], 561 - 562 - "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], 563 - 564 - "ts-api-utils": ["ts-api-utils@2.0.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ=="], 565 - 566 - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 567 - 568 - "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], 569 - 570 - "type-fest": ["type-fest@4.33.0", "", {}, "sha512-s6zVrxuyKbbAsSAD5ZPTB77q4YIdRctkTbJ2/Dqlinwz+8ooH2gd+YA7VA6Pa93KML9GockVvoxjZ2vHP+mu8g=="], 571 - 572 - "typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], 573 - 574 - "typescript-eslint": ["typescript-eslint@8.21.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.21.0", "@typescript-eslint/parser": "8.21.0", "@typescript-eslint/utils": "8.21.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.8.0" } }, "sha512-txEKYY4XMKwPXxNkN8+AxAdX6iIJAPiJbHE/FpQccs/sxw8Lf26kqwC3cn0xkHlW8kEbLhkhCsjWuMveaY9Rxw=="], 575 - 576 - "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], 577 - 578 - "update-browserslist-db": ["update-browserslist-db@1.1.2", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg=="], 579 - 580 - "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], 581 - 582 - "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "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-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], 583 - 584 - "use-composed-ref": ["use-composed-ref@1.4.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w=="], 585 - 586 - "use-isomorphic-layout-effect": ["use-isomorphic-layout-effect@1.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-q6ayo8DWoPZT0VdG4u3D3uxcgONP3Mevx2i2b0434cwWBoL+aelL1DzkXI6w3PhTZzUeR2kaVlZn70iCiseP6w=="], 587 - 588 - "use-latest": ["use-latest@1.3.0", "", { "dependencies": { "use-isomorphic-layout-effect": "^1.1.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ=="], 589 - 590 - "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=="], 591 - 592 - "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=="], 593 - 594 - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], 595 - 596 - "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], 597 - 598 - "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], 599 - 600 - "yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="], 601 - 602 - "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], 603 - 604 - "@babel/traverse/globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="], 605 - 606 - "@emotion/babel-plugin/convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="], 607 - 608 - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], 609 - 610 - "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], 611 - 612 - "@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="], 613 - 614 - "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], 615 - 616 - "@typescript-eslint/typescript-estree/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], 617 - 618 - "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], 619 - 620 - "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], 621 - } 622 - }
+21
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 + }
+14 -11
frontend/index.html
··· 1 1 <!doctype html> 2 2 <html lang="en"> 3 - <head> 4 - <meta charset="UTF-8" /> 5 - <link rel="icon" type="image/svg+xml" href="/vite.svg" /> 6 - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 - <title>Vite + React + TS</title> 8 - </head> 9 - <body> 10 - <div id="root"></div> 11 - <script type="module" src="/src/main.tsx"></script> 12 - </body> 13 - </html> 3 + 4 + <head> 5 + <meta charset="UTF-8" /> 6 + <link rel="icon" type="image/svg+xml" href="/vite.svg" /> 7 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 8 + <title>SimpleLink</title> 9 + </head> 10 + 11 + <body> 12 + <div id="root"></div> 13 + <script type="module" src="/src/main.tsx"></script> 14 + </body> 15 + 16 + </html>
+21 -1
frontend/package.json
··· 11 11 }, 12 12 "dependencies": { 13 13 "@emotion/react": "^11.14.0", 14 + "@hookform/resolvers": "^3.10.0", 15 + "@icons-pack/react-simple-icons": "^11.2.0", 14 16 "@mantine/core": "^7.16.1", 15 17 "@mantine/form": "^7.16.1", 16 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", 17 26 "axios": "^1.7.9", 27 + "class-variance-authority": "^0.7.1", 28 + "clsx": "^2.1.1", 29 + "lucide-react": "^0.474.0", 18 30 "react": "^18.3.1", 19 - "react-dom": "^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" 20 37 }, 21 38 "devDependencies": { 22 39 "@eslint/js": "^9.17.0", 40 + "@tailwindcss/postcss": "^4.0.0", 23 41 "@types/node": "^22.10.10", 24 42 "@types/react": "^18.3.18", 25 43 "@types/react-dom": "^18.3.5", ··· 28 46 "eslint-plugin-react-hooks": "^5.0.0", 29 47 "eslint-plugin-react-refresh": "^0.4.16", 30 48 "globals": "^15.14.0", 49 + "postcss": "^8.5.1", 50 + "tailwindcss": "^4.0.0", 31 51 "typescript": "~5.6.2", 32 52 "typescript-eslint": "^8.18.2", 33 53 "vite": "^6.0.5"
+5
frontend/postcss.config.js
··· 1 + export default { 2 + plugins: { 3 + '@tailwindcss/postcss': {}, 4 + }, 5 + }
-1
frontend/public/vite.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
-42
frontend/src/App.css
··· 1 - #root { 2 - max-width: 1280px; 3 - margin: 0 auto; 4 - padding: 2rem; 5 - text-align: center; 6 - } 7 - 8 - .logo { 9 - height: 6em; 10 - padding: 1.5em; 11 - will-change: filter; 12 - transition: filter 300ms; 13 - } 14 - .logo:hover { 15 - filter: drop-shadow(0 0 2em #646cffaa); 16 - } 17 - .logo.react:hover { 18 - filter: drop-shadow(0 0 2em #61dafbaa); 19 - } 20 - 21 - @keyframes logo-spin { 22 - from { 23 - transform: rotate(0deg); 24 - } 25 - to { 26 - transform: rotate(360deg); 27 - } 28 - } 29 - 30 - @media (prefers-reduced-motion: no-preference) { 31 - a:nth-of-type(2) .logo { 32 - animation: logo-spin infinite 20s linear; 33 - } 34 - } 35 - 36 - .card { 37 - padding: 2em; 38 - } 39 - 40 - .read-the-docs { 41 - color: #888; 42 - }
+65 -21
frontend/src/App.tsx
··· 1 - import { MantineProvider, Container, Title, Stack } from '@mantine/core'; 2 - import { LinkForm } from './components/LinkForm'; 3 - import { LinkList } from './components/LinkList'; 4 - import { Link } from './types/api'; 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' 5 11 6 - function App() { 7 - const handleLinkCreated = (link: Link) => { 8 - // You could update the list here or show a success message 9 - window.location.reload(); 10 - }; 12 + function AppContent() { 13 + const { user, logout } = useAuth() 14 + const [refreshCounter, setRefreshCounter] = useState(0) 15 + 16 + const handleLinkCreated = () => { 17 + setRefreshCounter(prev => prev + 1) 18 + } 11 19 12 - return ( 13 - <MantineProvider withGlobalStyles withNormalizeCSS> 14 - <Container size="lg" py="xl"> 15 - <Stack spacing="xl"> 16 - <Title order={1}>URL Shortener</Title> 17 - <LinkForm onSuccess={handleLinkCreated} /> 18 - <LinkList /> 19 - </Stack> 20 - </Container> 21 - </MantineProvider> 22 - ); 20 + return ( 21 + <div className="min-h-screen bg-background flex flex-col"> 22 + <header className="border-b"> 23 + <div className="container max-w-6xl mx-auto flex h-16 items-center justify-between px-4"> 24 + <h1 className="text-2xl font-bold">SimpleLink</h1> 25 + <div className="flex items-center space-x-2 sm:space-x-4"> 26 + {user ? ( 27 + <> 28 + <span className="text-sm text-muted-foreground hidden sm:inline">Welcome, {user.email}</span> 29 + <Button variant="outline" size="sm" onClick={logout}> 30 + Logout 31 + </Button> 32 + </> 33 + ) : ( 34 + <span className="text-sm text-muted-foreground mr-2">A link shortening service</span> 35 + )} 36 + <ModeToggle /> 37 + </div> 38 + </div> 39 + </header> 40 + <main className="flex-1 flex flex-col"> 41 + <div className="container max-w-6xl mx-auto px-4 py-8 flex-1 flex flex-col"> 42 + <div className="space-y-8 flex-1 flex flex-col justify-center"> 43 + {user ? ( 44 + <> 45 + <LinkForm onSuccess={handleLinkCreated} /> 46 + <LinkList refresh={refreshCounter} /> 47 + </> 48 + ) : ( 49 + <AuthForms /> 50 + )} 51 + </div> 52 + </div> 53 + </main> 54 + <Footer /> 55 + </div> 56 + ) 23 57 } 24 58 25 - export default App; 59 + function App() { 60 + return ( 61 + <ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme"> 62 + <AuthProvider> 63 + <AppContent /> 64 + <Toaster /> 65 + </AuthProvider> 66 + </ThemeProvider> 67 + ) 68 + } 26 69 70 + export default App
+81 -1
frontend/src/api/client.ts
··· 1 1 import axios from 'axios'; 2 - import { CreateLinkRequest, Link } from '../types/api'; 2 + import { CreateLinkRequest, Link, AuthResponse, ClickStats, SourceStats } from '../types/api'; 3 3 4 + // Create axios instance with default config 4 5 const api = axios.create({ 5 6 baseURL: '/api', 6 7 }); 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<AuthResponse>('/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<AuthResponse>('/auth/register', { 43 + email, 44 + password, 45 + admin_token: adminToken, 46 + }); 47 + return response.data; 48 + }; 49 + 50 + // Protected endpoints 8 51 export const createShortLink = async (data: CreateLinkRequest) => { 9 52 const response = await api.post<Link>('/shorten', data); 10 53 return response.data; ··· 15 58 return response.data; 16 59 }; 17 60 61 + export const editLink = async (id: number, data: Partial<CreateLinkRequest>) => { 62 + const response = await api.patch<Link>(`/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<ClickStats[]>(`/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<SourceStats[]>(`/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 };
-1
frontend/src/assets/react.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
+143
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<typeof formSchema> 27 + 28 + export function AuthForms() { 29 + const [isFirstUser, setIsFirstUser] = useState<boolean | null>(null) 30 + const { login, register } = useAuth() 31 + const { toast } = useToast() 32 + 33 + const form = useForm<FormValues>({ 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 <div>Loading...</div> 75 + } 76 + 77 + return ( 78 + <Card className="w-full max-w-md mx-auto p-6"> 79 + <div className="mb-6 text-center"> 80 + <h2 className="text-2xl font-bold"> 81 + {isFirstUser ? 'Create Admin Account' : 'Login'} 82 + </h2> 83 + <p className="text-sm text-muted-foreground mt-1"> 84 + {isFirstUser 85 + ? 'Set up your admin account to get started' 86 + : 'Welcome back! Please login to your account'} 87 + </p> 88 + </div> 89 + 90 + <Form {...form}> 91 + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> 92 + <FormField 93 + control={form.control} 94 + name="email" 95 + render={({ field }) => ( 96 + <FormItem> 97 + <FormLabel>Email</FormLabel> 98 + <FormControl> 99 + <Input type="email" {...field} /> 100 + </FormControl> 101 + <FormMessage /> 102 + </FormItem> 103 + )} 104 + /> 105 + 106 + <FormField 107 + control={form.control} 108 + name="password" 109 + render={({ field }) => ( 110 + <FormItem> 111 + <FormLabel>Password</FormLabel> 112 + <FormControl> 113 + <Input type="password" {...field} /> 114 + </FormControl> 115 + <FormMessage /> 116 + </FormItem> 117 + )} 118 + /> 119 + 120 + {isFirstUser && ( 121 + <FormField 122 + control={form.control} 123 + name="adminToken" 124 + render={({ field }) => ( 125 + <FormItem> 126 + <FormLabel>Admin Setup Token</FormLabel> 127 + <FormControl> 128 + <Input type="text" {...field} /> 129 + </FormControl> 130 + <FormMessage /> 131 + </FormItem> 132 + )} 133 + /> 134 + )} 135 + 136 + <Button type="submit" className="w-full"> 137 + {isFirstUser ? 'Create Account' : 'Sign in'} 138 + </Button> 139 + </form> 140 + </Form> 141 + </Card> 142 + ) 143 + }
+139
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<z.infer<typeof formSchema>>({ 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<typeof formSchema>) => { 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 + <Dialog open={isOpen} onOpenChange={onClose}> 86 + <DialogContent> 87 + <DialogHeader> 88 + <DialogTitle>Edit Link</DialogTitle> 89 + </DialogHeader> 90 + 91 + <Form {...form}> 92 + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> 93 + <FormField 94 + control={form.control} 95 + name="url" 96 + render={({ field }) => ( 97 + <FormItem> 98 + <FormLabel>Destination URL</FormLabel> 99 + <FormControl> 100 + <Input placeholder="https://example.com" {...field} /> 101 + </FormControl> 102 + <FormMessage /> 103 + </FormItem> 104 + )} 105 + /> 106 + 107 + <FormField 108 + control={form.control} 109 + name="custom_code" 110 + render={({ field }) => ( 111 + <FormItem> 112 + <FormLabel>Short Code</FormLabel> 113 + <FormControl> 114 + <Input placeholder="custom-code" {...field} /> 115 + </FormControl> 116 + <FormMessage /> 117 + </FormItem> 118 + )} 119 + /> 120 + 121 + <DialogFooter> 122 + <Button 123 + type="button" 124 + variant="outline" 125 + onClick={onClose} 126 + disabled={loading} 127 + > 128 + Cancel 129 + </Button> 130 + <Button type="submit" disabled={loading}> 131 + {loading ? 'Saving...' : 'Save Changes'} 132 + </Button> 133 + </DialogFooter> 134 + </form> 135 + </Form> 136 + </DialogContent> 137 + </Dialog> 138 + ); 139 + }
+54
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 + <footer className="border-t"> 19 + <div className="container max-w-6xl mx-auto flex h-14 items-center justify-between px-4"> 20 + <p className="text-sm text-muted-foreground">Created by waveringana</p> 21 + <div className="flex items-center space-x-4"> 22 + <nav className="flex items-center space-x-4"> 23 + <a 24 + onClick={handlePrivacyModalOpen} 25 + href="#" 26 + > 27 + Privacy 28 + </a> 29 + </nav> 30 + <div className="flex items-center space-x-2"> 31 + <Button variant="ghost" size="icon"> 32 + <a href="https://l.nekomimi.pet/github?source=shortener" target="_blank" rel="noopener noreferrer"> 33 + <SiGithub className="h-4 w-4" /> 34 + </a> 35 + <span className="sr-only">GitHub</span> 36 + </Button> 37 + 38 + <Button variant="ghost" size="icon"> 39 + <a href="https://l.nekomimi.pet/bsky?source=shortener" target="_blank" rel="noopener noreferrer"> 40 + <SiBluesky className="h-4 w-4" /> 41 + </a> 42 + <span className="sr-only">Twitter</span> 43 + </Button> 44 + </div> 45 + </div> 46 + </div> 47 + 48 + <PrivacyModal 49 + isOpen={privacyModalOpen} 50 + onClose={handlePrivacyModalClose} 51 + /> 52 + </footer> 53 + ) 54 + }
+99 -66
frontend/src/components/LinkForm.tsx
··· 1 - import { useState } from 'react'; 2 - import { TextInput, Button, Group, Box, Text } from '@mantine/core'; 3 - import { useForm } from '@mantine/form'; 4 - import { CreateLinkRequest, Link } from '../types/api'; 5 - import { createShortLink } from '../api/client'; 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 + }) 6 31 7 32 interface LinkFormProps { 8 - onSuccess: (link: Link) => void; 33 + onSuccess: () => void; 9 34 } 10 35 11 36 export function LinkForm({ onSuccess }: LinkFormProps) { 12 - const [error, setError] = useState<string | null>(null); 13 - const [loading, setLoading] = useState(false); 37 + const [loading, setLoading] = useState(false) 38 + const { toast } = useToast() 14 39 15 - const form = useForm<CreateLinkRequest>({ 16 - initialValues: { 40 + const form = useForm<z.infer<typeof formSchema>>({ 41 + resolver: zodResolver(formSchema), 42 + defaultValues: { 17 43 url: '', 18 44 custom_code: '', 19 45 }, 20 - validate: { 21 - url: (value) => { 22 - if (!value) return 'URL is required'; 23 - if (!value.startsWith('http://') && !value.startsWith('https://')) { 24 - return 'URL must start with http:// or https://'; 25 - } 26 - return null; 27 - }, 28 - custom_code: (value) => { 29 - if (value && !/^[a-zA-Z0-9_-]{1,32}$/.test(value)) { 30 - return 'Custom code must be 1-32 characters and contain only letters, numbers, underscores, and hyphens'; 31 - } 32 - return null; 33 - }, 34 - }, 35 - }); 46 + }) 36 47 37 - const handleSubmit = async (values: CreateLinkRequest) => { 48 + const onSubmit = async (values: z.infer<typeof formSchema>) => { 38 49 try { 39 - setLoading(true); 40 - setError(null); 41 - const link = await createShortLink(values); 42 - form.reset(); 43 - onSuccess(link); 44 - } catch (err) { 45 - setError(err.response?.data?.error || 'An error occurred'); 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 + }) 46 63 } finally { 47 - setLoading(false); 64 + setLoading(false) 48 65 } 49 - }; 66 + } 50 67 51 68 return ( 52 - <Box mx="auto" sx={{ maxWidth: 500 }}> 53 - <form onSubmit={form.onSubmit(handleSubmit)}> 54 - <TextInput 55 - required 56 - label="URL" 57 - placeholder="https://example.com" 58 - {...form.getInputProps('url')} 59 - /> 60 - 61 - <TextInput 62 - label="Custom Code (optional)" 63 - placeholder="example" 64 - mt="md" 65 - {...form.getInputProps('custom_code')} 66 - /> 67 - 68 - {error && ( 69 - <Text color="red" size="sm" mt="sm"> 70 - {error} 71 - </Text> 72 - )} 69 + <Card className="mb-8"> 70 + <CardHeader> 71 + <CardTitle>Create Short Link</CardTitle> 72 + <CardDescription>Enter a URL to generate a shortened link</CardDescription> 73 + </CardHeader> 74 + <CardContent> 75 + <Form {...form}> 76 + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4 md:flex-row md:items-end"> 77 + <FormField 78 + control={form.control} 79 + name="url" 80 + render={({ field }) => ( 81 + <div className="flex-1 space-y-2"> 82 + <FormLabel>URL</FormLabel> 83 + <FormControl> 84 + <div className="relative"> 85 + <LinkIcon className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> 86 + <Input placeholder="https://example.com" className="pl-9" {...field} /> 87 + </div> 88 + </FormControl> 89 + <FormMessage /> 90 + </div> 91 + )} 92 + /> 73 93 74 - <Group position="right" mt="md"> 75 - <Button type="submit" loading={loading}> 76 - Create Short Link 77 - </Button> 78 - </Group> 79 - </form> 80 - </Box> 81 - ); 82 - } 94 + <FormField 95 + control={form.control} 96 + name="custom_code" 97 + render={({ field }) => ( 98 + <div className="w-full md:w-1/4 space-y-2"> 99 + <FormLabel>Custom Code <span className="text-muted-foreground">(optional)</span></FormLabel> 100 + <FormControl> 101 + <Input placeholder="custom-code" {...field} /> 102 + </FormControl> 103 + <FormMessage /> 104 + </div> 105 + )} 106 + /> 83 107 108 + <Button type="submit" disabled={loading} className="md:w-auto"> 109 + {loading ? "Creating..." : "Create Short Link"} 110 + </Button> 111 + </form> 112 + </Form> 113 + </CardContent> 114 + </Card> 115 + ) 116 + }
+210 -58
frontend/src/components/LinkList.tsx
··· 1 - import { useEffect, useState } from 'react'; 2 - import { Table, Text, Box, CopyButton, Button } from '@mantine/core'; 3 - import { Link } from '../types/api'; 4 - import { getAllLinks } from '../api/client'; 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 + } 5 31 6 - export function LinkList() { 7 - const [links, setLinks] = useState<Link[]>([]); 8 - const [loading, setLoading] = useState(true); 9 - const [error, setError] = useState<string | null>(null); 32 + export function LinkList({ refresh = 0 }: LinkListProps) { 33 + const [links, setLinks] = useState<Link[]>([]) 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() 10 48 11 - const fetchLinks = async () => { 49 + const fetchLinks = useCallback(async () => { 12 50 try { 13 - setLoading(true); 14 - const data = await getAllLinks(); 15 - setLinks(data); 16 - } catch (err) { 17 - setError('Failed to load links'); 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 + }) 18 61 } finally { 19 - setLoading(false); 62 + setLoading(false) 20 63 } 21 - }; 64 + }, [toast, setLinks, setLoading]) 22 65 23 66 useEffect(() => { 24 - fetchLinks(); 25 - }, []); 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 + <br /> 99 + You can add ?source=TextHere to the end of the link to track the source of clicks 100 + </> 101 + ), 102 + }) 103 + } 26 104 27 - if (loading) return <Text>Loading...</Text>; 28 - if (error) return <Text color="red">{error}</Text>; 105 + if (loading && !links.length) { 106 + return <div className="text-center py-4">Loading...</div> 107 + } 29 108 30 109 return ( 31 - <Box> 32 - <Table> 33 - <thead> 34 - <tr> 35 - <th>Short Code</th> 36 - <th>Original URL</th> 37 - <th>Clicks</th> 38 - <th>Created</th> 39 - <th>Actions</th> 40 - </tr> 41 - </thead> 42 - <tbody> 43 - {links.map((link) => ( 44 - <tr key={link.id}> 45 - <td>{link.short_code}</td> 46 - <td>{link.original_url}</td> 47 - <td>{link.clicks}</td> 48 - <td>{new Date(link.created_at).toLocaleDateString()}</td> 49 - <td> 50 - <CopyButton value={`${window.location.origin}/${link.short_code}`}> 51 - {({ copied, copy }) => ( 52 - <Button 53 - color={copied ? 'teal' : 'blue'} 54 - onClick={copy} 55 - size="xs" 56 - > 57 - {copied ? 'Copied' : 'Copy'} 58 - </Button> 59 - )} 60 - </CopyButton> 61 - </td> 62 - </tr> 63 - ))} 64 - </tbody> 65 - </Table> 66 - </Box> 67 - ); 68 - } 110 + <> 111 + <Dialog open={deleteModal.isOpen} onOpenChange={(open) => setDeleteModal({ isOpen: open, linkId: null })}> 112 + <DialogContent> 113 + <DialogHeader> 114 + <DialogTitle>Delete Link</DialogTitle> 115 + <DialogDescription> 116 + Are you sure you want to delete this link? This action cannot be undone. 117 + </DialogDescription> 118 + </DialogHeader> 119 + <DialogFooter> 120 + <Button variant="outline" onClick={() => setDeleteModal({ isOpen: false, linkId: null })}> 121 + Cancel 122 + </Button> 123 + <Button variant="destructive" onClick={handleDelete}> 124 + Delete 125 + </Button> 126 + </DialogFooter> 127 + </DialogContent> 128 + </Dialog> 129 + 130 + <Card> 131 + <CardHeader> 132 + <CardTitle>Your Links</CardTitle> 133 + <CardDescription>Manage and track your shortened links</CardDescription> 134 + </CardHeader> 135 + <CardContent> 136 + <div className="rounded-md border"> 69 137 138 + <Table> 139 + <TableHeader> 140 + <TableRow> 141 + <TableHead>Short Code</TableHead> 142 + <TableHead className="hidden md:table-cell">Original URL</TableHead> 143 + <TableHead>Clicks</TableHead> 144 + <TableHead className="hidden md:table-cell">Created</TableHead> 145 + <TableHead className="w-[1%] whitespace-nowrap pr-4">Actions</TableHead> 146 + </TableRow> 147 + </TableHeader> 148 + <TableBody> 149 + {links.map((link) => ( 150 + <TableRow key={link.id}> 151 + <TableCell className="font-medium">{link.short_code}</TableCell> 152 + <TableCell className="hidden md:table-cell max-w-[300px] truncate"> 153 + {link.original_url} 154 + </TableCell> 155 + <TableCell>{link.clicks}</TableCell> 156 + <TableCell className="hidden md:table-cell"> 157 + {new Date(link.created_at).toLocaleDateString()} 158 + </TableCell> 159 + <TableCell className="p-2 pr-4"> 160 + <div className="flex items-center gap-1"> 161 + <Button 162 + variant="ghost" 163 + size="icon" 164 + className="h-8 w-8" 165 + onClick={() => handleCopy(link.short_code)} 166 + > 167 + <Copy className="h-4 w-4" /> 168 + <span className="sr-only">Copy link</span> 169 + </Button> 170 + <Button 171 + variant="ghost" 172 + size="icon" 173 + className="h-8 w-8" 174 + onClick={() => setStatsModal({ isOpen: true, linkId: link.id })} 175 + > 176 + <BarChart2 className="h-4 w-4" /> 177 + <span className="sr-only">View statistics</span> 178 + </Button> 179 + <Button 180 + variant="ghost" 181 + size="icon" 182 + className="h-8 w-8" 183 + onClick={() => setEditModal({ isOpen: true, link })} 184 + > 185 + <Pencil className="h-4 w-4" /> 186 + <span className="sr-only">Edit Link</span> 187 + </Button> 188 + <Button 189 + variant="ghost" 190 + size="icon" 191 + className="h-8 w-8 text-destructive" 192 + onClick={() => setDeleteModal({ isOpen: true, linkId: link.id })} 193 + > 194 + <Trash2 className="h-4 w-4" /> 195 + <span className="sr-only">Delete link</span> 196 + </Button> 197 + </div> 198 + </TableCell> 199 + </TableRow> 200 + ))} 201 + </TableBody> 202 + </Table> 203 + </div> 204 + </CardContent> 205 + </Card> 206 + <StatisticsModal 207 + isOpen={statsModal.isOpen} 208 + onClose={() => setStatsModal({ isOpen: false, linkId: null })} 209 + linkId={statsModal.linkId!} 210 + /> 211 + {editModal.link && ( 212 + <EditModal 213 + isOpen={editModal.isOpen} 214 + onClose={() => setEditModal({ isOpen: false, link: null })} 215 + link={editModal.link} 216 + onSuccess={fetchLinks} 217 + /> 218 + )} 219 + </> 220 + ) 221 + }
+39
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 + <Dialog open={isOpen}> 20 + <DialogContent className="max-w-md"> 21 + <DialogHeader> 22 + <DialogTitle>Privacy Policy</DialogTitle> 23 + <DialogDescription> 24 + Simplelink's data collection and usage policies 25 + </DialogDescription> 26 + </DialogHeader> 27 + <div className="text-sm text-muted-foreground"> 28 + <p>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.</p> 29 + </div> 30 + <DialogFooter> 31 + <Button variant="outline" onClick={onClose}> 32 + Close 33 + </Button> 34 + </DialogFooter> 35 + </DialogContent> 36 + </Dialog> 37 + ) 38 + } 39 +
+177
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 + <div className="bg-background text-foreground p-4 rounded-lg shadow-lg border"> 41 + <p className="font-medium">{label}</p> 42 + <p className="text-sm">Clicks: {data.clicks}</p> 43 + {data.sources && data.sources.length > 0 && ( 44 + <div className="mt-2"> 45 + <p className="font-medium text-sm">Sources:</p> 46 + <ul className="text-sm"> 47 + {data.sources.map((source: { source: string; count: number }) => ( 48 + <li key={source.source}> 49 + {source.source}: {source.count} 50 + </li> 51 + ))} 52 + </ul> 53 + </div> 54 + )} 55 + </div> 56 + ); 57 + } 58 + return null; 59 + }; 60 + 61 + export function StatisticsModal({ isOpen, onClose, linkId }: StatisticsModalProps) { 62 + const [clicksOverTime, setClicksOverTime] = useState<EnhancedClickStats[]>([]); 63 + const [sourcesData, setSourcesData] = useState<SourceStats[]>([]); 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<Record<string, number>>( 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 + <Dialog open={isOpen} onOpenChange={onClose}> 116 + <DialogContent className="max-w-3xl"> 117 + <DialogHeader> 118 + <DialogTitle>Link Statistics</DialogTitle> 119 + </DialogHeader> 120 + 121 + {loading ? ( 122 + <div className="flex items-center justify-center h-64">Loading...</div> 123 + ) : ( 124 + <div className="grid gap-4"> 125 + <Card> 126 + <CardHeader> 127 + <CardTitle>Clicks Over Time</CardTitle> 128 + </CardHeader> 129 + <CardContent> 130 + <div className="h-[300px]"> 131 + <ResponsiveContainer width="100%" height="100%"> 132 + <LineChart data={clicksOverTime}> 133 + <CartesianGrid strokeDasharray="3 3" /> 134 + <XAxis dataKey="date" /> 135 + <YAxis /> 136 + <Tooltip content={<CustomTooltip />} /> 137 + <Line 138 + type="monotone" 139 + dataKey="clicks" 140 + stroke="#8884d8" 141 + strokeWidth={2} 142 + /> 143 + </LineChart> 144 + </ResponsiveContainer> 145 + </div> 146 + </CardContent> 147 + </Card> 148 + 149 + <Card> 150 + <CardHeader> 151 + <CardTitle>Top Sources</CardTitle> 152 + </CardHeader> 153 + <CardContent> 154 + <ul className="space-y-2"> 155 + {aggregatedSources.map((source, index) => ( 156 + <li 157 + key={source.source} 158 + className="flex items-center justify-between py-2 border-b last:border-0" 159 + > 160 + <span className="text-sm"> 161 + <span className="font-medium text-muted-foreground mr-2"> 162 + {index + 1}. 163 + </span> 164 + {source.source} 165 + </span> 166 + <span className="text-sm font-medium">{source.count} clicks</span> 167 + </li> 168 + ))} 169 + </ul> 170 + </CardContent> 171 + </Card> 172 + </div> 173 + )} 174 + </DialogContent> 175 + </Dialog> 176 + ); 177 + }
+37
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 + <DropdownMenu> 17 + <DropdownMenuTrigger asChild> 18 + <Button variant="outline" size="icon"> 19 + <Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" /> 20 + <Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" /> 21 + <span className="sr-only">Toggle theme</span> 22 + </Button> 23 + </DropdownMenuTrigger> 24 + <DropdownMenuContent align="end"> 25 + <DropdownMenuItem onClick={() => setTheme("light")}> 26 + Light 27 + </DropdownMenuItem> 28 + <DropdownMenuItem onClick={() => setTheme("dark")}> 29 + Dark 30 + </DropdownMenuItem> 31 + <DropdownMenuItem onClick={() => setTheme("system")}> 32 + System 33 + </DropdownMenuItem> 34 + </DropdownMenuContent> 35 + </DropdownMenu> 36 + ) 37 + }
+73
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<ThemeProviderState>(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<Theme>( 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 + <ThemeProviderContext.Provider {...props} value={value}> 61 + {children} 62 + </ThemeProviderContext.Provider> 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 + }
+57
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<HTMLButtonElement>, 39 + VariantProps<typeof buttonVariants> { 40 + asChild?: boolean 41 + } 42 + 43 + const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( 44 + ({ className, variant, size, asChild = false, ...props }, ref) => { 45 + const Comp = asChild ? Slot : "button" 46 + return ( 47 + <Comp 48 + className={cn(buttonVariants({ variant, size, className }))} 49 + ref={ref} 50 + {...props} 51 + /> 52 + ) 53 + } 54 + ) 55 + Button.displayName = "Button" 56 + 57 + export { Button, buttonVariants }
+76
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<HTMLDivElement> 8 + >(({ className, ...props }, ref) => ( 9 + <div 10 + ref={ref} 11 + className={cn( 12 + "rounded-xl border bg-card text-card-foreground shadow-sm", 13 + className 14 + )} 15 + {...props} 16 + /> 17 + )) 18 + Card.displayName = "Card" 19 + 20 + const CardHeader = React.forwardRef< 21 + HTMLDivElement, 22 + React.HTMLAttributes<HTMLDivElement> 23 + >(({ className, ...props }, ref) => ( 24 + <div 25 + ref={ref} 26 + className={cn("flex flex-col space-y-1.5 p-6", className)} 27 + {...props} 28 + /> 29 + )) 30 + CardHeader.displayName = "CardHeader" 31 + 32 + const CardTitle = React.forwardRef< 33 + HTMLDivElement, 34 + React.HTMLAttributes<HTMLDivElement> 35 + >(({ className, ...props }, ref) => ( 36 + <div 37 + ref={ref} 38 + className={cn("font-semibold leading-none tracking-tight", className)} 39 + {...props} 40 + /> 41 + )) 42 + CardTitle.displayName = "CardTitle" 43 + 44 + const CardDescription = React.forwardRef< 45 + HTMLDivElement, 46 + React.HTMLAttributes<HTMLDivElement> 47 + >(({ className, ...props }, ref) => ( 48 + <div 49 + ref={ref} 50 + className={cn("text-sm text-muted-foreground", className)} 51 + {...props} 52 + /> 53 + )) 54 + CardDescription.displayName = "CardDescription" 55 + 56 + const CardContent = React.forwardRef< 57 + HTMLDivElement, 58 + React.HTMLAttributes<HTMLDivElement> 59 + >(({ className, ...props }, ref) => ( 60 + <div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> 61 + )) 62 + CardContent.displayName = "CardContent" 63 + 64 + const CardFooter = React.forwardRef< 65 + HTMLDivElement, 66 + React.HTMLAttributes<HTMLDivElement> 67 + >(({ className, ...props }, ref) => ( 68 + <div 69 + ref={ref} 70 + className={cn("flex items-center p-6 pt-0", className)} 71 + {...props} 72 + /> 73 + )) 74 + CardFooter.displayName = "CardFooter" 75 + 76 + export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+13
frontend/src/components/ui/container.tsx
··· 1 + import { cn } from "@/lib/utils" 2 + 3 + export function Container({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) { 4 + return ( 5 + <div 6 + className={cn( 7 + "mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8", 8 + className 9 + )} 10 + {...props} 11 + /> 12 + ) 13 + }
+120
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<typeof DialogPrimitive.Overlay>, 17 + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> 18 + >(({ className, ...props }, ref) => ( 19 + <DialogPrimitive.Overlay 20 + ref={ref} 21 + className={cn( 22 + "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", 23 + className 24 + )} 25 + {...props} 26 + /> 27 + )) 28 + DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 29 + 30 + const DialogContent = React.forwardRef< 31 + React.ElementRef<typeof DialogPrimitive.Content>, 32 + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> 33 + >(({ className, children, ...props }, ref) => ( 34 + <DialogPortal> 35 + <DialogOverlay /> 36 + <DialogPrimitive.Content 37 + ref={ref} 38 + className={cn( 39 + "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", 40 + className 41 + )} 42 + {...props} 43 + > 44 + {children} 45 + <DialogPrimitive.Close className="absolute right-4 top-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> 46 + <X className="h-4 w-4" /> 47 + <span className="sr-only">Close</span> 48 + </DialogPrimitive.Close> 49 + </DialogPrimitive.Content> 50 + </DialogPortal> 51 + )) 52 + DialogContent.displayName = DialogPrimitive.Content.displayName 53 + 54 + const DialogHeader = ({ 55 + className, 56 + ...props 57 + }: React.HTMLAttributes<HTMLDivElement>) => ( 58 + <div 59 + className={cn( 60 + "flex flex-col space-y-1.5 text-center sm:text-left", 61 + className 62 + )} 63 + {...props} 64 + /> 65 + ) 66 + DialogHeader.displayName = "DialogHeader" 67 + 68 + const DialogFooter = ({ 69 + className, 70 + ...props 71 + }: React.HTMLAttributes<HTMLDivElement>) => ( 72 + <div 73 + className={cn( 74 + "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", 75 + className 76 + )} 77 + {...props} 78 + /> 79 + ) 80 + DialogFooter.displayName = "DialogFooter" 81 + 82 + const DialogTitle = React.forwardRef< 83 + React.ElementRef<typeof DialogPrimitive.Title>, 84 + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> 85 + >(({ className, ...props }, ref) => ( 86 + <DialogPrimitive.Title 87 + ref={ref} 88 + className={cn( 89 + "text-lg font-semibold leading-none tracking-tight", 90 + className 91 + )} 92 + {...props} 93 + /> 94 + )) 95 + DialogTitle.displayName = DialogPrimitive.Title.displayName 96 + 97 + const DialogDescription = React.forwardRef< 98 + React.ElementRef<typeof DialogPrimitive.Description>, 99 + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> 100 + >(({ className, ...props }, ref) => ( 101 + <DialogPrimitive.Description 102 + ref={ref} 103 + className={cn("text-sm text-muted-foreground", className)} 104 + {...props} 105 + /> 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 + }
+199
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<typeof DropdownMenuPrimitive.SubTrigger>, 21 + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { 22 + inset?: boolean 23 + } 24 + >(({ className, inset, children, ...props }, ref) => ( 25 + <DropdownMenuPrimitive.SubTrigger 26 + ref={ref} 27 + className={cn( 28 + "flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 29 + inset && "pl-8", 30 + className 31 + )} 32 + {...props} 33 + > 34 + {children} 35 + <ChevronRight className="ml-auto" /> 36 + </DropdownMenuPrimitive.SubTrigger> 37 + )) 38 + DropdownMenuSubTrigger.displayName = 39 + DropdownMenuPrimitive.SubTrigger.displayName 40 + 41 + const DropdownMenuSubContent = React.forwardRef< 42 + React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, 43 + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> 44 + >(({ className, ...props }, ref) => ( 45 + <DropdownMenuPrimitive.SubContent 46 + ref={ref} 47 + className={cn( 48 + "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", 49 + className 50 + )} 51 + {...props} 52 + /> 53 + )) 54 + DropdownMenuSubContent.displayName = 55 + DropdownMenuPrimitive.SubContent.displayName 56 + 57 + const DropdownMenuContent = React.forwardRef< 58 + React.ElementRef<typeof DropdownMenuPrimitive.Content>, 59 + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> 60 + >(({ className, sideOffset = 4, ...props }, ref) => ( 61 + <DropdownMenuPrimitive.Portal> 62 + <DropdownMenuPrimitive.Content 63 + ref={ref} 64 + sideOffset={sideOffset} 65 + className={cn( 66 + "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md", 67 + "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", 68 + className 69 + )} 70 + {...props} 71 + /> 72 + </DropdownMenuPrimitive.Portal> 73 + )) 74 + DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 75 + 76 + const DropdownMenuItem = React.forwardRef< 77 + React.ElementRef<typeof DropdownMenuPrimitive.Item>, 78 + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { 79 + inset?: boolean 80 + } 81 + >(({ className, inset, ...props }, ref) => ( 82 + <DropdownMenuPrimitive.Item 83 + ref={ref} 84 + className={cn( 85 + "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>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<typeof DropdownMenuPrimitive.CheckboxItem>, 96 + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> 97 + >(({ className, children, checked, ...props }, ref) => ( 98 + <DropdownMenuPrimitive.CheckboxItem 99 + ref={ref} 100 + className={cn( 101 + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", 102 + className 103 + )} 104 + checked={checked} 105 + {...props} 106 + > 107 + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> 108 + <DropdownMenuPrimitive.ItemIndicator> 109 + <Check className="h-4 w-4" /> 110 + </DropdownMenuPrimitive.ItemIndicator> 111 + </span> 112 + {children} 113 + </DropdownMenuPrimitive.CheckboxItem> 114 + )) 115 + DropdownMenuCheckboxItem.displayName = 116 + DropdownMenuPrimitive.CheckboxItem.displayName 117 + 118 + const DropdownMenuRadioItem = React.forwardRef< 119 + React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, 120 + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> 121 + >(({ className, children, ...props }, ref) => ( 122 + <DropdownMenuPrimitive.RadioItem 123 + ref={ref} 124 + className={cn( 125 + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", 126 + className 127 + )} 128 + {...props} 129 + > 130 + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> 131 + <DropdownMenuPrimitive.ItemIndicator> 132 + <Circle className="h-2 w-2 fill-current" /> 133 + </DropdownMenuPrimitive.ItemIndicator> 134 + </span> 135 + {children} 136 + </DropdownMenuPrimitive.RadioItem> 137 + )) 138 + DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 139 + 140 + const DropdownMenuLabel = React.forwardRef< 141 + React.ElementRef<typeof DropdownMenuPrimitive.Label>, 142 + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { 143 + inset?: boolean 144 + } 145 + >(({ className, inset, ...props }, ref) => ( 146 + <DropdownMenuPrimitive.Label 147 + ref={ref} 148 + className={cn( 149 + "px-2 py-1.5 text-sm font-semibold", 150 + inset && "pl-8", 151 + className 152 + )} 153 + {...props} 154 + /> 155 + )) 156 + DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 157 + 158 + const DropdownMenuSeparator = React.forwardRef< 159 + React.ElementRef<typeof DropdownMenuPrimitive.Separator>, 160 + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> 161 + >(({ className, ...props }, ref) => ( 162 + <DropdownMenuPrimitive.Separator 163 + ref={ref} 164 + className={cn("-mx-1 my-1 h-px bg-muted", className)} 165 + {...props} 166 + /> 167 + )) 168 + DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 169 + 170 + const DropdownMenuShortcut = ({ 171 + className, 172 + ...props 173 + }: React.HTMLAttributes<HTMLSpanElement>) => { 174 + return ( 175 + <span 176 + className={cn("ml-auto text-xs tracking-widest opacity-60", className)} 177 + {...props} 178 + /> 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 + }
+178
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<TFieldValues> = FieldPath<TFieldValues> 23 + > = { 24 + name: TName 25 + } 26 + 27 + const FormFieldContext = React.createContext<FormFieldContextValue>( 28 + {} as FormFieldContextValue 29 + ) 30 + 31 + const FormField = < 32 + TFieldValues extends FieldValues = FieldValues, 33 + TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> 34 + >({ 35 + ...props 36 + }: ControllerProps<TFieldValues, TName>) => { 37 + return ( 38 + <FormFieldContext.Provider value={{ name: props.name }}> 39 + <Controller {...props} /> 40 + </FormFieldContext.Provider> 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 <FormField>") 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<FormItemContextValue>( 72 + {} as FormItemContextValue 73 + ) 74 + 75 + const FormItem = React.forwardRef< 76 + HTMLDivElement, 77 + React.HTMLAttributes<HTMLDivElement> 78 + >(({ className, ...props }, ref) => { 79 + const id = React.useId() 80 + 81 + return ( 82 + <FormItemContext.Provider value={{ id }}> 83 + <div ref={ref} className={cn("space-y-2", className)} {...props} /> 84 + </FormItemContext.Provider> 85 + ) 86 + }) 87 + FormItem.displayName = "FormItem" 88 + 89 + const FormLabel = React.forwardRef< 90 + React.ElementRef<typeof LabelPrimitive.Root>, 91 + React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> 92 + >(({ className, ...props }, ref) => { 93 + const { error, formItemId } = useFormField() 94 + 95 + return ( 96 + <Label 97 + ref={ref} 98 + className={cn(error && "text-destructive", className)} 99 + htmlFor={formItemId} 100 + {...props} 101 + /> 102 + ) 103 + }) 104 + FormLabel.displayName = "FormLabel" 105 + 106 + const FormControl = React.forwardRef< 107 + React.ElementRef<typeof Slot>, 108 + React.ComponentPropsWithoutRef<typeof Slot> 109 + >(({ ...props }, ref) => { 110 + const { error, formItemId, formDescriptionId, formMessageId } = useFormField() 111 + 112 + return ( 113 + <Slot 114 + ref={ref} 115 + id={formItemId} 116 + aria-describedby={ 117 + !error 118 + ? `${formDescriptionId}` 119 + : `${formDescriptionId} ${formMessageId}` 120 + } 121 + aria-invalid={!!error} 122 + {...props} 123 + /> 124 + ) 125 + }) 126 + FormControl.displayName = "FormControl" 127 + 128 + const FormDescription = React.forwardRef< 129 + HTMLParagraphElement, 130 + React.HTMLAttributes<HTMLParagraphElement> 131 + >(({ className, ...props }, ref) => { 132 + const { formDescriptionId } = useFormField() 133 + 134 + return ( 135 + <p 136 + ref={ref} 137 + id={formDescriptionId} 138 + className={cn("text-[0.8rem] text-muted-foreground", className)} 139 + {...props} 140 + /> 141 + ) 142 + }) 143 + FormDescription.displayName = "FormDescription" 144 + 145 + const FormMessage = React.forwardRef< 146 + HTMLParagraphElement, 147 + React.HTMLAttributes<HTMLParagraphElement> 148 + >(({ className, children, ...props }, ref) => { 149 + const { error, formMessageId } = useFormField() 150 + const body = error ? String(error?.message) : children 151 + 152 + if (!body) { 153 + return null 154 + } 155 + 156 + return ( 157 + <p 158 + ref={ref} 159 + id={formMessageId} 160 + className={cn("text-[0.8rem] font-medium text-destructive", className)} 161 + {...props} 162 + > 163 + {body} 164 + </p> 165 + ) 166 + }) 167 + FormMessage.displayName = "FormMessage" 168 + 169 + export { 170 + useFormField, 171 + Form, 172 + FormItem, 173 + FormLabel, 174 + FormControl, 175 + FormDescription, 176 + FormMessage, 177 + FormField, 178 + }
+22
frontend/src/components/ui/input.tsx
··· 1 + import * as React from "react" 2 + 3 + import { cn } from "@/lib/utils" 4 + 5 + const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>( 6 + ({ className, type, ...props }, ref) => { 7 + return ( 8 + <input 9 + type={type} 10 + className={cn( 11 + "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", 12 + className 13 + )} 14 + ref={ref} 15 + {...props} 16 + /> 17 + ) 18 + } 19 + ) 20 + Input.displayName = "Input" 21 + 22 + export { Input }
+24
frontend/src/components/ui/label.tsx
··· 1 + import * as React from "react" 2 + import * as LabelPrimitive from "@radix-ui/react-label" 3 + import { cva, type VariantProps } from "class-variance-authority" 4 + 5 + import { cn } from "@/lib/utils" 6 + 7 + const labelVariants = cva( 8 + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 9 + ) 10 + 11 + const Label = React.forwardRef< 12 + React.ElementRef<typeof LabelPrimitive.Root>, 13 + React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & 14 + VariantProps<typeof labelVariants> 15 + >(({ className, ...props }, ref) => ( 16 + <LabelPrimitive.Root 17 + ref={ref} 18 + className={cn(labelVariants(), className)} 19 + {...props} 20 + /> 21 + )) 22 + Label.displayName = LabelPrimitive.Root.displayName 23 + 24 + export { Label }
+120
frontend/src/components/ui/table.tsx
··· 1 + import * as React from "react" 2 + 3 + import { cn } from "@/lib/utils" 4 + 5 + const Table = React.forwardRef< 6 + HTMLTableElement, 7 + React.HTMLAttributes<HTMLTableElement> 8 + >(({ className, ...props }, ref) => ( 9 + <div className="relative w-full overflow-auto"> 10 + <table 11 + ref={ref} 12 + className={cn("w-full caption-bottom text-sm", className)} 13 + {...props} 14 + /> 15 + </div> 16 + )) 17 + Table.displayName = "Table" 18 + 19 + const TableHeader = React.forwardRef< 20 + HTMLTableSectionElement, 21 + React.HTMLAttributes<HTMLTableSectionElement> 22 + >(({ className, ...props }, ref) => ( 23 + <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} /> 24 + )) 25 + TableHeader.displayName = "TableHeader" 26 + 27 + const TableBody = React.forwardRef< 28 + HTMLTableSectionElement, 29 + React.HTMLAttributes<HTMLTableSectionElement> 30 + >(({ className, ...props }, ref) => ( 31 + <tbody 32 + ref={ref} 33 + className={cn("[&_tr:last-child]:border-0", className)} 34 + {...props} 35 + /> 36 + )) 37 + TableBody.displayName = "TableBody" 38 + 39 + const TableFooter = React.forwardRef< 40 + HTMLTableSectionElement, 41 + React.HTMLAttributes<HTMLTableSectionElement> 42 + >(({ className, ...props }, ref) => ( 43 + <tfoot 44 + ref={ref} 45 + className={cn( 46 + "border-t bg-muted/50 font-medium last:[&>tr]:border-b-0", 47 + className 48 + )} 49 + {...props} 50 + /> 51 + )) 52 + TableFooter.displayName = "TableFooter" 53 + 54 + const TableRow = React.forwardRef< 55 + HTMLTableRowElement, 56 + React.HTMLAttributes<HTMLTableRowElement> 57 + >(({ className, ...props }, ref) => ( 58 + <tr 59 + ref={ref} 60 + className={cn( 61 + "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", 62 + className 63 + )} 64 + {...props} 65 + /> 66 + )) 67 + TableRow.displayName = "TableRow" 68 + 69 + const TableHead = React.forwardRef< 70 + HTMLTableCellElement, 71 + React.ThHTMLAttributes<HTMLTableCellElement> 72 + >(({ className, ...props }, ref) => ( 73 + <th 74 + ref={ref} 75 + className={cn( 76 + "h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", 77 + className 78 + )} 79 + {...props} 80 + /> 81 + )) 82 + TableHead.displayName = "TableHead" 83 + 84 + const TableCell = React.forwardRef< 85 + HTMLTableCellElement, 86 + React.TdHTMLAttributes<HTMLTableCellElement> 87 + >(({ className, ...props }, ref) => ( 88 + <td 89 + ref={ref} 90 + className={cn( 91 + "p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", 92 + className 93 + )} 94 + {...props} 95 + /> 96 + )) 97 + TableCell.displayName = "TableCell" 98 + 99 + const TableCaption = React.forwardRef< 100 + HTMLTableCaptionElement, 101 + React.HTMLAttributes<HTMLTableCaptionElement> 102 + >(({ className, ...props }, ref) => ( 103 + <caption 104 + ref={ref} 105 + className={cn("mt-4 text-sm text-muted-foreground", className)} 106 + {...props} 107 + /> 108 + )) 109 + TableCaption.displayName = "TableCaption" 110 + 111 + export { 112 + Table, 113 + TableHeader, 114 + TableBody, 115 + TableFooter, 116 + TableHead, 117 + TableRow, 118 + TableCell, 119 + TableCaption, 120 + }
+53
frontend/src/components/ui/tabs.tsx
··· 1 + import * as React from "react" 2 + import * as TabsPrimitive from "@radix-ui/react-tabs" 3 + 4 + import { cn } from "@/lib/utils" 5 + 6 + const Tabs = TabsPrimitive.Root 7 + 8 + const TabsList = React.forwardRef< 9 + React.ElementRef<typeof TabsPrimitive.List>, 10 + React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> 11 + >(({ className, ...props }, ref) => ( 12 + <TabsPrimitive.List 13 + ref={ref} 14 + className={cn( 15 + "inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground", 16 + className 17 + )} 18 + {...props} 19 + /> 20 + )) 21 + TabsList.displayName = TabsPrimitive.List.displayName 22 + 23 + const TabsTrigger = React.forwardRef< 24 + React.ElementRef<typeof TabsPrimitive.Trigger>, 25 + React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> 26 + >(({ className, ...props }, ref) => ( 27 + <TabsPrimitive.Trigger 28 + ref={ref} 29 + className={cn( 30 + "inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm", 31 + className 32 + )} 33 + {...props} 34 + /> 35 + )) 36 + TabsTrigger.displayName = TabsPrimitive.Trigger.displayName 37 + 38 + const TabsContent = React.forwardRef< 39 + React.ElementRef<typeof TabsPrimitive.Content>, 40 + React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> 41 + >(({ className, ...props }, ref) => ( 42 + <TabsPrimitive.Content 43 + ref={ref} 44 + className={cn( 45 + "mt-2 ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", 46 + className 47 + )} 48 + {...props} 49 + /> 50 + )) 51 + TabsContent.displayName = TabsPrimitive.Content.displayName 52 + 53 + export { Tabs, TabsList, TabsTrigger, TabsContent }
+129
frontend/src/components/ui/toast.tsx
··· 1 + "use client" 2 + 3 + import * as React from "react" 4 + import * as ToastPrimitives from "@radix-ui/react-toast" 5 + import { cva, type VariantProps } from "class-variance-authority" 6 + import { X } from "lucide-react" 7 + 8 + import { cn } from "@/lib/utils" 9 + 10 + const ToastProvider = ToastPrimitives.Provider 11 + 12 + const ToastViewport = React.forwardRef< 13 + React.ElementRef<typeof ToastPrimitives.Viewport>, 14 + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport> 15 + >(({ className, ...props }, ref) => ( 16 + <ToastPrimitives.Viewport 17 + ref={ref} 18 + className={cn( 19 + "fixed top-0 z-100 flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", 20 + className 21 + )} 22 + {...props} 23 + /> 24 + )) 25 + ToastViewport.displayName = ToastPrimitives.Viewport.displayName 26 + 27 + const toastVariants = cva( 28 + "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", 29 + { 30 + variants: { 31 + variant: { 32 + default: "border bg-background text-foreground", 33 + destructive: 34 + "destructive group border-destructive bg-destructive text-destructive-foreground", 35 + }, 36 + }, 37 + defaultVariants: { 38 + variant: "default", 39 + }, 40 + } 41 + ) 42 + 43 + const Toast = React.forwardRef< 44 + React.ElementRef<typeof ToastPrimitives.Root>, 45 + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & 46 + VariantProps<typeof toastVariants> 47 + >(({ className, variant, ...props }, ref) => { 48 + return ( 49 + <ToastPrimitives.Root 50 + ref={ref} 51 + className={cn(toastVariants({ variant }), className)} 52 + {...props} 53 + /> 54 + ) 55 + }) 56 + Toast.displayName = ToastPrimitives.Root.displayName 57 + 58 + const ToastAction = React.forwardRef< 59 + React.ElementRef<typeof ToastPrimitives.Action>, 60 + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action> 61 + >(({ className, ...props }, ref) => ( 62 + <ToastPrimitives.Action 63 + ref={ref} 64 + className={cn( 65 + "inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-hidden focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 hover:group-[.destructive]:border-destructive/30 hover:group-[.destructive]:bg-destructive hover:group-[.destructive]:text-destructive-foreground focus:group-[.destructive]:ring-destructive", 66 + className 67 + )} 68 + {...props} 69 + /> 70 + )) 71 + ToastAction.displayName = ToastPrimitives.Action.displayName 72 + 73 + const ToastClose = React.forwardRef< 74 + React.ElementRef<typeof ToastPrimitives.Close>, 75 + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close> 76 + >(({ className, ...props }, ref) => ( 77 + <ToastPrimitives.Close 78 + ref={ref} 79 + className={cn( 80 + "absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-hidden focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 hover:group-[.destructive]:text-red-50 focus:group-[.destructive]:ring-red-400 focus:group-[.destructive]:ring-offset-red-600", 81 + className 82 + )} 83 + toast-close="" 84 + {...props} 85 + > 86 + <X className="h-4 w-4" /> 87 + </ToastPrimitives.Close> 88 + )) 89 + ToastClose.displayName = ToastPrimitives.Close.displayName 90 + 91 + const ToastTitle = React.forwardRef< 92 + React.ElementRef<typeof ToastPrimitives.Title>, 93 + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title> 94 + >(({ className, ...props }, ref) => ( 95 + <ToastPrimitives.Title 96 + ref={ref} 97 + className={cn("text-sm font-semibold [&+div]:text-xs", className)} 98 + {...props} 99 + /> 100 + )) 101 + ToastTitle.displayName = ToastPrimitives.Title.displayName 102 + 103 + const ToastDescription = React.forwardRef< 104 + React.ElementRef<typeof ToastPrimitives.Description>, 105 + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description> 106 + >(({ className, ...props }, ref) => ( 107 + <ToastPrimitives.Description 108 + ref={ref} 109 + className={cn("text-sm opacity-90", className)} 110 + {...props} 111 + /> 112 + )) 113 + ToastDescription.displayName = ToastPrimitives.Description.displayName 114 + 115 + type ToastProps = React.ComponentPropsWithoutRef<typeof Toast> 116 + 117 + type ToastActionElement = React.ReactElement<typeof ToastAction> 118 + 119 + export { 120 + type ToastProps, 121 + type ToastActionElement, 122 + ToastProvider, 123 + ToastViewport, 124 + Toast, 125 + ToastTitle, 126 + ToastDescription, 127 + ToastClose, 128 + ToastAction, 129 + }
+35
frontend/src/components/ui/toaster.tsx
··· 1 + "use client" 2 + 3 + import { useToast } from "@/hooks/use-toast" 4 + import { 5 + Toast, 6 + ToastClose, 7 + ToastDescription, 8 + ToastProvider, 9 + ToastTitle, 10 + ToastViewport, 11 + } from "@/components/ui/toast" 12 + 13 + export function Toaster() { 14 + const { toasts } = useToast() 15 + 16 + return ( 17 + <ToastProvider> 18 + {toasts.map(function ({ id, title, description, action, ...props }) { 19 + return ( 20 + <Toast key={id} {...props}> 21 + <div className="grid gap-1"> 22 + {title && <ToastTitle>{title}</ToastTitle>} 23 + {description && ( 24 + <ToastDescription>{description}</ToastDescription> 25 + )} 26 + </div> 27 + {action} 28 + <ToastClose /> 29 + </Toast> 30 + ) 31 + })} 32 + <ToastViewport /> 33 + </ToastProvider> 34 + ) 35 + }
+73
frontend/src/context/AuthContext.tsx
··· 1 + import { createContext, useContext, useEffect, useState } from 'react'; 2 + import { User } from '../types/api'; 3 + import * as api from '../api/client'; 4 + 5 + interface AuthContextType { 6 + user: User | null; 7 + login: (email: string, password: string) => Promise<void>; 8 + register: (email: string, password: string, adminToken: string) => Promise<void>; 9 + logout: () => void; 10 + isLoading: boolean; 11 + } 12 + 13 + const AuthContext = createContext<AuthContextType | undefined>(undefined); 14 + 15 + export function AuthProvider({ children }: { children: React.ReactNode }) { 16 + const [user, setUser] = useState<User | null>(null); 17 + const [isLoading, setIsLoading] = useState(true); 18 + 19 + useEffect(() => { 20 + const token = localStorage.getItem('token'); 21 + if (token) { 22 + const userData = JSON.parse(localStorage.getItem('user') || 'null'); 23 + setUser(userData); 24 + } 25 + setIsLoading(false); 26 + 27 + const handleUnauthorized = () => { 28 + setUser(null); 29 + }; 30 + 31 + window.addEventListener('unauthorized', handleUnauthorized); 32 + 33 + return () => { 34 + window.removeEventListener('unauthorized', handleUnauthorized); 35 + }; 36 + }, []); 37 + 38 + const login = async (email: string, password: string) => { 39 + const response = await api.login(email, password); 40 + const { token, user } = response; 41 + localStorage.setItem('token', token); 42 + localStorage.setItem('user', JSON.stringify(user)); 43 + setUser(user); 44 + }; 45 + 46 + const register = async (email: string, password: string, adminToken: string) => { 47 + const response = await api.register(email, password, adminToken); 48 + const { token, user } = response; 49 + localStorage.setItem('token', token); 50 + localStorage.setItem('user', JSON.stringify(user)); 51 + setUser(user); 52 + }; 53 + 54 + const logout = () => { 55 + localStorage.removeItem('token'); 56 + localStorage.removeItem('user'); 57 + setUser(null); 58 + }; 59 + 60 + return ( 61 + <AuthContext.Provider value={{ user, login, register, logout, isLoading }}> 62 + {children} 63 + </AuthContext.Provider> 64 + ); 65 + } 66 + 67 + export function useAuth() { 68 + const context = useContext(AuthContext); 69 + if (context === undefined) { 70 + throw new Error('useAuth must be used within an AuthProvider'); 71 + } 72 + return context; 73 + }
+191
frontend/src/hooks/use-toast.ts
··· 1 + import * as React from "react" 2 + 3 + import type { 4 + ToastActionElement, 5 + ToastProps, 6 + } from "@/components/ui/toast" 7 + 8 + const TOAST_LIMIT = 1 9 + const TOAST_REMOVE_DELAY = 1000000 10 + 11 + type ToasterToast = ToastProps & { 12 + id: string 13 + title?: React.ReactNode 14 + description?: React.ReactNode 15 + action?: ToastActionElement 16 + } 17 + 18 + const actionTypes = { 19 + ADD_TOAST: "ADD_TOAST", 20 + UPDATE_TOAST: "UPDATE_TOAST", 21 + DISMISS_TOAST: "DISMISS_TOAST", 22 + REMOVE_TOAST: "REMOVE_TOAST", 23 + } as const 24 + 25 + let count = 0 26 + 27 + function genId() { 28 + count = (count + 1) % Number.MAX_SAFE_INTEGER 29 + return count.toString() 30 + } 31 + 32 + type ActionType = typeof actionTypes 33 + 34 + type Action = 35 + | { 36 + type: ActionType["ADD_TOAST"] 37 + toast: ToasterToast 38 + } 39 + | { 40 + type: ActionType["UPDATE_TOAST"] 41 + toast: Partial<ToasterToast> 42 + } 43 + | { 44 + type: ActionType["DISMISS_TOAST"] 45 + toastId?: ToasterToast["id"] 46 + } 47 + | { 48 + type: ActionType["REMOVE_TOAST"] 49 + toastId?: ToasterToast["id"] 50 + } 51 + 52 + interface State { 53 + toasts: ToasterToast[] 54 + } 55 + 56 + const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>() 57 + 58 + const addToRemoveQueue = (toastId: string) => { 59 + if (toastTimeouts.has(toastId)) { 60 + return 61 + } 62 + 63 + const timeout = setTimeout(() => { 64 + toastTimeouts.delete(toastId) 65 + dispatch({ 66 + type: "REMOVE_TOAST", 67 + toastId: toastId, 68 + }) 69 + }, TOAST_REMOVE_DELAY) 70 + 71 + toastTimeouts.set(toastId, timeout) 72 + } 73 + 74 + export const reducer = (state: State, action: Action): State => { 75 + switch (action.type) { 76 + case "ADD_TOAST": 77 + return { 78 + ...state, 79 + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), 80 + } 81 + 82 + case "UPDATE_TOAST": 83 + return { 84 + ...state, 85 + toasts: state.toasts.map((t) => 86 + t.id === action.toast.id ? { ...t, ...action.toast } : t 87 + ), 88 + } 89 + 90 + case "DISMISS_TOAST": { 91 + const { toastId } = action 92 + 93 + // ! Side effects ! - This could be extracted into a dismissToast() action, 94 + // but I'll keep it here for simplicity 95 + if (toastId) { 96 + addToRemoveQueue(toastId) 97 + } else { 98 + state.toasts.forEach((toast) => { 99 + addToRemoveQueue(toast.id) 100 + }) 101 + } 102 + 103 + return { 104 + ...state, 105 + toasts: state.toasts.map((t) => 106 + t.id === toastId || toastId === undefined 107 + ? { 108 + ...t, 109 + open: false, 110 + } 111 + : t 112 + ), 113 + } 114 + } 115 + case "REMOVE_TOAST": 116 + if (action.toastId === undefined) { 117 + return { 118 + ...state, 119 + toasts: [], 120 + } 121 + } 122 + return { 123 + ...state, 124 + toasts: state.toasts.filter((t) => t.id !== action.toastId), 125 + } 126 + } 127 + } 128 + 129 + const listeners: Array<(state: State) => void> = [] 130 + 131 + let memoryState: State = { toasts: [] } 132 + 133 + function dispatch(action: Action) { 134 + memoryState = reducer(memoryState, action) 135 + listeners.forEach((listener) => { 136 + listener(memoryState) 137 + }) 138 + } 139 + 140 + type Toast = Omit<ToasterToast, "id"> 141 + 142 + function toast({ ...props }: Toast) { 143 + const id = genId() 144 + 145 + const update = (props: ToasterToast) => 146 + dispatch({ 147 + type: "UPDATE_TOAST", 148 + toast: { ...props, id }, 149 + }) 150 + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) 151 + 152 + dispatch({ 153 + type: "ADD_TOAST", 154 + toast: { 155 + ...props, 156 + id, 157 + open: true, 158 + onOpenChange: (open) => { 159 + if (!open) dismiss() 160 + }, 161 + }, 162 + }) 163 + 164 + return { 165 + id: id, 166 + dismiss, 167 + update, 168 + } 169 + } 170 + 171 + function useToast() { 172 + const [state, setState] = React.useState<State>(memoryState) 173 + 174 + React.useEffect(() => { 175 + listeners.push(setState) 176 + return () => { 177 + const index = listeners.indexOf(setState) 178 + if (index > -1) { 179 + listeners.splice(index, 1) 180 + } 181 + } 182 + }, [state]) 183 + 184 + return { 185 + ...state, 186 + toast, 187 + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), 188 + } 189 + } 190 + 191 + export { useToast, toast }
+147 -52
frontend/src/index.css
··· 1 - :root { 2 - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 - line-height: 1.5; 4 - font-weight: 400; 1 + @import "tailwindcss"; 2 + 3 + @variant dark (&:where(.dark, .dark *)); 5 4 6 - color-scheme: light dark; 7 - color: rgba(255, 255, 255, 0.87); 8 - background-color: #242424; 5 + @plugin "tailwindcss-animate"; 9 6 10 - font-synthesis: none; 11 - text-rendering: optimizeLegibility; 12 - -webkit-font-smoothing: antialiased; 13 - -moz-osx-font-smoothing: grayscale; 7 + @theme inline { 8 + --color-background: var(--background); 9 + --color-foreground: var(--foreground); 10 + --color-card: var(--card); 11 + --color-card-foreground: var(--card-foreground); 12 + --color-popover: var(--popover); 13 + --color-popover-foreground: var(--popover-foreground); 14 + --color-primary: var(--primary); 15 + --color-primary-foreground: var(--primary-foreground); 16 + --color-secondary: var(--secondary); 17 + --color-secondary-foreground: var(--secondary-foreground); 18 + --color-muted: var(--muted); 19 + --color-muted-foreground: var(--muted-foreground); 20 + --color-accent: var(--accent); 21 + --color-accent-foreground: var(--accent-foreground); 22 + --color-destructive: var(--destructive); 23 + --color-destructive-foreground: var(--destructive-foreground); 24 + --color-border: var(--border); 25 + --color-input: var(--input); 26 + --color-ring: var(--ring); 27 + --color-chart-1: var(--chart-1); 28 + --color-chart-2: var(--chart-2); 29 + --color-chart-3: var(--chart-3); 30 + --color-chart-4: var(--chart-4); 31 + --color-chart-5: var(--chart-5); 32 + --radius-lg: var(--radius); 33 + --radius-md: calc(var(--radius) - 2px); 34 + --radius-sm: calc(var(--radius) - 4px); 14 35 } 15 36 16 - a { 17 - font-weight: 500; 18 - color: #646cff; 19 - text-decoration: inherit; 37 + /* Default theme */ 38 + :root { 39 + --background: var(--color-white); 40 + --foreground: var(--color-slate-900); 41 + --card: var(--color-white); 42 + --card-foreground: var(--color-slate-900); 43 + --popover: var(--color-white); 44 + --popover-foreground: var(--color-slate-900); 45 + --primary: var(--color-slate-900); 46 + --primary-foreground: var(--color-slate-50); 47 + --secondary: var(--color-slate-100); 48 + --secondary-foreground: var(--color-slate-900); 49 + --muted: var(--color-slate-100); 50 + --muted-foreground: var(--color-slate-500); 51 + --accent: var(--color-slate-100); 52 + --accent-foreground: var(--color-slate-900); 53 + --destructive: var(--color-rose-500); 54 + --destructive-foreground: var(--color-slate-50); 55 + --border: var(--color-slate-200); 56 + --input: var(--color-slate-200); 57 + --ring: var(--color-slate-400); 58 + --chart-1: hsl(12 76% 61%); 59 + --chart-2: hsl(173 58% 39%); 60 + --chart-3: hsl(197 37% 24%); 61 + --chart-4: hsl(43 74% 66%); 62 + --chart-5: hsl(27 87% 67%); 63 + --radius: 0.5rem; 20 64 } 21 - a:hover { 22 - color: #535bf2; 65 + 66 + .dark { 67 + --background: var(--color-zinc-950); 68 + --foreground: var(--color-zinc-50); 69 + --card: var(--color-zinc-950); 70 + --card-foreground: var(--color-zinc-50); 71 + --popover: var(--color-zinc-950); 72 + --popover-foreground: var(--color-zinc-50); 73 + --primary: var(--color-zinc-50); 74 + --primary-foreground: var(--color-zinc-900); 75 + --secondary: var(--color-zinc-800); 76 + --secondary-foreground: var(--color-zinc-50); 77 + --muted: var(--color-zinc-800); 78 + --muted-foreground: var(--color-zinc-400); 79 + --accent: var(--color-zinc-800); 80 + --accent-foreground: var(--color-zinc-50); 81 + --destructive: var(--color-rose-700); 82 + --destructive-foreground: var(--color-zinc-50); 83 + --border: var(--color-zinc-800); 84 + --input: var(--color-zinc-800); 85 + --ring: var(--color-zinc-300); 86 + --chart-1: hsl(220 70% 50%); 87 + --chart-2: hsl(160 60% 45%); 88 + --chart-3: hsl(30 80% 55%); 89 + --chart-4: hsl(280 65% 60%); 90 + --chart-5: hsl(340 75% 55%); 23 91 } 24 92 25 - body { 26 - margin: 0; 27 - display: flex; 28 - place-items: center; 29 - min-width: 320px; 30 - min-height: 100vh; 93 + [data-theme="example"] { 94 + --background: var(--color-blue-50); 95 + --foreground: var(--color-gray-900); 96 + --card: var(--color-blue-100); 97 + --card-foreground: var(--color-gray-800); 98 + --popover: var(--color-blue-50); 99 + --popover-foreground: var(--color-gray-800); 100 + --primary: var(--color-blue-100); 101 + --primary-foreground: var(--color-gray-900); 102 + --secondary: var(--color-blue-400); 103 + --secondary-foreground: var(--color-gray-800); 104 + --muted: var(--color-emerald-100); 105 + --muted-foreground: var(--color-gray-600); 106 + --accent: var(--color-emerald-200); 107 + --accent-foreground: var(--color-gray-800); 108 + --destructive: var(--color-red-700); 109 + --destructive-foreground: var(--color-gray-200); 110 + --border: var(--color-blue-600); 111 + --input: var(--color-blue-800); 112 + --ring: var(--color-blue-100); 113 + --radius: 0.3rem; 31 114 } 32 115 33 - h1 { 34 - font-size: 3.2em; 35 - line-height: 1.1; 116 + .dark [data-theme="example"], 117 + .dark[data-theme="example"] { 118 + --background: var(--color-gray-900); 119 + --foreground: var(--color-gray-200); 120 + --card: var(--color-gray-900); 121 + --card-foreground: var(--color-gray-200); 122 + --popover: var(--color-gray-950); 123 + --popover-foreground: var(--color-gray-200); 124 + --primary: var(--color-blue-500); 125 + --primary-foreground: var(--color-blue-50); 126 + --secondary: var(--color-blue-800); 127 + --secondary-foreground: var(--color-blue-50); 128 + --muted: var(--color-emerald-900); 129 + --muted-foreground: var(--color-gray-500); 130 + --accent: var(--color-emerald-900); 131 + --accent-foreground: var(--color-gray-200); 132 + --destructive: var(--color-red-700); 133 + --destructive-foreground: var(--color-gray-200); 134 + --border: var(--color-blue-800); 135 + --input: var(--color-blue-800); 136 + --ring: var(--color-blue-100); 137 + --radius: 0.3rem; 36 138 } 37 139 38 - button { 39 - border-radius: 8px; 40 - border: 1px solid transparent; 41 - padding: 0.6em 1.2em; 42 - font-size: 1em; 43 - font-weight: 500; 44 - font-family: inherit; 45 - background-color: #1a1a1a; 46 - cursor: pointer; 47 - transition: border-color 0.25s; 140 + html, 141 + body { 142 + background-color: var(--background); 143 + color: var(--foreground); 48 144 } 49 - button:hover { 50 - border-color: #646cff; 145 + 146 + body * { 147 + border-color: var(--border); 51 148 } 52 - button:focus, 53 - button:focus-visible { 54 - outline: 4px auto -webkit-focus-ring-color; 149 + 150 + /* Animation utilities */ 151 + @keyframes enter { 152 + from { 153 + opacity: var(--tw-enter-opacity, 1); 154 + transform: translate3d(var(--tw-enter-translate-x, 0), var(--tw-enter-translate-y, 0), 0) scale3d(var(--tw-enter-scale, 1), var(--tw-enter-scale, 1), var(--tw-enter-scale, 1)) rotate(var(--tw-enter-rotate, 0)); 155 + } 55 156 } 56 157 57 - @media (prefers-color-scheme: light) { 58 - :root { 59 - color: #213547; 60 - background-color: #ffffff; 61 - } 62 - a:hover { 63 - color: #747bff; 64 - } 65 - button { 66 - background-color: #f9f9f9; 158 + @keyframes exit { 159 + to { 160 + opacity: var(--tw-exit-opacity, 1); 161 + transform: translate3d(var(--tw-exit-translate-x, 0), var(--tw-exit-translate-y, 0), 0) scale3d(var(--tw-exit-scale, 1), var(--tw-exit-scale, 1), var(--tw-exit-scale, 1)) rotate(var(--tw-exit-rotate, 0)); 67 162 } 68 - } 163 + }
+6
frontend/src/lib/utils.ts
··· 1 + import { clsx, type ClassValue } from "clsx" 2 + import { twMerge } from "tailwind-merge" 3 + 4 + export function cn(...inputs: ClassValue[]) { 5 + return twMerge(clsx(inputs)) 6 + }
+26
frontend/src/types/api.ts
··· 12 12 clicks: number; 13 13 } 14 14 15 + export interface User { 16 + id: number; 17 + email: string; 18 + } 19 + 20 + export interface AuthResponse { 21 + token: string; 22 + user: User; 23 + } 24 + 15 25 export interface ApiError { 16 26 error: string; 17 27 } 18 28 29 + export interface ClickStats { 30 + date: string; 31 + clicks: number; 32 + } 33 + 34 + export interface SourceStats { 35 + date: string; 36 + source: string; 37 + count: number; 38 + } 39 + 40 + export interface RegisterRequest { 41 + email: string; 42 + password: string; 43 + admin_token: string; 44 + }
+16 -6
frontend/tsconfig.app.json
··· 3 3 "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 4 "target": "ES2020", 5 5 "useDefineForClassFields": true, 6 - "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 + "lib": [ 7 + "ES2020", 8 + "DOM", 9 + "DOM.Iterable" 10 + ], 7 11 "module": "ESNext", 8 12 "skipLibCheck": true, 9 - 10 13 /* Bundler mode */ 11 14 "moduleResolution": "bundler", 12 15 "allowImportingTsExtensions": true, ··· 14 17 "moduleDetection": "force", 15 18 "noEmit": true, 16 19 "jsx": "react-jsx", 17 - 18 20 /* Linting */ 19 21 "strict": true, 20 22 "noUnusedLocals": true, 21 23 "noUnusedParameters": true, 22 24 "noFallthroughCasesInSwitch": true, 23 - "noUncheckedSideEffectImports": true 25 + "noUncheckedSideEffectImports": true, 26 + "baseUrl": ".", 27 + "paths": { 28 + "@/*": [ 29 + "./src/*" 30 + ] 31 + } 24 32 }, 25 - "include": ["src"] 26 - } 33 + "include": [ 34 + "src" 35 + ] 36 + }
+16 -4
frontend/tsconfig.json
··· 1 1 { 2 2 "files": [], 3 3 "references": [ 4 - { "path": "./tsconfig.app.json" }, 5 - { "path": "./tsconfig.node.json" } 6 - ] 7 - } 4 + { 5 + "path": "./tsconfig.app.json" 6 + }, 7 + { 8 + "path": "./tsconfig.node.json" 9 + } 10 + ], 11 + "compilerOptions": { 12 + "baseUrl": ".", 13 + "paths": { 14 + "@/*": [ 15 + "./src/*" 16 + ] 17 + } 18 + } 19 + }
+30 -11
frontend/vite.config.ts
··· 1 1 import { defineConfig } from 'vite' 2 2 import react from '@vitejs/plugin-react' 3 + import tailwindcss from '@tailwindcss/vite' 4 + import path from "path" 3 5 4 - export default defineConfig({ 5 - plugins: [react()], 6 - server: { 7 - proxy: { 8 - '/api': { 9 - target: 'http://localhost:8080', 10 - changeOrigin: true, 6 + export default defineConfig(({ command }) => { 7 + if (command === 'serve') { //command == 'dev' 8 + return { 9 + server: { 10 + proxy: { 11 + '/api': { 12 + target: process.env.VITE_API_URL || 'http://localhost:8080', 13 + changeOrigin: true, 14 + }, 15 + }, 16 + }, 17 + plugins: [react(), tailwindcss()], 18 + resolve: { 19 + alias: { 20 + "@": path.resolve(__dirname, "./src"), 21 + }, 22 + }, 23 + } 24 + } else { //command === 'build' 25 + return { 26 + plugins: [react(), tailwindcss()], 27 + resolve: { 28 + alias: { 29 + "@": path.resolve(__dirname, "./src"), 30 + }, 11 31 }, 12 - }, 13 - }, 14 - }) 15 - 32 + } 33 + } 34 + })
+37
migrations/20250125000000_init.sql
··· 1 + -- Add Migration Version 2 + CREATE TABLE IF NOT EXISTS _sqlx_migrations ( 3 + version BIGINT PRIMARY KEY, 4 + description TEXT NOT NULL, 5 + installed_on TIMESTAMPTZ NOT NULL DEFAULT NOW() 6 + ); 7 + 8 + -- Create users table 9 + CREATE TABLE users ( 10 + id SERIAL PRIMARY KEY, 11 + email VARCHAR(255) NOT NULL UNIQUE, 12 + password_hash TEXT NOT NULL 13 + ); 14 + 15 + -- Create links table 16 + CREATE TABLE links ( 17 + id SERIAL PRIMARY KEY, 18 + original_url TEXT NOT NULL, 19 + short_code VARCHAR(8) NOT NULL UNIQUE, 20 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 21 + clicks BIGINT NOT NULL DEFAULT 0, 22 + user_id INTEGER REFERENCES users(id) 23 + ); 24 + 25 + -- Create clicks table 26 + CREATE TABLE clicks ( 27 + id SERIAL PRIMARY KEY, 28 + link_id INTEGER REFERENCES links(id), 29 + source TEXT, 30 + query_source TEXT, 31 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 32 + ); 33 + 34 + -- Create indexes 35 + CREATE INDEX idx_short_code ON links(short_code); 36 + CREATE INDEX idx_user_id ON links(user_id); 37 + CREATE INDEX idx_link_id ON clicks(link_id);
+3
migrations/20250219000000_extend_short_code.sql
··· 1 + -- PostgreSQL migration 2 + ALTER TABLE links ALTER COLUMN short_code TYPE VARCHAR(32); 3 +
+42
migrations/sqlite/20250125000000_init.sql
··· 1 + -- Enable foreign key support 2 + PRAGMA foreign_keys = ON; 3 + 4 + -- Add Migration Version 5 + CREATE TABLE IF NOT EXISTS _sqlx_migrations ( 6 + version INTEGER PRIMARY KEY, 7 + description TEXT NOT NULL, 8 + installed_on TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP 9 + ); 10 + 11 + -- Create users table 12 + CREATE TABLE users ( 13 + id INTEGER PRIMARY KEY AUTOINCREMENT, 14 + email VARCHAR(255) NOT NULL UNIQUE, 15 + password_hash TEXT NOT NULL 16 + ); 17 + 18 + -- Create links table 19 + CREATE TABLE links ( 20 + id INTEGER PRIMARY KEY AUTOINCREMENT, 21 + original_url TEXT NOT NULL, 22 + short_code VARCHAR(8) NOT NULL UNIQUE, 23 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, 24 + clicks INTEGER NOT NULL DEFAULT 0, 25 + user_id INTEGER, 26 + FOREIGN KEY (user_id) REFERENCES users(id) 27 + ); 28 + 29 + -- Create clicks table 30 + CREATE TABLE clicks ( 31 + id INTEGER PRIMARY KEY AUTOINCREMENT, 32 + link_id INTEGER, 33 + source TEXT, 34 + query_source TEXT, 35 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, 36 + FOREIGN KEY (link_id) REFERENCES links(id) 37 + ); 38 + 39 + -- Create indexes 40 + CREATE INDEX idx_short_code ON links(short_code); 41 + CREATE INDEX idx_user_id ON links(user_id); 42 + CREATE INDEX idx_link_id ON clicks(link_id);
readme_img/mainview.jpg

This is a binary file and will not be displayed.

readme_img/statview.jpg

This is a binary file and will not be displayed.

+8 -7
src/auth.rs
··· 1 + use crate::{error::AppError, models::Claims}; 1 2 use actix_web::{dev::Payload, FromRequest, HttpRequest}; 2 3 use jsonwebtoken::{decode, DecodingKey, Validation}; 3 4 use std::future::{ready, Ready}; 4 - use crate::{error::AppError, models::Claims}; 5 5 6 6 pub struct AuthenticatedUser { 7 7 pub user_id: i32, ··· 12 12 type Future = Ready<Result<Self, Self::Error>>; 13 13 14 14 fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { 15 - let auth_header = req.headers() 15 + let auth_header = req 16 + .headers() 16 17 .get("Authorization") 17 18 .and_then(|h| h.to_str().ok()); 18 19 19 20 if let Some(auth_header) = auth_header { 20 21 if auth_header.starts_with("Bearer ") { 21 22 let token = &auth_header[7..]; 22 - let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string()); 23 - 23 + let secret = 24 + std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string()); 24 25 match decode::<Claims>( 25 26 token, 26 27 &DecodingKey::from_secret(secret.as_bytes()), 27 - &Validation::default() 28 + &Validation::default(), 28 29 ) { 29 30 Ok(token_data) => { 30 31 return ready(Ok(AuthenticatedUser { ··· 35 36 } 36 37 } 37 38 } 38 - 39 39 ready(Err(AppError::Unauthorized)) 40 40 } 41 - } 41 + } 42 +
+697 -121
src/handlers.rs
··· 1 - use actix_web::{web, HttpResponse, Responder, HttpRequest}; 2 - use jsonwebtoken::{encode, decode, Header, EncodingKey, DecodingKey, Validation, errors::Error as JwtError};use crate::{error::AppError, models::{AuthResponse, Claims, CreateLink, Link, LoginRequest, RegisterRequest, User, UserResponse}, AppState}; 3 - use regex::Regex; 4 - use argon2::{password_hash::{rand_core::OsRng, SaltString}, PasswordVerifier}; 5 - use lazy_static::lazy_static; 1 + use crate::auth::AuthenticatedUser; 2 + use crate::{ 3 + error::AppError, 4 + models::{ 5 + AuthResponse, Claims, ClickStats, CreateLink, DatabasePool, Link, LoginRequest, 6 + RegisterRequest, SourceStats, User, UserResponse, 7 + }, 8 + AppState, 9 + }; 10 + use actix_web::{web, HttpRequest, HttpResponse, Responder}; 11 + use argon2::{ 12 + password_hash::{rand_core::OsRng, SaltString}, 13 + PasswordVerifier, 14 + }; 6 15 use argon2::{Argon2, PasswordHash, PasswordHasher}; 7 - use crate::auth::{AuthenticatedUser}; 16 + use jsonwebtoken::{encode, EncodingKey, Header}; 17 + use lazy_static::lazy_static; 18 + use regex::Regex; 19 + use serde_json::json; 20 + use sqlx::{Postgres, Sqlite}; 8 21 9 22 lazy_static! { 10 23 static ref VALID_CODE_REGEX: Regex = Regex::new(r"^[a-zA-Z0-9_-]{1,32}$").unwrap(); ··· 16 29 payload: web::Json<CreateLink>, 17 30 ) -> Result<impl Responder, AppError> { 18 31 tracing::debug!("Creating short URL with user_id: {}", user.user_id); 19 - 20 32 validate_url(&payload.url)?; 21 - 33 + 22 34 let short_code = if let Some(ref custom_code) = payload.custom_code { 23 35 validate_custom_code(custom_code)?; 24 - 25 - tracing::debug!("Checking if custom code {} exists", custom_code); 26 - // Check if code is already taken 27 - if let Some(_) = sqlx::query_as::<_, Link>( 28 - "SELECT * FROM links WHERE short_code = $1" 29 - ) 30 - .bind(custom_code) 31 - .fetch_optional(&state.db) 32 - .await? { 36 + 37 + // Check if code exists using match on pool type 38 + let exists = match &state.db { 39 + DatabasePool::Postgres(pool) => { 40 + sqlx::query_as::<_, Link>("SELECT * FROM links WHERE short_code = $1") 41 + .bind(custom_code) 42 + .fetch_optional(pool) 43 + .await? 44 + } 45 + DatabasePool::Sqlite(pool) => { 46 + sqlx::query_as::<_, Link>("SELECT * FROM links WHERE short_code = ?1") 47 + .bind(custom_code) 48 + .fetch_optional(pool) 49 + .await? 50 + } 51 + }; 52 + 53 + if exists.is_some() { 33 54 return Err(AppError::InvalidInput( 34 - "Custom code already taken".to_string() 55 + "Custom code already taken".to_string(), 35 56 )); 36 57 } 37 - 38 58 custom_code.clone() 39 59 } else { 40 60 generate_short_code() 41 61 }; 42 - 43 - // Start transaction 44 - let mut tx = state.db.begin().await?; 45 - 46 - tracing::debug!("Inserting new link with short_code: {}", short_code); 47 - let link = sqlx::query_as::<_, Link>( 48 - "INSERT INTO links (original_url, short_code, user_id) VALUES ($1, $2, $3) RETURNING *" 49 - ) 50 - .bind(&payload.url) 51 - .bind(&short_code) 52 - .bind(user.user_id) 53 - .fetch_one(&mut *tx) 54 - .await?; 55 - 56 - if let Some(ref source) = payload.source { 57 - tracing::debug!("Adding click source: {}", source); 58 - sqlx::query( 59 - "INSERT INTO clicks (link_id, source) VALUES ($1, $2)" 60 - ) 61 - .bind(link.id) 62 - .bind(source) 63 - .execute(&mut *tx) 64 - .await?; 65 - } 66 - 67 - tx.commit().await?; 68 - Ok(HttpResponse::Created().json(link)) 62 + 63 + // Start transaction based on pool type 64 + let result = match &state.db { 65 + DatabasePool::Postgres(pool) => { 66 + let mut tx = pool.begin().await?; 67 + 68 + let link = sqlx::query_as::<_, Link>( 69 + "INSERT INTO links (original_url, short_code, user_id) VALUES ($1, $2, $3) RETURNING *" 70 + ) 71 + .bind(&payload.url) 72 + .bind(&short_code) 73 + .bind(user.user_id) 74 + .fetch_one(&mut *tx) 75 + .await?; 76 + 77 + if let Some(ref source) = payload.source { 78 + sqlx::query("INSERT INTO clicks (link_id, source) VALUES ($1, $2)") 79 + .bind(link.id) 80 + .bind(source) 81 + .execute(&mut *tx) 82 + .await?; 83 + } 84 + 85 + tx.commit().await?; 86 + link 87 + } 88 + DatabasePool::Sqlite(pool) => { 89 + let mut tx = pool.begin().await?; 90 + 91 + let link = sqlx::query_as::<_, Link>( 92 + "INSERT INTO links (original_url, short_code, user_id) VALUES (?1, ?2, ?3) RETURNING *" 93 + ) 94 + .bind(&payload.url) 95 + .bind(&short_code) 96 + .bind(user.user_id) 97 + .fetch_one(&mut *tx) 98 + .await?; 99 + 100 + if let Some(ref source) = payload.source { 101 + sqlx::query("INSERT INTO clicks (link_id, source) VALUES (?1, ?2)") 102 + .bind(link.id) 103 + .bind(source) 104 + .execute(&mut *tx) 105 + .await?; 106 + } 107 + 108 + tx.commit().await?; 109 + link 110 + } 111 + }; 112 + 113 + Ok(HttpResponse::Created().json(result)) 69 114 } 70 115 71 116 fn validate_custom_code(code: &str) -> Result<(), AppError> { ··· 74 119 "Custom code must be 1-32 characters long and contain only letters, numbers, underscores, and hyphens".to_string() 75 120 )); 76 121 } 77 - 122 + 78 123 // Add reserved words check 79 124 let reserved_words = ["api", "health", "admin", "static", "assets"]; 80 125 if reserved_words.contains(&code.to_lowercase().as_str()) { 81 126 return Err(AppError::InvalidInput( 82 - "This code is reserved and cannot be used".to_string() 127 + "This code is reserved and cannot be used".to_string(), 83 128 )); 84 129 } 85 - 130 + 86 131 Ok(()) 87 132 } 88 133 89 - fn validate_url(url: &String) -> Result<(), AppError> { 134 + fn validate_url(url: &str) -> Result<(), AppError> { 90 135 if url.is_empty() { 91 136 return Err(AppError::InvalidInput("URL cannot be empty".to_string())); 92 137 } 93 138 if !url.starts_with("http://") && !url.starts_with("https://") { 94 - return Err(AppError::InvalidInput("URL must start with http:// or https://".to_string())); 139 + return Err(AppError::InvalidInput( 140 + "URL must start with http:// or https://".to_string(), 141 + )); 95 142 } 96 143 Ok(()) 97 144 } ··· 102 149 req: HttpRequest, 103 150 ) -> Result<impl Responder, AppError> { 104 151 let short_code = path.into_inner(); 105 - 152 + 106 153 // Extract query source if present 107 - let query_source = req.uri() 154 + let query_source = req 155 + .uri() 108 156 .query() 109 157 .and_then(|q| web::Query::<std::collections::HashMap<String, String>>::from_query(q).ok()) 110 158 .and_then(|params| params.get("source").cloned()); 111 159 112 - let mut tx = state.db.begin().await?; 113 - 114 - let link = sqlx::query_as::<_, Link>( 115 - "UPDATE links SET clicks = clicks + 1 WHERE short_code = $1 RETURNING *" 116 - ) 117 - .bind(&short_code) 118 - .fetch_optional(&mut *tx) 119 - .await?; 160 + let link = match &state.db { 161 + DatabasePool::Postgres(pool) => { 162 + let mut tx = pool.begin().await?; 163 + let link = sqlx::query_as::<_, Link>( 164 + "UPDATE links SET clicks = clicks + 1 WHERE short_code = $1 RETURNING *", 165 + ) 166 + .bind(&short_code) 167 + .fetch_optional(&mut *tx) 168 + .await?; 169 + tx.commit().await?; 170 + link 171 + } 172 + DatabasePool::Sqlite(pool) => { 173 + let mut tx = pool.begin().await?; 174 + let link = sqlx::query_as::<_, Link>( 175 + "UPDATE links SET clicks = clicks + 1 WHERE short_code = ?1 RETURNING *", 176 + ) 177 + .bind(&short_code) 178 + .fetch_optional(&mut *tx) 179 + .await?; 180 + tx.commit().await?; 181 + link 182 + } 183 + }; 120 184 121 185 match link { 122 186 Some(link) => { 123 - // Record click with both user agent and query source 124 - let user_agent = req.headers() 125 - .get("user-agent") 126 - .and_then(|h| h.to_str().ok()) 127 - .unwrap_or("unknown") 128 - .to_string(); 187 + // Handle click recording based on database type 188 + match &state.db { 189 + DatabasePool::Postgres(pool) => { 190 + let mut tx = pool.begin().await?; 191 + let user_agent = req 192 + .headers() 193 + .get("user-agent") 194 + .and_then(|h| h.to_str().ok()) 195 + .unwrap_or("unknown") 196 + .to_string(); 197 + 198 + sqlx::query( 199 + "INSERT INTO clicks (link_id, source, query_source) VALUES ($1, $2, $3)", 200 + ) 201 + .bind(link.id) 202 + .bind(user_agent) 203 + .bind(query_source) 204 + .execute(&mut *tx) 205 + .await?; 129 206 130 - sqlx::query( 131 - "INSERT INTO clicks (link_id, source, query_source) VALUES ($1, $2, $3)" 132 - ) 133 - .bind(link.id) 134 - .bind(user_agent) 135 - .bind(query_source) 136 - .execute(&mut *tx) 137 - .await?; 207 + tx.commit().await?; 208 + } 209 + DatabasePool::Sqlite(pool) => { 210 + let mut tx = pool.begin().await?; 211 + let user_agent = req 212 + .headers() 213 + .get("user-agent") 214 + .and_then(|h| h.to_str().ok()) 215 + .unwrap_or("unknown") 216 + .to_string(); 217 + 218 + sqlx::query( 219 + "INSERT INTO clicks (link_id, source, query_source) VALUES (?1, ?2, ?3)", 220 + ) 221 + .bind(link.id) 222 + .bind(user_agent) 223 + .bind(query_source) 224 + .execute(&mut *tx) 225 + .await?; 138 226 139 - tx.commit().await?; 227 + tx.commit().await?; 228 + } 229 + }; 140 230 141 231 Ok(HttpResponse::TemporaryRedirect() 142 232 .append_header(("Location", link.original_url)) 143 233 .finish()) 144 - }, 234 + } 145 235 None => Err(AppError::NotFound), 146 236 } 147 237 } ··· 150 240 state: web::Data<AppState>, 151 241 user: AuthenticatedUser, 152 242 ) -> Result<impl Responder, AppError> { 153 - let links = sqlx::query_as::<_, Link>( 154 - "SELECT * FROM links WHERE user_id = $1 ORDER BY created_at DESC" 155 - ) 156 - .bind(user.user_id) 157 - .fetch_all(&state.db) 158 - .await?; 243 + let links = match &state.db { 244 + DatabasePool::Postgres(pool) => { 245 + sqlx::query_as::<_, Link>( 246 + "SELECT * FROM links WHERE user_id = $1 ORDER BY created_at DESC", 247 + ) 248 + .bind(user.user_id) 249 + .fetch_all(pool) 250 + .await? 251 + } 252 + DatabasePool::Sqlite(pool) => { 253 + sqlx::query_as::<_, Link>( 254 + "SELECT * FROM links WHERE user_id = ?1 ORDER BY created_at DESC", 255 + ) 256 + .bind(user.user_id) 257 + .fetch_all(pool) 258 + .await? 259 + } 260 + }; 159 261 160 262 Ok(HttpResponse::Ok().json(links)) 161 263 } 162 264 163 - pub async fn health_check( 164 - state: web::Data<AppState>, 165 - ) -> impl Responder { 166 - match sqlx::query("SELECT 1").execute(&state.db).await { 167 - Ok(_) => HttpResponse::Ok().json("Healthy"), 168 - Err(_) => HttpResponse::ServiceUnavailable().json("Database unavailable"), 265 + pub async fn health_check(state: web::Data<AppState>) -> impl Responder { 266 + let is_healthy = match &state.db { 267 + DatabasePool::Postgres(pool) => sqlx::query("SELECT 1").execute(pool).await.is_ok(), 268 + DatabasePool::Sqlite(pool) => sqlx::query("SELECT 1").execute(pool).await.is_ok(), 269 + }; 270 + 271 + if is_healthy { 272 + HttpResponse::Ok().json("Healthy") 273 + } else { 274 + HttpResponse::ServiceUnavailable().json("Database unavailable") 169 275 } 170 276 } 171 277 172 278 fn generate_short_code() -> String { 173 279 use base62::encode; 174 280 use uuid::Uuid; 175 - 281 + 176 282 let uuid = Uuid::new_v4(); 177 - encode(uuid.as_u128() as u64).chars().take(8).collect() 283 + encode(uuid.as_u128() as u64).chars().take(32).collect() 178 284 } 179 285 180 286 pub async fn register( 181 287 state: web::Data<AppState>, 182 288 payload: web::Json<RegisterRequest>, 183 289 ) -> Result<impl Responder, AppError> { 184 - let exists = sqlx::query!( 185 - "SELECT id FROM users WHERE email = $1", 186 - payload.email 187 - ) 188 - .fetch_optional(&state.db) 189 - .await?; 290 + // Check if any users exist 291 + let user_count = match &state.db { 292 + DatabasePool::Postgres(pool) => { 293 + let mut tx = pool.begin().await?; 294 + let count = sqlx::query_as::<Postgres, (i64,)>("SELECT COUNT(*)::bigint FROM users") 295 + .fetch_one(&mut *tx) 296 + .await? 297 + .0; 298 + tx.commit().await?; 299 + count 300 + } 301 + DatabasePool::Sqlite(pool) => { 302 + let mut tx = pool.begin().await?; 303 + let count = sqlx::query_as::<Sqlite, (i64,)>("SELECT COUNT(*) FROM users") 304 + .fetch_one(&mut *tx) 305 + .await? 306 + .0; 307 + tx.commit().await?; 308 + count 309 + } 310 + }; 311 + 312 + // If users exist, registration is closed - no exceptions 313 + if user_count > 0 { 314 + return Err(AppError::Auth("Registration is closed".to_string())); 315 + } 316 + 317 + // Verify admin token for first user 318 + match (&state.admin_token, &payload.admin_token) { 319 + (Some(stored_token), Some(provided_token)) if stored_token == provided_token => { 320 + // Token matches, proceed with registration 321 + } 322 + _ => return Err(AppError::Auth("Invalid admin setup token".to_string())), 323 + } 324 + 325 + // Check if email already exists 326 + let exists = match &state.db { 327 + DatabasePool::Postgres(pool) => { 328 + let mut tx = pool.begin().await?; 329 + let exists = 330 + sqlx::query_as::<Postgres, (i32,)>("SELECT id FROM users WHERE email = $1") 331 + .bind(&payload.email) 332 + .fetch_optional(&mut *tx) 333 + .await?; 334 + tx.commit().await?; 335 + exists 336 + } 337 + DatabasePool::Sqlite(pool) => { 338 + let mut tx = pool.begin().await?; 339 + let exists = sqlx::query_as::<Sqlite, (i32,)>("SELECT id FROM users WHERE email = ?") 340 + .bind(&payload.email) 341 + .fetch_optional(&mut *tx) 342 + .await?; 343 + tx.commit().await?; 344 + exists 345 + } 346 + }; 190 347 191 348 if exists.is_some() { 192 349 return Err(AppError::Auth("Email already registered".to_string())); ··· 194 351 195 352 let salt = SaltString::generate(&mut OsRng); 196 353 let argon2 = Argon2::default(); 197 - let password_hash = argon2.hash_password(payload.password.as_bytes(), &salt) 354 + let password_hash = argon2 355 + .hash_password(payload.password.as_bytes(), &salt) 198 356 .map_err(|e| AppError::Auth(e.to_string()))? 199 357 .to_string(); 200 358 201 - let user = sqlx::query_as!( 202 - User, 203 - "INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING *", 204 - payload.email, 205 - password_hash 206 - ) 207 - .fetch_one(&state.db) 208 - .await?; 359 + // Insert new user 360 + let user = match &state.db { 361 + DatabasePool::Postgres(pool) => { 362 + let mut tx = pool.begin().await?; 363 + let user = sqlx::query_as::<Postgres, User>( 364 + "INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING *", 365 + ) 366 + .bind(&payload.email) 367 + .bind(&password_hash) 368 + .fetch_one(&mut *tx) 369 + .await?; 370 + tx.commit().await?; 371 + user 372 + } 373 + DatabasePool::Sqlite(pool) => { 374 + let mut tx = pool.begin().await?; 375 + let user = sqlx::query_as::<Sqlite, User>( 376 + "INSERT INTO users (email, password_hash) VALUES (?, ?) RETURNING *", 377 + ) 378 + .bind(&payload.email) 379 + .bind(&password_hash) 380 + .fetch_one(&mut *tx) 381 + .await?; 382 + tx.commit().await?; 383 + user 384 + } 385 + }; 209 386 210 387 let claims = Claims::new(user.id); 211 388 let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string()); 212 389 let token = encode( 213 390 &Header::default(), 214 391 &claims, 215 - &EncodingKey::from_secret(secret.as_bytes()) 216 - ).map_err(|e| AppError::Auth(e.to_string()))?; 392 + &EncodingKey::from_secret(secret.as_bytes()), 393 + ) 394 + .map_err(|e| AppError::Auth(e.to_string()))?; 217 395 218 396 Ok(HttpResponse::Ok().json(AuthResponse { 219 397 token, ··· 228 406 state: web::Data<AppState>, 229 407 payload: web::Json<LoginRequest>, 230 408 ) -> Result<impl Responder, AppError> { 231 - let user = sqlx::query_as!( 232 - User, 233 - "SELECT * FROM users WHERE email = $1", 234 - payload.email 235 - ) 236 - .fetch_optional(&state.db) 237 - .await? 409 + let user = match &state.db { 410 + DatabasePool::Postgres(pool) => { 411 + let mut tx = pool.begin().await?; 412 + let user = sqlx::query_as::<Postgres, User>("SELECT * FROM users WHERE email = $1") 413 + .bind(&payload.email) 414 + .fetch_optional(&mut *tx) 415 + .await?; 416 + tx.commit().await?; 417 + user 418 + } 419 + DatabasePool::Sqlite(pool) => { 420 + let mut tx = pool.begin().await?; 421 + let user = sqlx::query_as::<Sqlite, User>("SELECT * FROM users WHERE email = ?") 422 + .bind(&payload.email) 423 + .fetch_optional(&mut *tx) 424 + .await?; 425 + tx.commit().await?; 426 + user 427 + } 428 + } 238 429 .ok_or_else(|| AppError::Auth("Invalid credentials".to_string()))?; 239 430 240 431 let argon2 = Argon2::default(); 241 - let parsed_hash = PasswordHash::new(&user.password_hash) 242 - .map_err(|e| AppError::Auth(e.to_string()))?; 432 + let parsed_hash = 433 + PasswordHash::new(&user.password_hash).map_err(|e| AppError::Auth(e.to_string()))?; 243 434 244 - if argon2.verify_password(payload.password.as_bytes(), &parsed_hash).is_err() { 435 + if argon2 436 + .verify_password(payload.password.as_bytes(), &parsed_hash) 437 + .is_err() 438 + { 245 439 return Err(AppError::Auth("Invalid credentials".to_string())); 246 440 } 247 441 ··· 250 444 let token = encode( 251 445 &Header::default(), 252 446 &claims, 253 - &EncodingKey::from_secret(secret.as_bytes()) 254 - ).map_err(|e| AppError::Auth(e.to_string()))?; 447 + &EncodingKey::from_secret(secret.as_bytes()), 448 + ) 449 + .map_err(|e| AppError::Auth(e.to_string()))?; 255 450 256 451 Ok(HttpResponse::Ok().json(AuthResponse { 257 452 token, ··· 260 455 email: user.email, 261 456 }, 262 457 })) 263 - } 458 + } 459 + 460 + pub async fn edit_link( 461 + state: web::Data<AppState>, 462 + user: AuthenticatedUser, 463 + path: web::Path<i32>, 464 + payload: web::Json<CreateLink>, 465 + ) -> Result<impl Responder, AppError> { 466 + let link_id: i32 = path.into_inner(); 467 + 468 + // Validate the new URL if provided 469 + validate_url(&payload.url)?; 470 + 471 + // Validate custom code if provided 472 + if let Some(ref custom_code) = payload.custom_code { 473 + validate_custom_code(custom_code)?; 474 + 475 + // Check if the custom code is already taken by another link 476 + let existing_link = match &state.db { 477 + DatabasePool::Postgres(pool) => { 478 + sqlx::query_as::<_, Link>("SELECT * FROM links WHERE short_code = $1 AND id != $2") 479 + .bind(custom_code) 480 + .bind(link_id) 481 + .fetch_optional(pool) 482 + .await? 483 + } 484 + DatabasePool::Sqlite(pool) => { 485 + sqlx::query_as::<_, Link>("SELECT * FROM links WHERE short_code = ?1 AND id != ?2") 486 + .bind(custom_code) 487 + .bind(link_id) 488 + .fetch_optional(pool) 489 + .await? 490 + } 491 + }; 492 + 493 + if existing_link.is_some() { 494 + return Err(AppError::InvalidInput( 495 + "Custom code already taken".to_string(), 496 + )); 497 + } 498 + } 499 + 500 + // Update the link 501 + let updated_link = match &state.db { 502 + DatabasePool::Postgres(pool) => { 503 + let mut tx = pool.begin().await?; 504 + 505 + // First verify the link belongs to the user 506 + let link = 507 + sqlx::query_as::<_, Link>("SELECT * FROM links WHERE id = $1 AND user_id = $2") 508 + .bind(link_id) 509 + .bind(user.user_id) 510 + .fetch_optional(&mut *tx) 511 + .await?; 512 + 513 + if link.is_none() { 514 + return Err(AppError::NotFound); 515 + } 516 + 517 + // Update the link 518 + let updated = sqlx::query_as::<_, Link>( 519 + r#" 520 + UPDATE links 521 + SET 522 + original_url = $1, 523 + short_code = COALESCE($2, short_code) 524 + WHERE id = $3 AND user_id = $4 525 + RETURNING * 526 + "#, 527 + ) 528 + .bind(&payload.url) 529 + .bind(&payload.custom_code) 530 + .bind(link_id) 531 + .bind(user.user_id) 532 + .fetch_one(&mut *tx) 533 + .await?; 534 + 535 + // If source is provided, add a click record 536 + if let Some(ref source) = payload.source { 537 + sqlx::query("INSERT INTO clicks (link_id, source) VALUES ($1, $2)") 538 + .bind(link_id) 539 + .bind(source) 540 + .execute(&mut *tx) 541 + .await?; 542 + } 543 + 544 + tx.commit().await?; 545 + updated 546 + } 547 + DatabasePool::Sqlite(pool) => { 548 + let mut tx = pool.begin().await?; 549 + 550 + // First verify the link belongs to the user 551 + let link = 552 + sqlx::query_as::<_, Link>("SELECT * FROM links WHERE id = ?1 AND user_id = ?2") 553 + .bind(link_id) 554 + .bind(user.user_id) 555 + .fetch_optional(&mut *tx) 556 + .await?; 557 + 558 + if link.is_none() { 559 + return Err(AppError::NotFound); 560 + } 561 + 562 + // Update the link 563 + let updated = sqlx::query_as::<_, Link>( 564 + r#" 565 + UPDATE links 566 + SET 567 + original_url = ?1, 568 + short_code = COALESCE(?2, short_code) 569 + WHERE id = ?3 AND user_id = ?4 570 + RETURNING * 571 + "#, 572 + ) 573 + .bind(&payload.url) 574 + .bind(&payload.custom_code) 575 + .bind(link_id) 576 + .bind(user.user_id) 577 + .fetch_one(&mut *tx) 578 + .await?; 579 + 580 + // If source is provided, add a click record 581 + if let Some(ref source) = payload.source { 582 + sqlx::query("INSERT INTO clicks (link_id, source) VALUES (?1, ?2)") 583 + .bind(link_id) 584 + .bind(source) 585 + .execute(&mut *tx) 586 + .await?; 587 + } 588 + 589 + tx.commit().await?; 590 + updated 591 + } 592 + }; 593 + 594 + Ok(HttpResponse::Ok().json(updated_link)) 595 + } 596 + 597 + pub async fn delete_link( 598 + state: web::Data<AppState>, 599 + user: AuthenticatedUser, 600 + path: web::Path<i32>, 601 + ) -> Result<impl Responder, AppError> { 602 + let link_id: i32 = path.into_inner(); 603 + 604 + match &state.db { 605 + DatabasePool::Postgres(pool) => { 606 + let mut tx = pool.begin().await?; 607 + 608 + // Verify the link belongs to the user 609 + let link = sqlx::query_as::<Postgres, (i32,)>( 610 + "SELECT id FROM links WHERE id = $1 AND user_id = $2", 611 + ) 612 + .bind(link_id) 613 + .bind(user.user_id) 614 + .fetch_optional(&mut *tx) 615 + .await?; 616 + 617 + if link.is_none() { 618 + return Err(AppError::NotFound); 619 + } 620 + 621 + // Delete associated clicks first due to foreign key constraint 622 + sqlx::query("DELETE FROM clicks WHERE link_id = $1") 623 + .bind(link_id) 624 + .execute(&mut *tx) 625 + .await?; 626 + 627 + // Delete the link 628 + sqlx::query("DELETE FROM links WHERE id = $1") 629 + .bind(link_id) 630 + .execute(&mut *tx) 631 + .await?; 632 + 633 + tx.commit().await?; 634 + } 635 + DatabasePool::Sqlite(pool) => { 636 + let mut tx = pool.begin().await?; 637 + 638 + // Verify the link belongs to the user 639 + let link = sqlx::query_as::<Sqlite, (i32,)>( 640 + "SELECT id FROM links WHERE id = ? AND user_id = ?", 641 + ) 642 + .bind(link_id) 643 + .bind(user.user_id) 644 + .fetch_optional(&mut *tx) 645 + .await?; 646 + 647 + if link.is_none() { 648 + return Err(AppError::NotFound); 649 + } 650 + 651 + // Delete associated clicks first due to foreign key constraint 652 + sqlx::query("DELETE FROM clicks WHERE link_id = ?") 653 + .bind(link_id) 654 + .execute(&mut *tx) 655 + .await?; 656 + 657 + // Delete the link 658 + sqlx::query("DELETE FROM links WHERE id = ?") 659 + .bind(link_id) 660 + .execute(&mut *tx) 661 + .await?; 662 + 663 + tx.commit().await?; 664 + } 665 + } 666 + 667 + Ok(HttpResponse::NoContent().finish()) 668 + } 669 + 670 + pub async fn get_link_clicks( 671 + state: web::Data<AppState>, 672 + user: AuthenticatedUser, 673 + path: web::Path<i32>, 674 + ) -> Result<impl Responder, AppError> { 675 + let link_id = path.into_inner(); 676 + 677 + // First verify the link belongs to the user 678 + let link = match &state.db { 679 + DatabasePool::Postgres(pool) => { 680 + sqlx::query_as::<_, (i32,)>("SELECT id FROM links WHERE id = $1 AND user_id = $2") 681 + .bind(link_id) 682 + .bind(user.user_id) 683 + .fetch_optional(pool) 684 + .await? 685 + } 686 + DatabasePool::Sqlite(pool) => { 687 + sqlx::query_as::<_, (i32,)>("SELECT id FROM links WHERE id = ? AND user_id = ?") 688 + .bind(link_id) 689 + .bind(user.user_id) 690 + .fetch_optional(pool) 691 + .await? 692 + } 693 + }; 694 + 695 + if link.is_none() { 696 + return Err(AppError::NotFound); 697 + } 698 + 699 + let clicks = match &state.db { 700 + DatabasePool::Postgres(pool) => { 701 + sqlx::query_as::<_, ClickStats>( 702 + r#" 703 + SELECT 704 + DATE(created_at)::text as date, 705 + COUNT(*)::bigint as clicks 706 + FROM clicks 707 + WHERE link_id = $1 708 + GROUP BY DATE(created_at) 709 + ORDER BY DATE(created_at) ASC 710 + "#, 711 + ) 712 + .bind(link_id) 713 + .fetch_all(pool) 714 + .await? 715 + } 716 + DatabasePool::Sqlite(pool) => { 717 + sqlx::query_as::<_, ClickStats>( 718 + r#" 719 + SELECT 720 + DATE(created_at) as date, 721 + COUNT(*) as clicks 722 + FROM clicks 723 + WHERE link_id = ? 724 + GROUP BY DATE(created_at) 725 + ORDER BY DATE(created_at) ASC 726 + "#, 727 + ) 728 + .bind(link_id) 729 + .fetch_all(pool) 730 + .await? 731 + } 732 + }; 733 + 734 + Ok(HttpResponse::Ok().json(clicks)) 735 + } 736 + 737 + pub async fn get_link_sources( 738 + state: web::Data<AppState>, 739 + user: AuthenticatedUser, 740 + path: web::Path<i32>, 741 + ) -> Result<impl Responder, AppError> { 742 + let link_id = path.into_inner(); 743 + 744 + // Verify the link belongs to the user 745 + let link = match &state.db { 746 + DatabasePool::Postgres(pool) => { 747 + let mut tx = pool.begin().await?; 748 + let link = sqlx::query_as::<Postgres, (i32,)>( 749 + "SELECT id FROM links WHERE id = $1 AND user_id = $2", 750 + ) 751 + .bind(link_id) 752 + .bind(user.user_id) 753 + .fetch_optional(&mut *tx) 754 + .await?; 755 + tx.commit().await?; 756 + link 757 + } 758 + DatabasePool::Sqlite(pool) => { 759 + let mut tx = pool.begin().await?; 760 + let link = sqlx::query_as::<Sqlite, (i32,)>( 761 + "SELECT id FROM links WHERE id = ? AND user_id = ?", 762 + ) 763 + .bind(link_id) 764 + .bind(user.user_id) 765 + .fetch_optional(&mut *tx) 766 + .await?; 767 + tx.commit().await?; 768 + link 769 + } 770 + }; 771 + 772 + if link.is_none() { 773 + return Err(AppError::NotFound); 774 + } 775 + 776 + let sources = match &state.db { 777 + DatabasePool::Postgres(pool) => { 778 + sqlx::query_as::<_, SourceStats>( 779 + r#" 780 + SELECT 781 + DATE(created_at)::text as date, 782 + query_source as source, 783 + COUNT(*)::bigint as count 784 + FROM clicks 785 + WHERE link_id = $1 786 + AND query_source IS NOT NULL 787 + AND query_source != '' 788 + GROUP BY DATE(created_at), query_source 789 + ORDER BY DATE(created_at) ASC, COUNT(*) DESC 790 + "#, 791 + ) 792 + .bind(link_id) 793 + .fetch_all(pool) 794 + .await? 795 + } 796 + DatabasePool::Sqlite(pool) => { 797 + sqlx::query_as::<_, SourceStats>( 798 + r#" 799 + SELECT 800 + DATE(created_at) as date, 801 + query_source as source, 802 + COUNT(*) as count 803 + FROM clicks 804 + WHERE link_id = ? 805 + AND query_source IS NOT NULL 806 + AND query_source != '' 807 + GROUP BY DATE(created_at), query_source 808 + ORDER BY DATE(created_at) ASC, COUNT(*) DESC 809 + "#, 810 + ) 811 + .bind(link_id) 812 + .fetch_all(pool) 813 + .await? 814 + } 815 + }; 816 + 817 + Ok(HttpResponse::Ok().json(sources)) 818 + } 819 + 820 + pub async fn check_first_user(state: web::Data<AppState>) -> Result<impl Responder, AppError> { 821 + let user_count = match &state.db { 822 + DatabasePool::Postgres(pool) => { 823 + sqlx::query_as::<Postgres, (i64,)>("SELECT COUNT(*)::bigint FROM users") 824 + .fetch_one(pool) 825 + .await? 826 + .0 827 + } 828 + DatabasePool::Sqlite(pool) => { 829 + sqlx::query_as::<Sqlite, (i64,)>("SELECT COUNT(*) FROM users") 830 + .fetch_one(pool) 831 + .await? 832 + .0 833 + } 834 + }; 835 + 836 + Ok(HttpResponse::Ok().json(json!({ 837 + "isFirstUser": user_count == 0 838 + }))) 839 + }
+136
src/lib.rs
··· 1 + use anyhow::Result; 2 + use rand::Rng; 3 + use sqlx::migrate::MigrateDatabase; 4 + use sqlx::postgres::PgPoolOptions; 5 + use sqlx::{Postgres, Sqlite}; 6 + use std::fs::File; 7 + use std::io::Write; 8 + use tracing::info; 9 + 10 + use models::DatabasePool; 11 + 12 + pub mod auth; 13 + pub mod error; 14 + pub mod handlers; 15 + pub mod models; 16 + 17 + #[derive(Clone)] 18 + pub struct AppState { 19 + pub db: DatabasePool, 20 + pub admin_token: Option<String>, 21 + } 22 + 23 + pub async fn create_db_pool() -> Result<DatabasePool> { 24 + let database_url = std::env::var("DATABASE_URL").ok(); 25 + 26 + match database_url { 27 + Some(url) if url.starts_with("postgres://") || url.starts_with("postgresql://") => { 28 + info!("Using PostgreSQL database"); 29 + let pool = PgPoolOptions::new() 30 + .max_connections(5) 31 + .acquire_timeout(std::time::Duration::from_secs(3)) 32 + .connect(&url) 33 + .await?; 34 + 35 + Ok(DatabasePool::Postgres(pool)) 36 + } 37 + _ => { 38 + info!("No PostgreSQL connection string found, using SQLite"); 39 + 40 + // Get the project root directory 41 + let project_root = std::env::current_dir()?; 42 + let data_dir = project_root.join("data"); 43 + 44 + // Create a data directory if it doesn't exist 45 + if !data_dir.exists() { 46 + std::fs::create_dir_all(&data_dir)?; 47 + } 48 + 49 + let db_path = data_dir.join("simplelink.db"); 50 + let sqlite_url = format!("sqlite://{}", db_path.display()); 51 + 52 + // Check if database exists and create it if it doesn't 53 + if !Sqlite::database_exists(&sqlite_url).await.unwrap_or(false) { 54 + info!("Creating new SQLite database at {}", db_path.display()); 55 + Sqlite::create_database(&sqlite_url).await?; 56 + info!("Database created successfully"); 57 + } else { 58 + info!("Database already exists"); 59 + } 60 + 61 + let pool = sqlx::sqlite::SqlitePoolOptions::new() 62 + .max_connections(5) 63 + .connect(&sqlite_url) 64 + .await?; 65 + 66 + Ok(DatabasePool::Sqlite(pool)) 67 + } 68 + } 69 + } 70 + 71 + pub async fn run_migrations(pool: &DatabasePool) -> Result<()> { 72 + match pool { 73 + DatabasePool::Postgres(pool) => { 74 + // Use the root migrations directory for postgres 75 + sqlx::migrate!().run(pool).await?; 76 + } 77 + DatabasePool::Sqlite(pool) => { 78 + sqlx::migrate!("./migrations/sqlite").run(pool).await?; 79 + } 80 + } 81 + Ok(()) 82 + } 83 + 84 + pub async fn check_and_generate_admin_token(db: &DatabasePool) -> anyhow::Result<Option<String>> { 85 + // Check if any users exist 86 + let user_count = match db { 87 + DatabasePool::Postgres(pool) => { 88 + let mut tx = pool.begin().await?; 89 + let count = sqlx::query_as::<Postgres, (i64,)>("SELECT COUNT(*)::bigint FROM users") 90 + .fetch_one(&mut *tx) 91 + .await? 92 + .0; 93 + tx.commit().await?; 94 + count 95 + } 96 + DatabasePool::Sqlite(pool) => { 97 + let mut tx = pool.begin().await?; 98 + let count = sqlx::query_as::<Sqlite, (i64,)>("SELECT COUNT(*) FROM users") 99 + .fetch_one(&mut *tx) 100 + .await? 101 + .0; 102 + tx.commit().await?; 103 + count 104 + } 105 + }; 106 + 107 + if user_count == 0 { 108 + let token: String = (0..32) 109 + .map(|_| { 110 + let idx = rand::thread_rng().gen_range(0..62); 111 + match idx { 112 + 0..=9 => (b'0' + idx as u8) as char, 113 + 10..=35 => (b'a' + (idx - 10) as u8) as char, 114 + _ => (b'A' + (idx - 36) as u8) as char, 115 + } 116 + }) 117 + .collect(); 118 + 119 + // Get the project root directory 120 + let project_root = std::env::current_dir()?; 121 + let token_path = project_root.join("admin-setup-token.txt"); 122 + 123 + // Save token to file 124 + let mut file = File::create(token_path)?; 125 + writeln!(file, "{}", token)?; 126 + 127 + info!("No users found - generated admin setup token"); 128 + info!("Token has been saved to admin-setup-token.txt"); 129 + info!("Use this token to create the admin user"); 130 + info!("Admin setup token: {}", token); 131 + 132 + Ok(Some(token)) 133 + } else { 134 + Ok(None) 135 + } 136 + }
+209 -33
src/main.rs
··· 1 - use actix_web::{web, App, HttpServer}; 2 1 use actix_cors::Cors; 2 + use actix_web::{web, App, HttpResponse, HttpServer}; 3 3 use anyhow::Result; 4 - use sqlx::PgPool; 5 - use tracing::info; 4 + use clap::Parser; 5 + use rust_embed::RustEmbed; 6 + use simplelink::check_and_generate_admin_token; 7 + use simplelink::models::DatabasePool; 8 + use simplelink::{create_db_pool, run_migrations}; 9 + use simplelink::{handlers, AppState}; 10 + use sqlx::{Postgres, Sqlite}; 11 + use tracing::{error, info}; 12 + 13 + #[derive(Parser, Debug)] 14 + #[command(author, version, about, long_about = None)] 15 + #[derive(RustEmbed)] 16 + #[folder = "static/"] 17 + struct Asset; 18 + 19 + async fn serve_static_file(path: &str) -> HttpResponse { 20 + match Asset::get(path) { 21 + Some(content) => { 22 + let mime = mime_guess::from_path(path).first_or_octet_stream(); 23 + HttpResponse::Ok() 24 + .content_type(mime.as_ref()) 25 + .body(content.data.into_owned()) 26 + } 27 + None => HttpResponse::NotFound().body("404 Not Found"), 28 + } 29 + } 30 + 31 + async fn create_initial_links(pool: &DatabasePool) -> Result<()> { 32 + if let Ok(links) = std::env::var("INITIAL_LINKS") { 33 + for link_entry in links.split(';') { 34 + let parts: Vec<&str> = link_entry.split(',').collect(); 35 + if parts.len() >= 2 { 36 + let url = parts[0]; 37 + let code = parts[1]; 6 38 7 - mod error; 8 - mod handlers; 9 - mod models; 10 - mod auth; 39 + match pool { 40 + DatabasePool::Postgres(pool) => { 41 + sqlx::query( 42 + "INSERT INTO links (original_url, short_code, user_id) 43 + VALUES ($1, $2, $3) 44 + ON CONFLICT (short_code) 45 + DO UPDATE SET short_code = EXCLUDED.short_code 46 + WHERE links.original_url = EXCLUDED.original_url", 47 + ) 48 + .bind(url) 49 + .bind(code) 50 + .bind(1) 51 + .execute(pool) 52 + .await?; 53 + } 54 + DatabasePool::Sqlite(pool) => { 55 + // First check if the exact combination exists 56 + let exists = sqlx::query_scalar::<_, bool>( 57 + "SELECT EXISTS( 58 + SELECT 1 FROM links 59 + WHERE original_url = ?1 60 + AND short_code = ?2 61 + )", 62 + ) 63 + .bind(url) 64 + .bind(code) 65 + .fetch_one(pool) 66 + .await?; 11 67 12 - #[derive(Clone)] 13 - pub struct AppState { 14 - db: PgPool, 68 + // Only insert if the exact combination doesn't exist 69 + if !exists { 70 + sqlx::query( 71 + "INSERT INTO links (original_url, short_code, user_id) 72 + VALUES (?1, ?2, ?3)", 73 + ) 74 + .bind(url) 75 + .bind(code) 76 + .bind(1) 77 + .execute(pool) 78 + .await?; 79 + info!("Created initial link: {} -> {} for user_id: 1", code, url); 80 + } else { 81 + info!("Skipped existing link: {} -> {} for user_id: 1", code, url); 82 + } 83 + } 84 + } 85 + } 86 + } 87 + } 88 + Ok(()) 89 + } 90 + 91 + async fn create_admin_user(pool: &DatabasePool, email: &str, password: &str) -> Result<()> { 92 + use argon2::{ 93 + password_hash::{rand_core::OsRng, SaltString}, 94 + Argon2, PasswordHasher, 95 + }; 96 + 97 + let salt = SaltString::generate(&mut OsRng); 98 + let argon2 = Argon2::default(); 99 + let password_hash = argon2 100 + .hash_password(password.as_bytes(), &salt) 101 + .map_err(|e| anyhow::anyhow!("Password hashing error: {}", e))? 102 + .to_string(); 103 + 104 + match pool { 105 + DatabasePool::Postgres(pool) => { 106 + sqlx::query( 107 + "INSERT INTO users (email, password_hash) 108 + VALUES ($1, $2) 109 + ON CONFLICT (email) DO NOTHING", 110 + ) 111 + .bind(email) 112 + .bind(&password_hash) 113 + .execute(pool) 114 + .await?; 115 + } 116 + DatabasePool::Sqlite(pool) => { 117 + sqlx::query( 118 + "INSERT OR IGNORE INTO users (email, password_hash) 119 + VALUES (?1, ?2)", 120 + ) 121 + .bind(email) 122 + .bind(&password_hash) 123 + .execute(pool) 124 + .await?; 125 + } 126 + } 127 + info!("Created admin user: {}", email); 128 + Ok(()) 15 129 } 16 130 17 131 #[actix_web::main] ··· 22 136 // Initialize logging 23 137 tracing_subscriber::fmt::init(); 24 138 25 - // Database connection string from environment 26 - let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); 139 + // Create database connection pool 140 + let pool = create_db_pool().await?; 141 + run_migrations(&pool).await?; 142 + 143 + // First check if admin credentials are provided in environment variables 144 + let admin_credentials = match ( 145 + std::env::var("SIMPLELINK_USER"), 146 + std::env::var("SIMPLELINK_PASS"), 147 + ) { 148 + (Ok(user), Ok(pass)) => Some((user, pass)), 149 + _ => None, 150 + }; 151 + 152 + if let Some((email, password)) = admin_credentials { 153 + // Now check for existing users 154 + let user_count = match &pool { 155 + DatabasePool::Postgres(pool) => { 156 + let mut tx = pool.begin().await?; 157 + let count = 158 + sqlx::query_as::<Postgres, (i64,)>("SELECT COUNT(*)::bigint FROM users") 159 + .fetch_one(&mut *tx) 160 + .await? 161 + .0; 162 + tx.commit().await?; 163 + count 164 + } 165 + DatabasePool::Sqlite(pool) => { 166 + let mut tx = pool.begin().await?; 167 + let count = sqlx::query_as::<Sqlite, (i64,)>("SELECT COUNT(*) FROM users") 168 + .fetch_one(&mut *tx) 169 + .await? 170 + .0; 171 + tx.commit().await?; 172 + count 173 + } 174 + }; 27 175 28 - // Create database connection pool 29 - use sqlx::postgres::PgPoolOptions; 176 + if user_count == 0 { 177 + info!("No users found, creating admin user: {}", email); 178 + match create_admin_user(&pool, &email, &password).await { 179 + Ok(_) => info!("Successfully created admin user"), 180 + Err(e) => { 181 + error!("Failed to create admin user: {}", e); 182 + return Err(anyhow::anyhow!("Failed to create admin user: {}", e)); 183 + } 184 + } 185 + } 186 + } else { 187 + info!( 188 + "No admin credentials provided in environment variables, skipping admin user creation" 189 + ); 190 + } 30 191 31 - // In main(), replace the PgPool::connect with: 32 - let pool = PgPoolOptions::new() 33 - .max_connections(5) 34 - .acquire_timeout(std::time::Duration::from_secs(3)) 35 - .connect(&database_url) 36 - .await?; 192 + // Create initial links from environment variables 193 + create_initial_links(&pool).await?; 37 194 38 - // Run database migrations 39 - //sqlx::migrate!("./migrations").run(&pool).await?; 195 + let admin_token = check_and_generate_admin_token(&pool).await?; 40 196 41 - let state = AppState { db: pool }; 197 + let state = AppState { 198 + db: pool, 199 + admin_token, 200 + }; 42 201 43 - info!("Starting server at http://127.0.0.1:8080"); 202 + let host = std::env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); 203 + let port = std::env::var("SERVER_PORT").unwrap_or_else(|_| "8080".to_string()); 204 + info!("Starting server at http://{}:{}", host, port); 44 205 45 206 // Start HTTP server 46 207 HttpServer::new(move || { ··· 49 210 .allow_any_method() 50 211 .allow_any_header() 51 212 .max_age(3600); 52 - 213 + 53 214 App::new() 54 215 .wrap(cors) 55 216 .app_data(web::Data::new(state.clone())) ··· 57 218 web::scope("/api") 58 219 .route("/shorten", web::post().to(handlers::create_short_url)) 59 220 .route("/links", web::get().to(handlers::get_all_links)) 221 + .route("/links/{id}", web::delete().to(handlers::delete_link)) 222 + .route( 223 + "/links/{id}/clicks", 224 + web::get().to(handlers::get_link_clicks), 225 + ) 226 + .route( 227 + "/links/{id}/sources", 228 + web::get().to(handlers::get_link_sources), 229 + ) 230 + .route("/links/{id}", web::patch().to(handlers::edit_link)) 60 231 .route("/auth/register", web::post().to(handlers::register)) 61 - .route("/auth/login", web::post().to(handlers::login)), 62 - 63 - ) 64 - .service( 65 - web::resource("/{short_code}") 66 - .route(web::get().to(handlers::redirect_to_url)) 232 + .route("/auth/login", web::post().to(handlers::login)) 233 + .route( 234 + "/auth/check-first-user", 235 + web::get().to(handlers::check_first_user), 236 + ) 237 + .route("/health", web::get().to(handlers::health_check)), 67 238 ) 68 239 .service(web::resource("/{short_code}").route(web::get().to(handlers::redirect_to_url))) 240 + .default_service(web::route().to(|req: actix_web::HttpRequest| async move { 241 + let path = req.path().trim_start_matches('/'); 242 + let path = if path.is_empty() { "index.html" } else { path }; 243 + serve_static_file(path).await 244 + })) 69 245 }) 70 - .workers(2) // Limit worker threads 246 + .workers(2) 71 247 .backlog(10_000) 72 - .bind("127.0.0.1:8080")? 248 + .bind(format!("{}:{}", host, port))? 73 249 .run() 74 250 .await?; 75 251
-30
src/migrations/2025125_initial.sql
··· 1 - -- Create users table 2 - CREATE TABLE users ( 3 - id SERIAL PRIMARY KEY, 4 - email VARCHAR(255) NOT NULL UNIQUE, 5 - password_hash TEXT NOT NULL 6 - ); 7 - 8 - -- Create links table with user_id from the start 9 - CREATE TABLE links ( 10 - id SERIAL PRIMARY KEY, 11 - original_url TEXT NOT NULL, 12 - short_code VARCHAR(8) NOT NULL UNIQUE, 13 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 14 - clicks BIGINT NOT NULL DEFAULT 0, 15 - user_id INTEGER REFERENCES users(id) 16 - ); 17 - 18 - -- Create clicks table for tracking 19 - CREATE TABLE clicks ( 20 - id SERIAL PRIMARY KEY, 21 - link_id INTEGER REFERENCES links(id), 22 - source TEXT, 23 - query_source TEXT, 24 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 25 - ); 26 - 27 - -- Create indexes 28 - CREATE INDEX idx_short_code ON links(short_code); 29 - CREATE INDEX idx_user_id ON links(user_id); 30 - CREATE INDEX idx_link_id ON clicks(link_id);
+92 -8
src/models.rs
··· 1 + use anyhow::Result; 2 + use futures::future::BoxFuture; 3 + use serde::{Deserialize, Serialize}; 4 + use sqlx::postgres::PgRow; 5 + use sqlx::sqlite::SqliteRow; 6 + use sqlx::FromRow; 7 + use sqlx::Pool; 8 + use sqlx::Postgres; 9 + use sqlx::Sqlite; 10 + use sqlx::Transaction; 1 11 use std::time::{SystemTime, UNIX_EPOCH}; 2 12 3 - use serde::{Deserialize, Serialize}; 4 - use sqlx::FromRow; 13 + #[derive(Clone)] 14 + pub enum DatabasePool { 15 + Postgres(Pool<Postgres>), 16 + Sqlite(Pool<Sqlite>), 17 + } 18 + 19 + impl DatabasePool { 20 + pub async fn begin(&self) -> Result<Box<dyn std::any::Any + Send>> { 21 + match self { 22 + DatabasePool::Postgres(pool) => Ok(Box::new(pool.begin().await?)), 23 + DatabasePool::Sqlite(pool) => Ok(Box::new(pool.begin().await?)), 24 + } 25 + } 26 + 27 + pub async fn fetch_optional<T>(&self, pg_query: &str, sqlite_query: &str) -> Result<Option<T>> 28 + where 29 + T: for<'r> FromRow<'r, PgRow> + for<'r> FromRow<'r, SqliteRow> + Send + Sync + Unpin, 30 + { 31 + match self { 32 + DatabasePool::Postgres(pool) => { 33 + Ok(sqlx::query_as(pg_query).fetch_optional(pool).await?) 34 + } 35 + DatabasePool::Sqlite(pool) => { 36 + Ok(sqlx::query_as(sqlite_query).fetch_optional(pool).await?) 37 + } 38 + } 39 + } 40 + 41 + pub async fn execute(&self, pg_query: &str, sqlite_query: &str) -> Result<()> { 42 + match self { 43 + DatabasePool::Postgres(pool) => { 44 + sqlx::query(pg_query).execute(pool).await?; 45 + Ok(()) 46 + } 47 + DatabasePool::Sqlite(pool) => { 48 + sqlx::query(sqlite_query).execute(pool).await?; 49 + Ok(()) 50 + } 51 + } 52 + } 53 + 54 + pub async fn transaction<'a, F, R>(&'a self, f: F) -> Result<R> 55 + where 56 + F: for<'c> Fn(&'c mut Transaction<'_, Postgres>) -> BoxFuture<'c, Result<R>> 57 + + for<'c> Fn(&'c mut Transaction<'_, Sqlite>) -> BoxFuture<'c, Result<R>> 58 + + Copy, 59 + R: Send + 'static, 60 + { 61 + match self { 62 + DatabasePool::Postgres(pool) => { 63 + let mut tx = pool.begin().await?; 64 + let result = f(&mut tx).await?; 65 + tx.commit().await?; 66 + Ok(result) 67 + } 68 + DatabasePool::Sqlite(pool) => { 69 + let mut tx = pool.begin().await?; 70 + let result = f(&mut tx).await?; 71 + tx.commit().await?; 72 + Ok(result) 73 + } 74 + } 75 + } 76 + } 5 77 6 78 #[derive(Debug, Serialize, Deserialize)] 7 79 pub struct Claims { ··· 14 86 let exp = SystemTime::now() 15 87 .duration_since(UNIX_EPOCH) 16 88 .unwrap() 17 - .as_secs() as usize + 24 * 60 * 60; // 24 hours from now 18 - 19 - Self { 20 - sub: user_id, 21 - exp, 22 - } 89 + .as_secs() as usize 90 + + 14 * 24 * 60 * 60; // 2 weeks from now 91 + 92 + Self { sub: user_id, exp } 23 93 } 24 94 } 25 95 ··· 50 120 pub struct RegisterRequest { 51 121 pub email: String, 52 122 pub password: String, 123 + pub admin_token: Option<String>, 53 124 } 54 125 55 126 #[derive(Serialize)] ··· 70 141 pub email: String, 71 142 pub password_hash: String, 72 143 } 144 + 145 + #[derive(sqlx::FromRow, Serialize)] 146 + pub struct ClickStats { 147 + pub date: String, 148 + pub clicks: i64, 149 + } 150 + 151 + #[derive(sqlx::FromRow, Serialize)] 152 + pub struct SourceStats { 153 + pub date: String, 154 + pub source: String, 155 + pub count: i64, 156 + }
+175
test/.gitignore
··· 1 + # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 + 3 + # Logs 4 + 5 + logs 6 + _.log 7 + npm-debug.log_ 8 + yarn-debug.log* 9 + yarn-error.log* 10 + lerna-debug.log* 11 + .pnpm-debug.log* 12 + 13 + # Caches 14 + 15 + .cache 16 + 17 + # Diagnostic reports (https://nodejs.org/api/report.html) 18 + 19 + report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 20 + 21 + # Runtime data 22 + 23 + pids 24 + _.pid 25 + _.seed 26 + *.pid.lock 27 + 28 + # Directory for instrumented libs generated by jscoverage/JSCover 29 + 30 + lib-cov 31 + 32 + # Coverage directory used by tools like istanbul 33 + 34 + coverage 35 + *.lcov 36 + 37 + # nyc test coverage 38 + 39 + .nyc_output 40 + 41 + # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 + 43 + .grunt 44 + 45 + # Bower dependency directory (https://bower.io/) 46 + 47 + bower_components 48 + 49 + # node-waf configuration 50 + 51 + .lock-wscript 52 + 53 + # Compiled binary addons (https://nodejs.org/api/addons.html) 54 + 55 + build/Release 56 + 57 + # Dependency directories 58 + 59 + node_modules/ 60 + jspm_packages/ 61 + 62 + # Snowpack dependency directory (https://snowpack.dev/) 63 + 64 + web_modules/ 65 + 66 + # TypeScript cache 67 + 68 + *.tsbuildinfo 69 + 70 + # Optional npm cache directory 71 + 72 + .npm 73 + 74 + # Optional eslint cache 75 + 76 + .eslintcache 77 + 78 + # Optional stylelint cache 79 + 80 + .stylelintcache 81 + 82 + # Microbundle cache 83 + 84 + .rpt2_cache/ 85 + .rts2_cache_cjs/ 86 + .rts2_cache_es/ 87 + .rts2_cache_umd/ 88 + 89 + # Optional REPL history 90 + 91 + .node_repl_history 92 + 93 + # Output of 'npm pack' 94 + 95 + *.tgz 96 + 97 + # Yarn Integrity file 98 + 99 + .yarn-integrity 100 + 101 + # dotenv environment variable files 102 + 103 + .env 104 + .env.development.local 105 + .env.test.local 106 + .env.production.local 107 + .env.local 108 + 109 + # parcel-bundler cache (https://parceljs.org/) 110 + 111 + .parcel-cache 112 + 113 + # Next.js build output 114 + 115 + .next 116 + out 117 + 118 + # Nuxt.js build / generate output 119 + 120 + .nuxt 121 + dist 122 + 123 + # Gatsby files 124 + 125 + # Comment in the public line in if your project uses Gatsby and not Next.js 126 + 127 + # https://nextjs.org/blog/next-9-1#public-directory-support 128 + 129 + # public 130 + 131 + # vuepress build output 132 + 133 + .vuepress/dist 134 + 135 + # vuepress v2.x temp and cache directory 136 + 137 + .temp 138 + 139 + # Docusaurus cache and generated files 140 + 141 + .docusaurus 142 + 143 + # Serverless directories 144 + 145 + .serverless/ 146 + 147 + # FuseBox cache 148 + 149 + .fusebox/ 150 + 151 + # DynamoDB Local files 152 + 153 + .dynamodb/ 154 + 155 + # TernJS port file 156 + 157 + .tern-port 158 + 159 + # Stores VSCode versions used for testing VSCode extensions 160 + 161 + .vscode-test 162 + 163 + # yarn v2 164 + 165 + .yarn/cache 166 + .yarn/unplugged 167 + .yarn/build-state.yml 168 + .yarn/install-state.gz 169 + .pnp.* 170 + 171 + # IntelliJ based IDEs 172 + .idea 173 + 174 + # Finder (MacOS) folder config 175 + .DS_Store
+15
test/README.md
··· 1 + # test 2 + 3 + To install dependencies: 4 + 5 + ```bash 6 + bun install 7 + ``` 8 + 9 + To run: 10 + 11 + ```bash 12 + bun run index.ts 13 + ``` 14 + 15 + This project was created using `bun init` in bun v1.2.0. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
+32
test/bun.lock
··· 1 + { 2 + "lockfileVersion": 1, 3 + "workspaces": { 4 + "": { 5 + "name": "test", 6 + "dependencies": { 7 + "k6": "^0.0.0", 8 + }, 9 + "devDependencies": { 10 + "@types/bun": "latest", 11 + }, 12 + "peerDependencies": { 13 + "typescript": "^5.0.0", 14 + }, 15 + }, 16 + }, 17 + "packages": { 18 + "@types/bun": ["@types/bun@1.2.0", "", { "dependencies": { "bun-types": "1.2.0" } }, "sha512-5N1JqdahfpBlAv4wy6svEYcd/YfO2GNrbL95JOmFx8nkE6dbK4R0oSE5SpBA4vBRqgrOUAXF8Dpiz+gi7r80SA=="], 19 + 20 + "@types/node": ["@types/node@22.10.10", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-X47y/mPNzxviAGY5TcYPtYL8JsY3kAq2n8fMmKoRCxq/c4v4pyGNCzM2R6+M5/umG4ZfHuT+sgqDYqWc9rJ6ww=="], 21 + 22 + "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], 23 + 24 + "bun-types": ["bun-types@1.2.0", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-KEaJxyZfbV/c4eyG0vyehDpYmBGreNiQbZIqvVHJwZ4BmeuWlNZ7EAzMN2Zcd7ailmS/tGVW0BgYbGf+lGEpWw=="], 25 + 26 + "k6": ["k6@0.0.0", "", {}, "sha512-GAQSWayS2+LjbH5bkRi+pMPYyP1JSp7o+4j58ANZ762N/RH/SdlAT3CHHztnn8s/xgg8kYNM24Gd2IPo9b5W+g=="], 27 + 28 + "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], 29 + 30 + "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], 31 + } 32 + }
+1
test/index.ts
··· 1 + console.log("Hello via Bun!");
+39
test/mikubeam.js
··· 1 + import http from "k6/http"; 2 + import { check, sleep } from "k6"; 3 + 4 + // Test configuration 5 + export const options = { 6 + stages: [ 7 + { duration: "30s", target: 50 }, // Ramp up to 50 users 8 + { duration: "1m", target: 50 }, // Stay at 50 users for 1 minute 9 + { duration: "30s", target: 100 }, // Ramp up to 100 users 10 + { duration: "1m", target: 100 }, // Stay at 100 users for 1 minute 11 + { duration: "30s", target: 0 }, // Ramp down to 0 users 12 + ], 13 + thresholds: { 14 + http_req_duration: ["p(95)<500"], // 95% of requests should be below 500ms 15 + "checks{type:redirect}": ["rate>0.95"], // 95% success rate 16 + }, 17 + }; 18 + 19 + const SHORTENED_URL = "http://localhost:8080/mikubeam"; 20 + 21 + export default function () { 22 + const res = http.get(SHORTENED_URL, { 23 + tags: { type: "redirect" }, 24 + redirects: 0, // Don't follow redirects to measure just the redirect response 25 + }); 26 + 27 + // Check if we got a redirect status (307) 28 + check( 29 + res, 30 + { 31 + "status is 307": (r) => r.status === 307, 32 + "has location header": (r) => r.headers["Location"] !== undefined, 33 + }, 34 + { type: "redirect" } 35 + ); 36 + 37 + sleep(1); // Add some think time between requests 38 + } 39 +
+14
test/package.json
··· 1 + { 2 + "name": "test", 3 + "module": "index.ts", 4 + "type": "module", 5 + "devDependencies": { 6 + "@types/bun": "latest" 7 + }, 8 + "peerDependencies": { 9 + "typescript": "^5.0.0" 10 + }, 11 + "dependencies": { 12 + "k6": "^0.0.0" 13 + } 14 + }
+27
test/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + // Enable latest features 4 + "lib": ["ESNext", "DOM"], 5 + "target": "ESNext", 6 + "module": "ESNext", 7 + "moduleDetection": "force", 8 + "jsx": "react-jsx", 9 + "allowJs": true, 10 + 11 + // Bundler mode 12 + "moduleResolution": "bundler", 13 + "allowImportingTsExtensions": true, 14 + "verbatimModuleSyntax": true, 15 + "noEmit": true, 16 + 17 + // Best practices 18 + "strict": true, 19 + "skipLibCheck": true, 20 + "noFallthroughCasesInSwitch": true, 21 + 22 + // Some stricter flags (disabled by default) 23 + "noUnusedLocals": false, 24 + "noUnusedParameters": false, 25 + "noPropertyAccessFromIndexSignature": false 26 + } 27 + }