+5
.env.example
+5
.env.example
+70
.github/workflows/docker-image.yml
+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
+10
.gitignore
+35
.sqlx/query-2adc9fa303079a3e9c28bbf0565c1ac60eea3a5c37e34fc6a0cb6e151c325382.json
+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
+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
+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
+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
+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
+14
.sqlx/query-d5fcbbb502138662f670111479633938368ca7a29212cf4f72567efc4cd81a85.json
+14
.sqlx/query-eeec02400984de4ad69b0b363ce911e74c5684121d0340a97ca8f1fa726774d5.json
+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
+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
+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
+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
+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
+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
+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
+

6
+
7
+

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
+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
+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
-
-622
frontend/bun.lock
-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
+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
+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
+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
+5
frontend/postcss.config.js
-1
frontend/public/vite.svg
-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
-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
+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
+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
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
+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
+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
+
}
+99
-66
frontend/src/components/LinkForm.tsx
+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
+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
+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
+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
+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
+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
+
}
+76
frontend/src/components/ui/card.tsx
+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
+13
frontend/src/components/ui/container.tsx
+120
frontend/src/components/ui/dialog.tsx
+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
+
}
+178
frontend/src/components/ui/form.tsx
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+6
frontend/src/lib/utils.ts
+26
frontend/src/types/api.ts
+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
+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
+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
+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
+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
+3
migrations/20250219000000_extend_short_code.sql
+42
migrations/sqlite/20250125000000_init.sql
+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
readme_img/mainview.jpg
This is a binary file and will not be displayed.
readme_img/statview.jpg
readme_img/statview.jpg
This is a binary file and will not be displayed.
+8
-7
src/auth.rs
+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
+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
+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
+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
-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
+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
+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
+15
test/README.md
+32
test/bun.lock
+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
test/index.ts
···
1
+
console.log("Hello via Bun!");
+39
test/mikubeam.js
+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
+14
test/package.json
+27
test/tsconfig.json
+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
+
}